Standardize Settings layout and save feedback (#2122)

* Group settings navigation menu

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Revert "Group settings navigation menu"

This reverts commit 5983a4e82074b8dab1084af1cadd803ba28ea65d.

* Standardize settings layout feedback

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Fix settings save status timer typing

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Retain settings save status

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Report color mode save state

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Match Appearance settings to two-column layout

Rebuild Appearance into Color mode & Theme, Localization, and Density & type sections with responsive two-column grids, consistent section headers, page description, and green save status.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Migrate settings pages to SettingsPageLayout and SettingsSection

Replace ScrollableOverlay/max-w-3xl shells with the shared settings
layout primitives across entity and static settings pages, normalize
section headers, and add settings.page.behavior.description locales.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Unify remaining settings pages on shared section chrome

Wire Appearance, Projects, and Remote Instances through SettingsSection/SettingsPageLayout so every settings surface shares the same header, divider, and page shell treatment.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Align settings UI with OpenChamber design system kit

Apply kit geometry and control specs: 840px content width, 32/48 padding, fixed 260/280 sidebars, radius/spacing tokens, settings select height, stepper dimensions, and shared field/link typography.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Migrate OpenChamberVisualSettings to shared settings primitives

Replace ad-hoc radio/checkbox/chip/field layouts with SettingsSection
shared chrome for Appearance, Density, Navigation, Chat/behavior, and
Privacy while preserving handlers and data-settings-item anchors.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Migrate settings pages to shared field/checkbox/radio primitives

Unify Defaults, Notifications, Behavior, Git, Session Retention, Passkeys,
OpenCode CLI, Commands, About, Keyboard Shortcuts, and Desktop Network on
SettingsFieldRow / SettingsCheckboxRow / SettingsRadioGroup / SettingsChipGroup
for consistent grid, spacing, and DRY layout. Also remove the GitPage double
SettingsSection wrap around GitHubSettings.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Unify remaining settings pages onto shared field primitives

Migrate Agents, Snippets, Skills, Usage, MCP identity rows and selects to
SettingsFieldRow/CheckboxRow/ChipGroup and SETTINGS_SELECT_*; align page
titles; light-touch Voice/Tunnel/Providers/Plugins without rewriting
complex OAuth, permissions, or tunnel flows.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Polish settings section dividers and transport helper text

Slightly stronger section borders for clearer group separation, and keep
message-stream transport description under the chip control.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Fix settings grid alignment, control heights, and Chat section titles

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Tighten settings grid: flat Chat 2x2 and full-width stacked selects

Message options use a flat two-column grid so row headers share a baseline.
Stacked selects fill their column; field-row selects keep a fixed sm:w-56 width.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Align mixed settings controls to shared FieldRow/CheckboxRow grid

Separate FieldRows from CheckboxRows with SettingsInset, move enum
radios into ControlGroups, and convert misplaced StackedFields to
full-width FieldRows so left edges no longer clash.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Translate Behavior response-style preset labels for es and pl

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Group settings nav into categories and improve icons/order

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Fix settings layout: fewer hrs, shared control widths, chat transport section

Remove SettingsInset top borders, align App install/Density controls to full cluster width, give Message Stream Transport its own Chat section, and fold Sessions Small Model into the first section to cut extra dividers.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Align Voice and MCP settings labels with shared heading classes

Swap form field labels to SETTINGS_FIELD_LABEL_CLASS and use
SettingsGroupTitle for MCP control-group headings (manual auth fallback,
request headers).

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Differentiate settings heading levels by context

Page titles are larger and quieter than section titles; group and field
labels use dedicated shared classes so hierarchy is consistent across
settings surfaces without ad-hoc typography mixes.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Use shared settings title classes in SettingsView home

Wire home and unavailable headings through the shared L1/L2 class
constants so they stay aligned with SettingsPageLayout.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Keep settings number steppers from stretching +/- buttons

Density & type NumberInputs no longer flex-grow across the row, and
NumberInput locks minus/plus to fixed width so the plus side cannot
inflate when the control is placed in a full-width cluster.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Place spacing and input-bar offset on their own row

Density & type now lays out as font families, then font sizes, then
Spacing Density / Input Bar Offset on the row below.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Regroup settings nav and improve Voice layout

Drop Advanced/Usage/Git nav categories: Voice and About under
Interface, Usage under OpenCode, Git under Workspace. Voice provider
chips and STT model cards use shared settings primitives with roomier
spacing and a two-column model grid.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Space out chat feature groups in visual settings

* Polish settings: save-state wiring, container-query layouts, unified dropdown triggers

- Wire shared save indicator into Behavior page saves and git identity CRUD
- Convert settings layout primitives and page rows from viewport to container
  queries so narrow panes stack instead of clipping
- Unify custom dropdown triggers (model/agent/provider pickers) and remaining
  page selects on the settings control size
- Bump global radius scale by 1px; align variant input with select size
- Migrate stray raw controls (MCP OAuth checkbox, worktree remove button,
  git identity dialog rows, skills dialog labels) onto shared primitives
- Make settings nav items span full sidebar width; trim nav to 240px

* Add General settings page, regroup nav, cap control widths, promote chat feature headers

* Mobile settings nav: plain background and touch-sized rows

* Hide secondary settings descriptions behind clickable info hints

* Move quota credentials to Usage, navigation settings to General, rename External Tunnel

* Quiet settings save indicator: silent success, delayed spinner, visible errors

* Rewrite settings-ui-patterns skill around shared primitives and refactored conventions

* Remove settings starter page; open last visited page, defaulting to General

* Settings polish: spacing, control sizes, trigger widths, readable model names, device dates

* Centralize dropdown trigger chrome, settings nav polish, mobile-only input bar offset

* Fix global line-height regression, auto-hide first-section divider, shortcut row spacing

* Global line-height 1.45, align scheduled tasks header button with select

* Hide editor toolbar and About in VS Code, animate chat render preview outside desktop dialog

* Rebuild agent tool permissions on source-of-truth model

Edit the agent's own permission map verbatim (inherit vs explicit actions, pattern rules only for pattern-capable keys), save permission-only, drop the server-side non-wildcard re-merge that resurrected deleted rules, and surface session-granted rules as read-only.

* Agents model parameters polish: row spacing, variant dropdown, unified widths, dash for unset numbers

---------

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
Serhii Dziupin
2026-07-18 00:11:05 +03:00
committed by GitHub
co-authored by Serhii Dziupin Bohdan Triapitsyn
parent 1b402fcb67
commit 17f1b24709
103 changed files with 6406 additions and 6110 deletions
@@ -131,6 +131,7 @@ export const iconSpriteData = {
"heart": `<path d="M12.001 4.52853C14.35 2.42 17.98 2.49 20.2426 4.75736C22.5053 7.02472 22.583 10.637 20.4786 12.993L11.9999 21.485L3.52138 12.993C1.41705 10.637 1.49571 7.01901 3.75736 4.75736C6.02157 2.49315 9.64519 2.41687 12.001 4.52853ZM18.827 6.1701C17.3279 4.66794 14.9076 4.60701 13.337 6.01687L12.0019 7.21524L10.6661 6.01781C9.09098 4.60597 6.67506 4.66808 5.17157 6.17157C3.68183 7.66131 3.60704 10.0473 4.97993 11.6232L11.9999 18.6543L19.0201 11.6232C20.3935 10.0467 20.319 7.66525 18.827 6.1701Z" fill="currentColor"/>`,
"history": `<path d="M12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12H4C4 16.4183 7.58172 20 12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C9.25022 4 6.82447 5.38734 5.38451 7.50024L8 7.5V9.5H2V3.5H4L3.99989 5.99918C5.82434 3.57075 8.72873 2 12 2ZM13 7L12.9998 11.585L16.2426 14.8284L14.8284 16.2426L10.9998 12.413L11 7H13Z" fill="currentColor"/>`,
"home": `<path d="M21 20C21 20.5523 20.5523 21 20 21H4C3.44772 21 3 20.5523 3 20V9.48907C3 9.18048 3.14247 8.88917 3.38606 8.69972L11.3861 2.47749C11.7472 2.19663 12.2528 2.19663 12.6139 2.47749L20.6139 8.69972C20.8575 8.88917 21 9.18048 21 9.48907V20ZM19 19V9.97815L12 4.53371L5 9.97815V19H19Z" fill="currentColor"/>`,
"home-office": `<path d="M12.6727 1.61162 20.7999 9H17.8267L12 3.70302 6 9.15757V19.0001H11V21.0001H5C4.44772 21.0001 4 20.5524 4 20.0001V11.0001L1 11.0001 11.3273 1.61162C11.7087 1.26488 12.2913 1.26488 12.6727 1.61162ZM14 11H23V18H14V11ZM16 13V16H21V13H16ZM24 21H13V19H24V21Z" fill="currentColor"/>`,
"hourglass-fill": `<path d="M6 4H4V2H20V4H18V6C18 7.61543 17.1838 8.91468 16.1561 9.97667C15.4532 10.703 14.598 11.372 13.7309 12C14.598 12.628 15.4532 13.297 16.1561 14.0233C17.1838 15.0853 18 16.3846 18 18V20H20V22H4V20H6V18C6 16.3846 6.81616 15.0853 7.8439 14.0233C8.54682 13.297 9.40202 12.628 10.2691 12C9.40202 11.372 8.54682 10.703 7.8439 9.97667C6.81616 8.91468 6 7.61543 6 6V4ZM8 4V6C8 6.68514 8.26026 7.33499 8.77131 8H15.2287C15.7397 7.33499 16 6.68514 16 6V4H8ZM12 13.2219C10.9548 13.9602 10.008 14.663 9.2811 15.4142C9.09008 15.6116 8.92007 15.8064 8.77131 16H15.2287C15.0799 15.8064 14.9099 15.6116 14.7189 15.4142C13.992 14.663 13.0452 13.9602 12 13.2219Z" fill="currentColor"/>`,
"hourglass": `<path d="M6 4H4V2H20V4H18V6C18 7.61543 17.1838 8.91468 16.1561 9.97667C15.4532 10.703 14.598 11.372 13.7309 12C14.598 12.628 15.4532 13.297 16.1561 14.0233C17.1838 15.0853 18 16.3846 18 18V20H20V22H4V20H6V18C6 16.3846 6.81616 15.0853 7.8439 14.0233C8.54682 13.297 9.40202 12.628 10.2691 12C9.40202 11.372 8.54682 10.703 7.8439 9.97667C6.81616 8.91468 6 7.61543 6 6V4ZM8 4V6C8 6.88457 8.43384 7.71032 9.2811 8.58583C10.008 9.33699 10.9548 10.0398 12 10.7781C13.0452 10.0398 13.992 9.33699 14.7189 8.58583C15.5662 7.71032 16 6.88457 16 6V4H8ZM12 13.2219C10.9548 13.9602 10.008 14.663 9.2811 15.4142C8.43384 16.2897 8 17.1154 8 18V20H16V18C16 17.1154 15.5662 16.2897 14.7189 15.4142C13.992 14.663 13.0452 13.9602 12 13.2219Z" fill="currentColor"/>`,
"image-download": `<path d="M21 15V19H24L20 23L16 19H19V15H21ZM21.0078 3C21.5555 3 21.9999 3.44482 22 3.99316V13H20V5H4V18.999L14 9L17 12V14.8291L14 11.8281L6.82715 19H14V21H2.99219C2.44451 21 2.00013 20.5552 2 20.0068V3.99316C2.00013 3.44463 2.45577 3 2.99219 3H21.0078ZM8 7C9.10457 7 10 7.89543 10 9C10 10.1046 9.10457 11 8 11C6.89543 11 6 10.1046 6 9C6 7.89543 6.89543 7 8 7Z" fill="currentColor"/>`,
@@ -92,10 +92,7 @@ export const AgentSelector: React.FC<AgentSelectorProps> = ({
<SelectTrigger
id={id}
size="lg"
className={cn(
'max-w-full typography-meta text-foreground !border-border/80 !bg-[var(--surface-subtle)] hover:!bg-[var(--interactive-hover)]/70 data-[popup-open]:!bg-[var(--interactive-active)]/70',
className,
)}
className={cn('max-w-full', className)}
>
<SelectValue placeholder={t('multirun.agentSelector.placeholder')} />
</SelectTrigger>
@@ -9,6 +9,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { useGitStore, useGitBranches, useGitLoadingBranches, useGitLoadingStatus, useIsGitRepo } from '@/stores/useGitStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
@@ -171,7 +172,7 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
<SelectTrigger
id={id}
size="lg"
className={className ?? 'w-fit typography-meta text-foreground'}
className={cn('min-w-0 max-w-full *:data-[slot=select-value]:truncate', className ?? 'w-fit')}
>
<SelectValue placeholder={isLoading ? t('multiRun.branchSelector.status.loadingBranches') : t('multiRun.branchSelector.placeholder.selectSourceBranch')} />
</SelectTrigger>
@@ -1,5 +1,5 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { Icon } from "@/components/icon/Icon";
@@ -229,14 +229,13 @@ export const ModelMultiSelect: React.FC<ModelMultiSelectProps> = ({
<div className="flex flex-wrap gap-1.5 items-center">
{/* Add model button (dropdown trigger) */}
<div className={cn('relative', containerClassName)} ref={dropdownRef}>
<Button
<button
ref={triggerRef}
type="button"
variant="outline"
size="sm"
data-popup-open={isOpen ? '' : undefined}
className={cn(
CHIP_HEIGHT_CLASS,
'!border-border/80 !bg-[var(--surface-subtle)] hover:!bg-[var(--interactive-hover)]/70',
dropdownTriggerVariants({ size: 'default' }),
'w-fit',
addButtonClassName,
)}
disabled={!canAddModel}
@@ -246,7 +245,7 @@ export const ModelMultiSelect: React.FC<ModelMultiSelectProps> = ({
>
{triggerIcon ?? <Icon name="add" className="h-3.5 w-3.5 mr-1" />}
{addButtonLabel ?? t('multirun.modelMultiSelect.actions.addModel')}
</Button>
</button>
{isOpen ? (
<div
@@ -324,8 +323,8 @@ export const ModelMultiSelect: React.FC<ModelMultiSelectProps> = ({
}}
>
<SelectTrigger
size="chip"
className="px-2 gap-1.5 rounded-md !border-border/80 !bg-[var(--surface-subtle)] hover:!bg-[var(--interactive-hover)]/70 typography-meta font-medium text-foreground"
size="sm"
className="gap-1.5"
>
<Icon name="brain-ai-3"
className={cn(
@@ -211,7 +211,7 @@ export function MultiRunFusionDialog({
maxModels={1}
addButtonLabel={selectedModelLabel}
showChips={false}
addButtonClassName="h-8 w-fit max-w-[min(28rem,calc(100vw-8rem))] justify-start rounded-[9px] [corner-shape:squircle] supports-[corner-shape:squircle]:rounded-[50px] px-3 py-1.5"
addButtonClassName="w-fit max-w-[min(28rem,calc(100vw-8rem))] justify-start"
dropdownSide="bottom"
dropdownClassName="w-[min(28rem,calc(100vw-8rem))]"
triggerIcon={providerID ? <ProviderLogo providerId={providerID} className="h-3.5 w-3.5 mr-1" /> : undefined}
@@ -220,7 +220,7 @@ export function MultiRunFusionDialog({
{variantKeys.length > 0 ? (
<Select value={variant || '__default__'} onValueChange={(value) => setVariant(value === '__default__' ? '' : value)}>
<SelectTrigger size="lg" className="h-8 w-fit rounded-[9px] [corner-shape:squircle] supports-[corner-shape:squircle]:rounded-[50px] !border-border/80 !bg-[var(--surface-subtle)] hover:!bg-[var(--interactive-hover)]/70 typography-meta font-medium text-foreground px-3 py-1.5">
<SelectTrigger size="lg" className="w-fit">
<Icon name="brain-ai-3" className="h-3.5 w-3.5 text-muted-foreground" />
<SelectValue>{(value) => value === '__default__' ? t('multirun.modelMultiSelect.variant.default') : value}</SelectValue>
</SelectTrigger>
@@ -231,7 +231,7 @@ export function MultiRunFusionDialog({
</Select>
) : null}
<AgentSelector value={agent} onChange={setAgent} portalToBody className="h-8 rounded-[9px] [corner-shape:squircle] supports-[corner-shape:squircle]:rounded-[50px] px-3 py-1.5" />
<AgentSelector value={agent} onChange={setAgent} portalToBody />
</div>
<div className="space-y-2">
@@ -0,0 +1,58 @@
import { describe, expect, test } from 'bun:test';
import { parsePermissionConfig, serializePermissionModel } from './agentPermissionModel';
const roundTrip = (config: unknown) => serializePermissionModel(parsePermissionConfig(config));
describe('agent permission source round-trip', () => {
test('null and empty configs stay unset', () => {
expect(roundTrip(null)).toBeNull();
expect(roundTrip(undefined)).toBeNull();
expect(roundTrip({})).toBeNull();
});
test('bare action string becomes the * key', () => {
expect(roundTrip('ask')).toEqual({ '*': 'ask' });
});
test('flat per-key actions survive verbatim', () => {
const config = { '*': 'allow', bash: 'ask', edit: 'deny' };
expect(roundTrip(config)).toEqual(config);
});
test('nested pattern maps survive verbatim, including wildcard inside', () => {
const config = {
'*': 'ask',
bash: { '*': 'ask', 'rm -rf *': 'deny', 'git status': 'allow' },
external_directory: { '/tmp/**': 'allow' },
};
expect(roundTrip(config)).toEqual(config);
});
test('pattern-only key without wildcard stays pattern-only (no synthesized default)', () => {
const config = { read: { '/secret/**': 'deny' } };
expect(roundTrip(config)).toEqual(config);
});
test('explicit allow is preserved — not conflated with unset', () => {
expect(roundTrip({ bash: 'allow' })).toEqual({ bash: 'allow' });
expect(roundTrip({ '*': 'allow' })).toEqual({ '*': 'allow' });
});
test('unknown junk values are dropped, valid siblings kept', () => {
expect(roundTrip({ bash: 'ask', broken: 42, worse: ['deny'] })).toEqual({ bash: 'ask' });
});
test('clearing everything serializes to null (key removed from config)', () => {
const model = parsePermissionConfig({ bash: 'ask' });
model.keys = {};
model.global = null;
expect(serializePermissionModel(model)).toBeNull();
});
test('blank patterns are not persisted', () => {
const model = parsePermissionConfig({});
model.keys.bash = { action: 'ask', patterns: [{ pattern: ' ', action: 'deny' }] };
expect(serializePermissionModel(model)).toEqual({ bash: 'ask' });
});
});
@@ -0,0 +1,504 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Icon } from '@/components/icon/Icon';
import { toast } from '@/components/ui';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { opencodeClient } from '@/lib/opencode/client';
import {
useAgentsStore,
getConfigDirectory,
type AgentWithExtras,
} from '@/stores/useAgentsStore';
import {
SettingsSection,
SettingsChipGroup,
SETTINGS_FIELD_LABEL_CLASS,
SETTINGS_HELPER_CLASS,
} from '@/components/sections/shared/SettingsSection';
import { SettingsInfoHint } from '@/components/sections/shared/SettingsInfoHint';
import {
ACTIONS,
cloneModel,
emptyModel,
isAction,
modelsEqual,
parsePermissionConfig,
serializePermissionModel,
type Action,
type EffectiveRule,
type PermissionModel,
} from './agentPermissionModel';
/**
* Source-of-truth permissions editor.
*
* This component edits EXACTLY the agent's own `permission` map as stored in
* its markdown frontmatter / opencode.json entry — never the resolved rules
* that `/agent` returns (those already include global config and one-off
* session grants, and writing them back is what used to corrupt configs).
*
* - "Inherit" means the key is absent from the agent's config; the effective
* action (from the resolved view) is shown as a hint.
* - Saving PATCHes only `{ permission }`, and the server writes it verbatim.
*/
/**
* Permission keys that exist beyond plain tool ids (virtual capabilities).
* Shown so they are discoverable; nothing is written unless set explicitly.
*/
const VIRTUAL_PERMISSION_KEYS = [
'edit',
'external_directory',
'doom_loop',
'plan_enter',
'plan_exit',
] as const;
/**
* Keys where opencode matches pattern rules (per docs: these accept either a
* bare action or a pattern map). Everything else is action-only — the pattern
* UI is hidden unless the config already contains patterns for the key.
*/
const PATTERN_CAPABLE_KEYS = new Set([
'read',
'edit',
'glob',
'grep',
'list',
'bash',
'task',
'external_directory',
'lsp',
'skill',
]);
/** Tool ids folded into broader permission keys — never shown standalone. */
const FOLDED_TOOL_IDS = new Set(['write', 'patch', 'apply_patch', 'multiedit', 'invalid']);
const formatKeyLabel = (key: string): string =>
key
.split(/[_-]/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
interface AgentPermissionsEditorProps {
agent: AgentWithExtras;
}
export const AgentPermissionsEditor: React.FC<AgentPermissionsEditorProps> = ({ agent }) => {
const { t } = useI18n();
const updateAgent = useAgentsStore((state) => state.updateAgent);
const [baseline, setBaseline] = React.useState<PermissionModel>(emptyModel);
const [model, setModel] = React.useState<PermissionModel>(emptyModel);
const [isLoading, setIsLoading] = React.useState(true);
const [loadFailed, setLoadFailed] = React.useState(false);
const [isSaving, setIsSaving] = React.useState(false);
const [expandedKeys, setExpandedKeys] = React.useState<Record<string, boolean>>({});
const [toolIds, setToolIds] = React.useState<string[]>([]);
const [customKeyDraft, setCustomKeyDraft] = React.useState('');
const [reloadToken, setReloadToken] = React.useState(0);
const agentName = agent.name;
// --- Load the SOURCE permission map (the agent's own config file). ---
React.useEffect(() => {
let cancelled = false;
setIsLoading(true);
setLoadFailed(false);
void (async () => {
try {
const directory = getConfigDirectory();
const query = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await runtimeFetch(`/api/config/agents/${encodeURIComponent(agentName)}/config${query}`, {
headers: {
'Cache-Control': 'no-cache',
...(directory ? { 'x-opencode-directory': directory } : {}),
},
});
if (!response.ok) throw new Error(String(response.status));
const data = (await response.json().catch(() => null)) as { config?: { permission?: unknown } } | null;
if (cancelled) return;
const parsed = parsePermissionConfig(data?.config?.permission);
setBaseline(cloneModel(parsed));
setModel(parsed);
} catch {
if (!cancelled) setLoadFailed(true);
} finally {
if (!cancelled) setIsLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [agentName, reloadToken]);
// --- Known tool ids for the key list (display only). ---
React.useEffect(() => {
let cancelled = false;
void (async () => {
try {
const ids = await opencodeClient.listToolIds({ directory: getConfigDirectory() });
if (!cancelled && Array.isArray(ids)) {
setToolIds(ids.filter((id) => typeof id === 'string' && !FOLDED_TOOL_IDS.has(id)));
}
} catch {
// tool ids are additive display data — the editor works without them
}
})();
return () => {
cancelled = true;
};
}, [agentName]);
// --- Effective rules from the resolved view (read-only hints). ---
const effectiveRules = React.useMemo<EffectiveRule[]>(() => {
const raw = (agent as { permission?: unknown }).permission;
if (!Array.isArray(raw)) return [];
const rules: EffectiveRule[] = [];
for (const entry of raw) {
if (!entry || typeof entry !== 'object') continue;
const { permission, pattern, action } = entry as Record<string, unknown>;
if (typeof permission === 'string' && typeof pattern === 'string' && isAction(action)) {
rules.push({ permission, pattern, action });
}
}
return rules;
}, [agent]);
const effectiveFor = React.useCallback((key: string): Action | null => {
const exact = effectiveRules.find((rule) => rule.permission === key && rule.pattern === '*');
if (exact) return exact.action;
const wildcard = effectiveRules.find((rule) => rule.permission === '*' && rule.pattern === '*');
return wildcard ? wildcard.action : null;
}, [effectiveRules]);
/** Session/runtime-granted rules that are NOT part of the saved config. */
const runtimeRulesFor = React.useCallback((key: string): EffectiveRule[] => {
const saved = model.keys[key]?.patterns ?? [];
const savedPatterns = new Set(saved.map((rule) => rule.pattern));
return effectiveRules.filter(
(rule) => rule.permission === key && rule.pattern !== '*' && !savedPatterns.has(rule.pattern),
);
}, [effectiveRules, model.keys]);
// --- Displayed key list: tools + virtual keys + anything set in the config. ---
const displayKeys = React.useMemo(() => {
const keys = new Set<string>();
for (const id of toolIds) keys.add(id);
for (const key of VIRTUAL_PERMISSION_KEYS) keys.add(key);
for (const key of Object.keys(model.keys)) keys.add(key);
// `edit` covers write/edit/apply_patch — the folded ids never show.
for (const folded of FOLDED_TOOL_IDS) keys.delete(folded);
return Array.from(keys).sort((a, b) => a.localeCompare(b));
}, [toolIds, model.keys]);
const isDirty = React.useMemo(() => !modelsEqual(model, baseline), [model, baseline]);
// --- Mutators ---
const setGlobal = (action: Action | null) => {
setModel((current) => ({ ...current, global: action }));
};
const setKeyAction = (key: string, action: Action | null) => {
setModel((current) => {
const next = cloneModel(current);
const state = next.keys[key] ?? { action: null, patterns: [] };
state.action = action;
if (state.action === null && state.patterns.length === 0) {
delete next.keys[key];
} else {
next.keys[key] = state;
}
return next;
});
};
const setPattern = (key: string, index: number, pattern: string, action: Action) => {
setModel((current) => {
const next = cloneModel(current);
const state = next.keys[key] ?? { action: null, patterns: [] };
state.patterns[index] = { pattern, action };
next.keys[key] = state;
return next;
});
};
const addPattern = (key: string) => {
setModel((current) => {
const next = cloneModel(current);
const state = next.keys[key] ?? { action: null, patterns: [] };
state.patterns.push({ pattern: '', action: 'allow' });
next.keys[key] = state;
return next;
});
setExpandedKeys((current) => ({ ...current, [key]: true }));
};
const removePattern = (key: string, index: number) => {
setModel((current) => {
const next = cloneModel(current);
const state = next.keys[key];
if (!state) return current;
state.patterns.splice(index, 1);
if (state.action === null && state.patterns.length === 0) {
delete next.keys[key];
}
return next;
});
};
const addCustomKey = () => {
const key = customKeyDraft.trim();
if (!key || key === '*') return;
setModel((current) => {
if (current.keys[key]) return current;
const next = cloneModel(current);
next.keys[key] = { action: 'ask', patterns: [] };
return next;
});
setExpandedKeys((current) => ({ ...current, [key]: true }));
setCustomKeyDraft('');
};
const handleSave = async () => {
setIsSaving(true);
try {
const permission = serializePermissionModel(model);
const result = await updateAgent(agentName, { permission });
if (result.ok) {
setBaseline(cloneModel(model));
toast.success(
result.requiresManualRestart
? t('settings.agents.page.permissionsEditor.toast.savedRestartRequired')
: t('settings.agents.page.permissionsEditor.toast.saved'),
);
} else {
toast.error(t('settings.agents.page.permissionsEditor.toast.saveFailed'));
}
} finally {
setIsSaving(false);
}
};
const handleDiscard = () => {
setModel(cloneModel(baseline));
};
const actionLabel = (action: Action): string => t(
action === 'allow'
? 'settings.agents.page.permissionsEditor.action.allow'
: action === 'ask'
? 'settings.agents.page.permissionsEditor.action.ask'
: 'settings.agents.page.permissionsEditor.action.deny',
);
const inheritLabel = t('settings.agents.page.permissionsEditor.action.inherit');
const defaultChipLabel = t('settings.agents.page.permissionsEditor.action.default');
const chipOptions = (unsetLabel?: string) => [
...(unsetLabel ? [{ value: 'inherit', label: unsetLabel }] : []),
...ACTIONS.map((action) => ({ value: action, label: actionLabel(action) })),
];
const renderActionChips = (
value: Action | null,
onChange: (action: Action | null) => void,
ariaLabel: string,
unsetLabel: string = inheritLabel,
) => (
<SettingsChipGroup
value={value ?? 'inherit'}
options={chipOptions(unsetLabel)}
onChange={(next) => onChange(next === 'inherit' ? null : (next as Action))}
aria-label={ariaLabel}
/>
);
if (isLoading) {
return (
<SettingsSection title={t('settings.agents.page.section.toolPermissions')}>
<p className={SETTINGS_HELPER_CLASS}>{t('common.loading')}</p>
</SettingsSection>
);
}
if (loadFailed) {
return (
<SettingsSection title={t('settings.agents.page.section.toolPermissions')}>
<div className="flex items-center gap-3">
<p className="typography-meta text-[var(--status-error)]">
{t('settings.agents.page.permissionsEditor.state.loadFailed')}
</p>
<Button variant="outline" size="xs" onClick={() => setReloadToken((token) => token + 1)}>
{t('settings.agents.page.permissionsEditor.actions.retry')}
</Button>
</div>
</SettingsSection>
);
}
return (
<SettingsSection
title={t('settings.agents.page.section.toolPermissions')}
settingsItem="agents.permissions"
info={t('settings.agents.page.permissionsEditor.sectionInfo')}
headerAction={isDirty ? (
<div className="flex items-center gap-2">
<Button variant="ghost" size="xs" className="!font-normal" onClick={handleDiscard} disabled={isSaving}>
{t('settings.agents.page.permissionsEditor.actions.discard')}
</Button>
<Button size="xs" className="!font-normal" onClick={() => void handleSave()} disabled={isSaving}>
{isSaving ? t('settings.common.actions.saving') : t('settings.agents.page.permissionsEditor.actions.save')}
</Button>
</div>
) : undefined}
contentClassName="space-y-4"
>
{/* Agent default (the `*` key) */}
<div className="flex flex-col gap-2 pb-2 @xl:flex-row @xl:items-center @xl:justify-between">
<div className="flex items-center gap-1.5">
<span className={SETTINGS_FIELD_LABEL_CLASS}>
{t('settings.agents.page.permissionsEditor.defaultLabel')}
</span>
<SettingsInfoHint>{t('settings.agents.page.permissionsEditor.defaultInfo')}</SettingsInfoHint>
{model.global === null && (
<span className="typography-micro text-muted-foreground">
{t('settings.agents.page.permissionsEditor.effectiveHint', { action: actionLabel(effectiveFor('*') ?? 'allow') })}
</span>
)}
</div>
{renderActionChips(model.global, setGlobal, t('settings.agents.page.permissionsEditor.defaultAria'), defaultChipLabel)}
</div>
<div>
{displayKeys.map((key) => {
const state = model.keys[key] ?? { action: null, patterns: [] };
const runtimeRules = runtimeRulesFor(key);
const supportsPatterns = PATTERN_CAPABLE_KEYS.has(key) || state.patterns.length > 0;
const hasDetails = supportsPatterns || runtimeRules.length > 0;
const isExpanded = expandedKeys[key] === true;
const effective = effectiveFor(key);
return (
<div key={key} className="border-t border-border/40 py-2">
<div className="flex flex-col gap-2 @xl:flex-row @xl:items-center @xl:justify-between">
<button
type="button"
onClick={hasDetails ? () => setExpandedKeys((current) => ({ ...current, [key]: !isExpanded })) : undefined}
className={cn('flex min-w-0 items-center gap-1.5 text-left', !hasDetails && 'cursor-default')}
aria-expanded={hasDetails ? isExpanded : undefined}
>
<Icon
name={isExpanded && hasDetails ? 'arrow-down-s' : 'arrow-right-s'}
className={cn('h-3.5 w-3.5 shrink-0 text-muted-foreground', !hasDetails && 'opacity-0')}
/>
<span className={SETTINGS_FIELD_LABEL_CLASS}>{formatKeyLabel(key)}</span>
<span className="typography-micro font-mono text-muted-foreground/70">{key}</span>
{state.action === null && effective !== null && (
<span className="typography-micro text-muted-foreground">
{t('settings.agents.page.permissionsEditor.effectiveHint', { action: actionLabel(effective) })}
</span>
)}
{state.patterns.length > 0 && (
<span className="typography-micro rounded bg-muted px-1 text-muted-foreground">
{t('settings.agents.page.permissionsEditor.ruleCount', { count: String(state.patterns.length) })}
</span>
)}
</button>
{renderActionChips(
state.action,
(action) => setKeyAction(key, action),
t('settings.agents.page.permissionsEditor.keyAria', { key }),
)}
</div>
{isExpanded && hasDetails && (
<div className="mt-2 space-y-2 pl-5">
{state.patterns.map((rule, index) => (
<div key={index} className="flex flex-wrap items-center gap-2">
<Input
value={rule.pattern}
onChange={(event) => setPattern(key, index, event.target.value, rule.action)}
placeholder={t('settings.agents.page.permissionsEditor.patternPlaceholder')}
className="h-8 w-full max-w-[24rem] min-w-0 flex-1 font-mono text-xs"
/>
<SettingsChipGroup
value={rule.action}
options={chipOptions()}
onChange={(next) => setPattern(key, index, rule.pattern, next as Action)}
aria-label={t('settings.agents.page.permissionsEditor.patternActionAria', { key })}
/>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
onClick={() => removePattern(key, index)}
aria-label={t('settings.agents.page.permissionsEditor.actions.removeRuleAria')}
>
<Icon name="close" className="h-3.5 w-3.5" />
</Button>
</div>
))}
{supportsPatterns && (
<Button variant="outline" size="xs" className="!font-normal" onClick={() => addPattern(key)}>
<Icon name="add" className="mr-1 h-3.5 w-3.5" />
{t('settings.agents.page.permissionsEditor.actions.addRule')}
</Button>
)}
{runtimeRules.length > 0 && (
<div className="space-y-1 pt-1">
<div className="flex items-center gap-1.5">
<span className="typography-micro font-medium text-muted-foreground">
{t('settings.agents.page.permissionsEditor.sessionRulesTitle')}
</span>
<SettingsInfoHint>
{t('settings.agents.page.permissionsEditor.sessionRulesInfo')}
</SettingsInfoHint>
</div>
{runtimeRules.map((rule) => (
<div key={`${rule.pattern}-${rule.action}`} className="flex items-center gap-2">
<span className="typography-micro min-w-0 flex-1 truncate font-mono text-muted-foreground/70">
{rule.pattern}
</span>
<span className="typography-micro text-muted-foreground">{actionLabel(rule.action)}</span>
</div>
))}
</div>
)}
</div>
)}
</div>
);
})}
</div>
{/* Custom permission key */}
<div className="flex flex-wrap items-center gap-2 border-t border-border/40 pt-3">
<Input
value={customKeyDraft}
onChange={(event) => setCustomKeyDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
addCustomKey();
}
}}
placeholder={t('settings.agents.page.permissionsEditor.customKeyPlaceholder')}
className="h-8 w-full max-w-[16rem] font-mono text-xs"
/>
<Button variant="outline" size="xs" className="!font-normal" onClick={addCustomKey} disabled={!customKeyDraft.trim()}>
<Icon name="add" className="mr-1 h-3.5 w-3.5" />
{t('settings.agents.page.permissionsEditor.actions.addKey')}
</Button>
</div>
</SettingsSection>
);
};
File diff suppressed because it is too large Load Diff
@@ -27,6 +27,7 @@ import { SettingsProjectSelector } from '@/components/sections/shared/SettingsPr
import { SidebarGroup } from '@/components/sections/shared/SidebarGroup';
import { Icon } from "@/components/icon/Icon";
import { useI18n } from '@/lib/i18n';
import { SETTINGS_PANEL_TITLE_CLASS } from '@/components/sections/shared/SettingsSection';
interface AgentsSidebarProps {
onItemSelect?: () => void;
@@ -350,7 +351,7 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
return (
<div className={cn('flex h-full flex-col', bgClass)}>
<div className="border-b px-3 pt-4 pb-3">
<h2 className="text-base font-semibold text-foreground mb-3">{t('settings.agents.sidebar.title')}</h2>
<h2 className={`${SETTINGS_PANEL_TITLE_CLASS} mb-3`}>{t('settings.agents.sidebar.title')}</h2>
<SettingsProjectSelector className="mb-3" />
<div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground">{t('settings.agents.sidebar.total', { count: visibleAgents.length })}</span>
@@ -12,6 +12,7 @@ import { useOpenCodeReadiness } from '@/hooks/useOpenCodeReadiness';
import { useDeviceInfo } from '@/lib/device';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger';
import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
import { ModelPickerList, type ModelPickerEntry, type ModelPickerProvider } from '@/components/model-picker/ModelPickerList';
@@ -90,7 +91,15 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
}), [placeholder, t]);
const selectedModel = providerId && modelId ? { providerID: providerId, modelID: modelId } : null;
const triggerLabel = providerId && modelId ? `${providerId}/${modelId}` : (placeholder || t('settings.agents.modelSelector.notSelected'));
// Show the model's display name (as in the picker list), not the raw provider/model id.
const triggerLabel = React.useMemo(() => {
if (!providerId || !modelId) {
return placeholder || t('settings.agents.modelSelector.notSelected');
}
const provider = providers.find((entry) => entry.id === providerId);
const model = provider?.models?.find((entry) => entry.id === modelId);
return (typeof model?.name === 'string' && model.name.trim()) || modelId;
}, [modelId, placeholder, providerId, providers, t]);
const picker = (
<ModelPickerList
@@ -123,8 +132,8 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
onClick={isReady ? () => setIsMobilePanelOpen(true) : undefined}
disabled={!isReady}
className={cn(
'flex w-full items-center justify-between gap-2 rounded-lg border border-border/40 bg-[var(--surface-elevated)] px-2 py-1.5 text-left',
!isReady && 'opacity-60 cursor-not-allowed',
dropdownTriggerVariants(),
'w-full',
className,
)}
>
@@ -158,7 +167,8 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
<DropdownMenu open={isReady && isDropdownOpen} onOpenChange={isReady ? setIsDropdownOpen : undefined}>
<DropdownMenuTrigger asChild>
<div className={cn(
'border-input data-[placeholder]:text-muted-foreground flex min-w-0 items-center justify-between gap-2 rounded-lg border bg-transparent px-2 py-2 typography-ui-label whitespace-nowrap shadow-none outline-none hover:bg-interactive-hover data-[popup-open]:bg-interactive-active h-6 w-fit',
dropdownTriggerVariants({ size: 'sm' }),
'min-w-0 w-fit',
!isReady && 'opacity-60 cursor-not-allowed',
className,
)}>
@@ -0,0 +1,97 @@
import type { PermissionConfig } from '@opencode-ai/sdk/v2';
/**
* Editor model for an agent's OWN `permission` map (the source config), with
* lossless parse/serialize between the persisted shape and the model.
* "null" action = key/pattern not set → inherited from global config/defaults.
*/
export type Action = 'allow' | 'ask' | 'deny';
export const ACTIONS: Action[] = ['allow', 'ask', 'deny'];
export interface KeyState {
/** Explicit action for the key's `*` pattern; null = not set (inherit). */
action: Action | null;
/** Non-wildcard pattern rules, in stable order. */
patterns: Array<{ pattern: string; action: Action }>;
}
export interface PermissionModel {
/** Explicit agent-level default (the `*` key); null = inherit. */
global: Action | null;
keys: Record<string, KeyState>;
}
/** Effective (resolved) rule as returned by /agent. */
export interface EffectiveRule {
permission: string;
pattern: string;
action: Action;
}
export const isAction = (value: unknown): value is Action =>
value === 'allow' || value === 'ask' || value === 'deny';
export const emptyModel = (): PermissionModel => ({ global: null, keys: {} });
/** Parse the persisted PermissionConfig into the editor model, verbatim. */
export const parsePermissionConfig = (config: unknown): PermissionModel => {
const model = emptyModel();
if (config == null) return model;
if (typeof config === 'string') {
if (isAction(config)) model.global = config;
return model;
}
if (typeof config !== 'object' || Array.isArray(config)) return model;
for (const [key, value] of Object.entries(config as Record<string, unknown>)) {
if (key === '*') {
if (isAction(value)) model.global = value;
continue;
}
const state: KeyState = { action: null, patterns: [] };
if (isAction(value)) {
state.action = value;
} else if (value && typeof value === 'object' && !Array.isArray(value)) {
for (const [pattern, action] of Object.entries(value as Record<string, unknown>)) {
if (!isAction(action)) continue;
if (pattern === '*') {
state.action = action;
} else {
state.patterns.push({ pattern, action });
}
}
} else {
continue;
}
model.keys[key] = state;
}
return model;
};
/** Serialize the editor model back to the persisted shape (canonical form). */
export const serializePermissionModel = (model: PermissionModel): PermissionConfig | null => {
const result: Record<string, Action | Record<string, Action>> = {};
if (model.global !== null) {
result['*'] = model.global;
}
for (const [key, state] of Object.entries(model.keys)) {
const patterns = state.patterns.filter((rule) => rule.pattern.trim().length > 0);
if (state.action !== null && patterns.length === 0) {
result[key] = state.action;
} else if (patterns.length > 0) {
const nested: Record<string, Action> = {};
if (state.action !== null) nested['*'] = state.action;
for (const rule of patterns) nested[rule.pattern] = rule.action;
result[key] = nested;
}
// action === null && no patterns → key omitted entirely (inherit)
}
return Object.keys(result).length > 0 ? (result as PermissionConfig) : null;
};
export const modelsEqual = (a: PermissionModel, b: PermissionModel): boolean =>
JSON.stringify(serializePermissionModel(a)) === JSON.stringify(serializePermissionModel(b));
export const cloneModel = (model: PermissionModel): PermissionModel =>
JSON.parse(JSON.stringify(model)) as PermissionModel;
@@ -1,11 +1,9 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { toast } from '@/components/ui';
import { useI18n, type I18nKey } from '@/lib/i18n';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Checkbox } from '@/components/ui/checkbox';
import { reportSettingsSaveState } from '@/lib/persistence';
import {
Select,
SelectContent,
@@ -13,7 +11,6 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Icon } from "@/components/icon/Icon";
import {
getResponseStylePresetInstructions,
isResponseStylePreset,
@@ -22,6 +19,14 @@ import {
} from '@/lib/responseStyle';
import type { DesktopSettings } from '@/lib/desktop';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
import {
SettingsSection,
SettingsCheckboxRow,
SettingsFieldRow,
SETTINGS_SELECT_ROW_TRIGGER_CLASS,
SETTINGS_SELECT_SIZE,
} from '@/components/sections/shared/SettingsSection';
const AGENTS_MD_PATH = '~/.config/opencode/AGENTS.md';
@@ -70,17 +75,24 @@ const RESPONSE_STYLE_OPTION_LABEL_KEYS: Record<ResponseStylePreset, I18nKey> = {
};
const saveBehaviorSetting = async (settings: Partial<DesktopSettings>, fallbackError: string) => {
const response = await runtimeFetch('/api/config/settings', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(settings),
});
reportSettingsSaveState('saving');
try {
const response = await runtimeFetch('/api/config/settings', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(settings),
});
if (!response.ok) {
throw new Error(await readApiError(response, fallbackError));
if (!response.ok) {
throw new Error(await readApiError(response, fallbackError));
}
reportSettingsSaveState('saved');
} catch (error) {
reportSettingsSaveState('error');
throw error;
}
};
@@ -235,126 +247,101 @@ export const BehaviorPage: React.FC = () => {
};
return (
<ScrollableOverlay outerClassName="h-full" className="w-full">
<div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8 space-y-6">
<div className="space-y-1">
<h2 className="typography-ui-header font-semibold text-foreground">
{t('settings.behavior.page.title')}
</h2>
</div>
<div data-settings-item="behavior.system-prompt">
<div className="mb-1 px-1">
<div className="flex items-center gap-1.5">
<h3 className="typography-ui-header font-medium text-foreground">
{t('settings.behavior.page.section.systemPrompt')}
</h3>
<Tooltip>
<TooltipTrigger asChild>
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
<div className="space-y-1">
<p className="font-medium text-foreground">
{t('settings.behavior.page.warning.title')}
</p>
<p>
{t('settings.behavior.page.warning.description', { path: AGENTS_MD_PATH })}
</p>
</div>
</TooltipContent>
</Tooltip>
</div>
<SettingsPageLayout
title={t('settings.behavior.page.title')}
description={t('settings.page.behavior.description')}
showSaveStatus
>
<SettingsSection
title={t('settings.behavior.page.section.systemPrompt')}
info={(
<div className="space-y-1">
<p className="font-medium text-foreground">
{t('settings.behavior.page.warning.title')}
</p>
<p>
{t('settings.behavior.page.warning.description', { path: AGENTS_MD_PATH })}
</p>
</div>
)}
divider={false}
settingsItem="behavior.system-prompt"
contentClassName="space-y-3"
>
<Textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder={t('settings.behavior.page.field.systemPromptPlaceholder')}
rows={12}
disabled={isLoading}
outerClassName="min-h-[160px] max-h-[70vh]"
className="w-full font-mono typography-meta bg-transparent"
/>
<Button
onClick={handleSave}
disabled={isSaving || !isPromptDirty || isLoading}
size="xs"
className="!font-normal"
>
{isSaving ? t('settings.common.actions.saving') : t('settings.common.actions.saveChanges')}
</Button>
</SettingsSection>
<section className="px-2 pb-2 pt-0 space-y-3">
<Textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder={t('settings.behavior.page.field.systemPromptPlaceholder')}
rows={12}
disabled={isLoading}
outerClassName="min-h-[160px] max-h-[70vh]"
className="w-full font-mono typography-meta bg-transparent"
/>
<Button
onClick={handleSave}
disabled={isSaving || !isPromptDirty || isLoading}
size="xs"
className="!font-normal"
>
{isSaving ? t('settings.common.actions.saving') : t('settings.common.actions.saveChanges')}
</Button>
</section>
</div>
<SettingsSection
title={t('settings.behavior.page.section.responseStyle')}
info={t('settings.behavior.page.responseStyle.tooltip')}
settingsItem="behavior.response-style"
contentClassName="space-y-3"
>
<SettingsCheckboxRow
checked={responseStyleEnabled}
onChange={setResponseStyleEnabled}
disabled={isLoading}
label={t('settings.behavior.page.responseStyle.enable')}
ariaLabel={t('settings.behavior.page.responseStyle.enableAria')}
/>
<div data-settings-item="behavior.response-style">
<div className="mb-1 px-1">
<div className="flex items-center gap-1.5">
<h3 className="typography-ui-header font-medium text-foreground">
{t('settings.behavior.page.section.responseStyle')}
</h3>
<Tooltip>
<TooltipTrigger asChild>
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
{t('settings.behavior.page.responseStyle.tooltip')}
</TooltipContent>
</Tooltip>
</div>
</div>
<section className="px-2 pb-2 pt-0 space-y-3">
<label className="flex items-center gap-2 typography-ui-label text-foreground">
<Checkbox
checked={responseStyleEnabled}
onChange={setResponseStyleEnabled}
disabled={isLoading}
ariaLabel={t('settings.behavior.page.responseStyle.enableAria')}
/>
{t('settings.behavior.page.responseStyle.enable')}
</label>
<Select<ResponseStyleValue>
value={responseStylePreset}
onValueChange={(value) => setResponseStylePreset(value)}
disabled={isLoading || !responseStyleEnabled}
>
<SelectTrigger className="w-full sm:w-56" size="lg">
<SelectValue>
{(value) => {
if (value === 'custom') return t('settings.behavior.page.responseStyle.option.custom');
if (isResponseStylePreset(value)) return t(RESPONSE_STYLE_OPTION_LABEL_KEYS[value]);
return null;
}}
</SelectValue>
</SelectTrigger>
<SelectContent>
{RESPONSE_STYLE_PRESETS.map((preset) => (
<SelectItem key={preset} value={preset}>
{t(RESPONSE_STYLE_OPTION_LABEL_KEYS[preset])}
</SelectItem>
))}
<SelectItem value="custom">
{t('settings.behavior.page.responseStyle.option.custom')}
<SettingsFieldRow
label={t('settings.behavior.page.responseStyle.preset')}
alignEnd={false}
>
<Select<ResponseStyleValue>
value={responseStylePreset}
onValueChange={(value) => setResponseStylePreset(value)}
disabled={isLoading || !responseStyleEnabled}
>
<SelectTrigger size={SETTINGS_SELECT_SIZE} className={SETTINGS_SELECT_ROW_TRIGGER_CLASS}>
<SelectValue>
{(value) => {
if (value === 'custom') return t('settings.behavior.page.responseStyle.option.custom');
if (isResponseStylePreset(value)) return t(RESPONSE_STYLE_OPTION_LABEL_KEYS[value]);
return null;
}}
</SelectValue>
</SelectTrigger>
<SelectContent>
{RESPONSE_STYLE_PRESETS.map((preset) => (
<SelectItem key={preset} value={preset}>
{t(RESPONSE_STYLE_OPTION_LABEL_KEYS[preset])}
</SelectItem>
</SelectContent>
</Select>
))}
<SelectItem value="custom">
{t('settings.behavior.page.responseStyle.option.custom')}
</SelectItem>
</SelectContent>
</Select>
</SettingsFieldRow>
<Textarea
value={responseStylePreview}
onChange={(event) => setResponseStyleCustomInstructions(event.target.value)}
placeholder={t('settings.behavior.page.responseStyle.customPlaceholder')}
rows={5}
disabled={isLoading || !responseStyleEnabled || responseStylePreset !== 'custom'}
outerClassName="min-h-[120px]"
className="w-full font-mono typography-meta bg-transparent"
/>
</section>
</div>
</div>
</ScrollableOverlay>
<Textarea
value={responseStylePreview}
onChange={(event) => setResponseStyleCustomInstructions(event.target.value)}
placeholder={t('settings.behavior.page.responseStyle.customPlaceholder')}
rows={5}
disabled={isLoading || !responseStyleEnabled || responseStylePreset !== 'custom'}
outerClassName="min-h-[120px]"
className="w-full font-mono typography-meta bg-transparent"
/>
</SettingsSection>
</SettingsPageLayout>
);
};
@@ -11,6 +11,7 @@ import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
import { useDeviceInfo } from '@/lib/device';
import { cn } from '@/lib/utils';
import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { Icon } from "@/components/icon/Icon";
import { useI18n } from '@/lib/i18n';
@@ -132,8 +133,8 @@ export const AgentSelector: React.FC<AgentSelectorProps> = ({
onClick={isReady ? () => setIsMobilePanelOpen(true) : undefined}
disabled={!isReady}
className={cn(
'flex w-full items-center justify-between gap-2 rounded-lg border border-border/40 bg-background/95 px-2 py-1.5 text-left',
!isReady && 'opacity-60 cursor-not-allowed',
dropdownTriggerVariants(),
'w-full',
className
)}
>
@@ -156,7 +157,8 @@ export const AgentSelector: React.FC<AgentSelectorProps> = ({
</button>
) : !isReady ? (
<div className={cn(
'flex items-center gap-2 px-2 rounded-lg bg-interactive-selection/20 border border-border/20 h-6 w-fit opacity-60',
dropdownTriggerVariants({ size: 'sm' }),
'w-fit opacity-60',
className
)}>
<Icon name="loader-4" className="h-3 w-3 animate-spin text-muted-foreground flex-shrink-0" />
@@ -168,11 +170,12 @@ export const AgentSelector: React.FC<AgentSelectorProps> = ({
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className={cn(
'flex items-center gap-2 px-2 rounded-lg bg-interactive-selection/20 border border-border/20 cursor-pointer hover:bg-interactive-hover/30 h-6 w-fit',
dropdownTriggerVariants({ size: 'sm' }),
'w-fit cursor-pointer',
className
)}>
<Icon name="robot-2" className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
<span className="typography-micro font-medium whitespace-nowrap">
<span className="typography-micro min-w-0 flex-1 truncate text-left font-medium">
{agentName || t('settings.commands.agentSelector.notSelected')}
</span>
<Icon name="arrow-down-s" className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
@@ -7,7 +7,6 @@ import { useCommandsStore, type CommandConfig, type CommandScope } from '@/store
import { useShallow } from 'zustand/react/shallow';
import { ModelSelector } from '../agents/ModelSelector';
import { AgentSelector } from './AgentSelector';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import {
Select,
SelectContent,
@@ -18,6 +17,13 @@ import {
import { Icon } from "@/components/icon/Icon";
import { useI18n } from '@/lib/i18n';
import { parseModelIdentifier } from '@/lib/modelIdentifier';
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
import {
SettingsSection,
SettingsFieldRow,
SETTINGS_SELECT_SIZE,
SETTINGS_CUSTOM_TRIGGER_CLASS,
} from '@/components/sections/shared/SettingsSection';
export const CommandsPage: React.FC = () => {
const { t } = useI18n();
@@ -189,158 +195,117 @@ export const CommandsPage: React.FC = () => {
}
return (
<ScrollableOverlay outerClassName="h-full" className="w-full">
<div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8">
{/* Header */}
<div className="mb-4 flex items-center justify-between gap-4">
<div className="min-w-0">
<h2 className="typography-ui-header font-semibold text-foreground truncate">
{isNewCommand ? t('settings.commands.page.title.new') : `/${selectedCommandName}`}
</h2>
<p className="typography-meta text-muted-foreground truncate">
{isNewCommand ? t('settings.commands.page.subtitle.new') : t('settings.commands.page.subtitle.edit')}
</p>
</div>
</div>
{/* Identity */}
<div className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">
{t('settings.commands.page.section.identity')}
</h3>
</div>
<section className="px-2 pb-2 pt-0 space-y-0">
{isNewCommand && (
<div data-settings-item="commands.name" className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
<span className="typography-ui-label text-foreground">{t('settings.commands.page.field.commandName')}</span>
</div>
<div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial">
<div className="flex items-center">
<span className="typography-ui-label text-muted-foreground mr-1">/</span>
<Input
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
placeholder={t('settings.commands.page.field.commandNamePlaceholder')}
className="h-7 w-40 px-2"
/>
<SettingsPageLayout
title={isNewCommand ? t('settings.commands.page.title.new') : `/${selectedCommandName}`}
description={isNewCommand ? t('settings.commands.page.subtitle.new') : t('settings.commands.page.subtitle.edit')}
showSaveStatus={false}
>
<SettingsSection
title={t('settings.commands.page.section.identity')}
divider={false}
contentClassName="space-y-0"
>
{isNewCommand && (
<SettingsFieldRow
settingsItem="commands.name"
label={t('settings.commands.page.field.commandName')}
>
<div className="flex items-center">
<span className="typography-ui-label text-muted-foreground mr-1">/</span>
<Input
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
placeholder={t('settings.commands.page.field.commandNamePlaceholder')}
className="h-7 w-40 px-2"
/>
</div>
<Select value={draftScope} onValueChange={(v) => setDraftScope(v as CommandScope)}>
<SelectTrigger size={SETTINGS_SELECT_SIZE} className="w-fit min-w-[100px]">
<SelectValue placeholder={t('settings.agents.page.field.scopePlaceholder')} />
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="user">
<div className="flex items-center gap-2">
<Icon name="user-3" className="h-3.5 w-3.5" />
<span>{t('settings.common.scope.global')}</span>
</div>
<Select value={draftScope} onValueChange={(v) => setDraftScope(v as CommandScope)}>
<SelectTrigger className="w-fit min-w-[100px]">
<SelectValue placeholder={t('settings.agents.page.field.scopePlaceholder')} />
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="user">
<div className="flex items-center gap-2">
<Icon name="user-3" className="h-3.5 w-3.5" />
<span>{t('settings.common.scope.global')}</span>
</div>
</SelectItem>
<SelectItem value="project">
<div className="flex items-center gap-2">
<Icon name="folder" className="h-3.5 w-3.5" />
<span>{t('settings.common.scope.project')}</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
</SelectItem>
<SelectItem value="project">
<div className="flex items-center gap-2">
<Icon name="folder" className="h-3.5 w-3.5" />
<span>{t('settings.common.scope.project')}</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</SettingsFieldRow>
)}
<div className="py-1.5">
<span className="typography-ui-label text-foreground">{t('settings.common.field.description')}</span>
<div className="mt-1.5">
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t('settings.commands.page.field.descriptionPlaceholder')}
rows={2}
className="w-full resize-none min-h-[60px] bg-transparent"
/>
</div>
</div>
</section>
</div>
{/* Execution Context */}
<div className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">
{t('settings.commands.page.section.executionContext')}
</h3>
</div>
<section className="px-2 pb-2 pt-0 space-y-0">
<div data-settings-item="commands.agent" className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
<span className="typography-ui-label text-foreground">{t('settings.commands.page.field.overrideAgent')}</span>
</div>
<div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial">
<AgentSelector
agentName={agent}
onChange={(agentName: string) => setAgent(agentName)}
/>
</div>
</div>
<div data-settings-item="commands.model" className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
<span className="typography-ui-label text-foreground">{t('settings.agents.page.field.overrideModel')}</span>
</div>
<div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial">
<ModelSelector
providerId={parseModelIdentifier(model)?.providerId ?? ''}
modelId={parseModelIdentifier(model)?.modelId ?? ''}
onChange={(providerId: string, modelId: string) => {
if (providerId && modelId) {
setModel(`${providerId}/${modelId}`);
} else {
setModel('');
}
}}
/>
</div>
</div>
</section>
</div>
{/* Command Template */}
<div data-settings-item="commands.template" className="mb-2">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">
{t('settings.commands.page.section.template')}
</h3>
</div>
<section className="px-2 pb-2 pt-0">
<div className="py-1.5">
<span className="typography-ui-label text-foreground">{t('settings.common.field.description')}</span>
<div className="mt-1.5">
<Textarea
value={template}
onChange={(e) => setTemplate(e.target.value)}
placeholder={t('settings.commands.page.field.templatePlaceholder')}
rows={12}
className="w-full font-mono typography-meta min-h-[160px] max-h-[60vh] bg-transparent resize-y"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t('settings.commands.page.field.descriptionPlaceholder')}
rows={2}
className="w-full resize-none min-h-[60px] bg-transparent"
/>
</section>
<div className="mt-2 px-2">
<p className="typography-meta text-muted-foreground">
<code className="text-foreground">$ARGUMENTS</code> {t('settings.commands.page.templateHint.userInput')} &middot;{' '}
<code className="text-foreground">!`cmd`</code> {t('settings.commands.page.templateHint.shellOutput')} &middot;{' '}
<code className="text-foreground">@file</code> {t('settings.commands.page.templateHint.fileContents')}
</p>
</div>
</div>
</SettingsSection>
{/* Save action */}
<div className="px-2 py-1">
<SettingsSection
title={t('settings.commands.page.section.executionContext')}
contentClassName="space-y-0"
>
<SettingsFieldRow
settingsItem="commands.agent"
label={t('settings.commands.page.field.overrideAgent')}
>
<AgentSelector
agentName={agent}
onChange={(agentName: string) => setAgent(agentName)}
className={SETTINGS_CUSTOM_TRIGGER_CLASS}
/>
</SettingsFieldRow>
<SettingsFieldRow
settingsItem="commands.model"
label={t('settings.agents.page.field.overrideModel')}
>
<ModelSelector
providerId={parseModelIdentifier(model)?.providerId ?? ''}
modelId={parseModelIdentifier(model)?.modelId ?? ''}
onChange={(providerId: string, modelId: string) => {
if (providerId && modelId) {
setModel(`${providerId}/${modelId}`);
} else {
setModel('');
}
}}
className={SETTINGS_CUSTOM_TRIGGER_CLASS}
/>
</SettingsFieldRow>
</SettingsSection>
<SettingsSection
title={t('settings.commands.page.section.template')}
settingsItem="commands.template"
>
<Textarea
value={template}
onChange={(e) => setTemplate(e.target.value)}
placeholder={t('settings.commands.page.field.templatePlaceholder')}
rows={12}
className="w-full font-mono typography-meta min-h-[160px] max-h-[60vh] bg-transparent resize-y"
/>
<p className="mt-2 typography-meta text-muted-foreground">
<code className="text-foreground">$ARGUMENTS</code> {t('settings.commands.page.templateHint.userInput')} &middot;{' '}
<code className="text-foreground">!`cmd`</code> {t('settings.commands.page.templateHint.shellOutput')} &middot;{' '}
<code className="text-foreground">@file</code> {t('settings.commands.page.templateHint.fileContents')}
</p>
<div className="pt-3">
<Button
onClick={handleSave}
disabled={isSaving || !isDirty}
@@ -350,8 +315,7 @@ export const CommandsPage: React.FC = () => {
{isSaving ? t('settings.common.actions.saving') : t('settings.common.actions.saveChanges')}
</Button>
</div>
</div>
</ScrollableOverlay>
</SettingsSection>
</SettingsPageLayout>
);
};
@@ -26,6 +26,7 @@ import { cn } from '@/lib/utils';
import { SettingsProjectSelector } from '@/components/sections/shared/SettingsProjectSelector';
import { Icon } from "@/components/icon/Icon";
import { useI18n } from '@/lib/i18n';
import { SETTINGS_PANEL_TITLE_CLASS } from '@/components/sections/shared/SettingsSection';
interface CommandsSidebarProps {
onItemSelect?: () => void;
@@ -227,7 +228,7 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
return (
<div className={cn('flex h-full flex-col', bgClass)}>
<div className="border-b px-3 pt-4 pb-3">
<h2 className="text-base font-semibold text-foreground mb-3">{t('settings.commands.sidebar.title')}</h2>
<h2 className={`${SETTINGS_PANEL_TITLE_CLASS} mb-3`}>{t('settings.commands.sidebar.title')}</h2>
<SettingsProjectSelector className="mb-3" />
<div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground">{t('settings.commands.sidebar.total', { count: commandOnlyItems.length })}</span>
@@ -1,7 +1,8 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { SettingsCheckboxRow, SETTINGS_FIELD_LABEL_CLASS } from '@/components/sections/shared/SettingsSection';
import { SettingsInfoHint } from '@/components/sections/shared/SettingsInfoHint';
import { toast } from '@/components/ui';
import {
Dialog,
@@ -11,7 +12,6 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Icon } from "@/components/icon/Icon";
import type { IconName } from "@/components/icon/icons";
import { useGitIdentitiesStore, type GitIdentityProfile, type GitIdentityAuthType } from '@/stores/useGitIdentitiesStore';
@@ -235,7 +235,7 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
{!isGlobalProfile && (
<div className="space-y-3">
<div>
<label className="typography-ui-label text-foreground block mb-1.5">{t('settings.gitIdentities.editor.field.profileName')}</label>
<label className={`${SETTINGS_FIELD_LABEL_CLASS} block mb-1.5`}>{t('settings.gitIdentities.editor.field.profileName')}</label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
@@ -302,16 +302,11 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
<div className="space-y-3">
<div>
<div className="flex items-center gap-1.5 mb-1.5">
<label className="typography-ui-label text-foreground">{t('settings.gitIdentities.editor.field.userName')}</label>
<label className={SETTINGS_FIELD_LABEL_CLASS}>{t('settings.gitIdentities.editor.field.userName')}</label>
{!isGlobalProfile && <span className="text-[var(--status-error)] text-xs">*</span>}
<Tooltip>
<TooltipTrigger asChild>
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
{t('settings.gitIdentities.editor.field.userNameTooltip')}
</TooltipContent>
</Tooltip>
<SettingsInfoHint contentClassName="max-w-xs">
{t('settings.gitIdentities.editor.field.userNameTooltip')}
</SettingsInfoHint>
</div>
<Input
value={userName}
@@ -326,16 +321,11 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
<div>
<div className="flex items-center gap-1.5 mb-1.5">
<label className="typography-ui-label text-foreground">{t('settings.gitIdentities.editor.field.emailAddress')}</label>
<label className={SETTINGS_FIELD_LABEL_CLASS}>{t('settings.gitIdentities.editor.field.emailAddress')}</label>
{!isGlobalProfile && <span className="text-[var(--status-error)] text-xs">*</span>}
<Tooltip>
<TooltipTrigger asChild>
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
{t('settings.gitIdentities.editor.field.emailAddressTooltip')}
</TooltipContent>
</Tooltip>
<SettingsInfoHint contentClassName="max-w-xs">
{t('settings.gitIdentities.editor.field.emailAddressTooltip')}
</SettingsInfoHint>
</div>
<Input
type="email"
@@ -380,15 +370,10 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
{authType === 'ssh' && (
<div>
<div className="flex items-center gap-1.5 mb-1.5">
<label className="typography-ui-label text-foreground">{t('settings.gitIdentities.editor.field.sshKeyPath')}</label>
<Tooltip>
<TooltipTrigger asChild>
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
{t('settings.gitIdentities.editor.field.sshKeyPathTooltip')}
</TooltipContent>
</Tooltip>
<label className={SETTINGS_FIELD_LABEL_CLASS}>{t('settings.gitIdentities.editor.field.sshKeyPath')}</label>
<SettingsInfoHint contentClassName="max-w-xs">
{t('settings.gitIdentities.editor.field.sshKeyPathTooltip')}
</SettingsInfoHint>
</div>
<Input
value={sshKey}
@@ -400,25 +385,17 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
)}
<div className="border-t border-border/40 pt-3 space-y-3">
<div className="flex items-start gap-2">
<Checkbox
checked={signCommits}
onChange={setSignCommits}
ariaLabel={t('settings.gitIdentities.editor.field.signCommits')}
/>
<div className="min-w-0">
<div className="typography-ui-label text-foreground">
{t('settings.gitIdentities.editor.field.signCommits')}
</div>
<div className="typography-micro text-muted-foreground/70">
{t('settings.gitIdentities.editor.section.commitSigning')}
</div>
</div>
</div>
<SettingsCheckboxRow
checked={signCommits}
onChange={setSignCommits}
label={t('settings.gitIdentities.editor.field.signCommits')}
info={t('settings.gitIdentities.editor.section.commitSigning')}
ariaLabel={t('settings.gitIdentities.editor.field.signCommits')}
/>
<div>
<div className="flex items-center gap-1.5 mb-1.5">
<label className="typography-ui-label text-foreground">
<label className={SETTINGS_FIELD_LABEL_CLASS}>
{t('settings.gitIdentities.editor.field.signingKey')}
</label>
</div>
@@ -435,16 +412,11 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
{authType === 'token' && (
<div>
<div className="flex items-center gap-1.5 mb-1.5">
<label className="typography-ui-label text-foreground">{t('settings.gitIdentities.editor.field.host')}</label>
<label className={SETTINGS_FIELD_LABEL_CLASS}>{t('settings.gitIdentities.editor.field.host')}</label>
<span className="text-[var(--status-error)] text-xs">*</span>
<Tooltip>
<TooltipTrigger asChild>
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
{t('settings.gitIdentities.editor.field.hostTooltip')}
</TooltipContent>
</Tooltip>
<SettingsInfoHint contentClassName="max-w-xs">
{t('settings.gitIdentities.editor.field.hostTooltip')}
</SettingsInfoHint>
</div>
<Input
value={host}
@@ -21,11 +21,12 @@ import { useShallow } from 'zustand/react/shallow';
import { GitSettings } from '@/components/sections/openchamber/GitSettings';
import { GitHubSettings } from '@/components/sections/openchamber/GitHubSettings';
import { GitIdentityEditorDialog } from './GitIdentityEditorDialog';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Icon } from "@/components/icon/Icon";
import type { IconName } from "@/components/icon/icons";
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
import { SettingsSection } from '@/components/sections/shared/SettingsSection';
const ICON_MAP: Record<string, IconName> = {
branch: 'git-branch',
@@ -116,82 +117,79 @@ export const GitPage: React.FC = () => {
return (
<>
<ScrollableOverlay outerClassName="h-full" className="w-full bg-background">
<div className="mx-auto w-full max-w-3xl space-y-6 p-3 sm:p-6 sm:pt-8">
<div data-settings-item="git.github-account">
<GitHubSettings />
</div>
<SettingsPageLayout
title={t('settings.page.git.title')}
showSaveStatus
>
<GitHubSettings />
{/* Identities Section */}
<div data-settings-item="git.identities" className="border-t border-border/40 pt-6">
<div className="mb-3 px-1 flex items-start justify-between gap-4">
<div className="flex items-center gap-2">
<h3 className="typography-ui-header font-semibold text-foreground">{t('settings.gitIdentities.page.section.title')}</h3>
<SettingsSection
title={t('settings.gitIdentities.page.section.title')}
headerAction={(
<Button size="sm" variant="outline" onClick={() => openEditor('new')}>
<Icon name="add" className="w-3.5 h-3.5 mr-1" /> {t('settings.common.badge.new')}
</Button>
)}
settingsItem="git.identities"
>
<div className="rounded-lg bg-[var(--surface-elevated)]/70 overflow-hidden flex flex-col">
{/* Global identity */}
{globalIdentity && (
<IdentityRow
profile={globalIdentity}
isDefault={defaultGitIdentityId === 'global'}
onEdit={() => openEditor('global')}
onToggleDefault={() => handleToggleDefault('global')}
isReadOnly
hasBorder={profiles.length > 0 || unimportedCredentials.length > 0}
/>
)}
{/* Custom profiles */}
{profiles.map((profile, i) => (
<IdentityRow
key={profile.id}
profile={profile}
isDefault={defaultGitIdentityId === profile.id}
onEdit={() => openEditor(profile.id)}
onToggleDefault={() => handleToggleDefault(profile.id)}
onDelete={() => setDeleteDialogProfile(profile)}
hasBorder={i < profiles.length - 1 || unimportedCredentials.length > 0}
/>
))}
{/* Empty state */}
{!globalIdentity && profiles.length === 0 && unimportedCredentials.length === 0 && (
<div className="py-8 px-4 text-center text-muted-foreground">
<Icon name="shield-keyhole" className="mx-auto mb-2 h-8 w-8 opacity-40" />
<p className="typography-ui-label">{t('settings.gitIdentities.page.empty.title')}</p>
<p className="typography-meta mt-1 opacity-75">{t('settings.gitIdentities.page.empty.description')}</p>
</div>
<Button size="sm" variant="outline" onClick={() => openEditor('new')}>
<Icon name="add" className="w-3.5 h-3.5 mr-1" /> {t('settings.common.badge.new')}
</Button>
</div>
)}
<div className="rounded-lg bg-[var(--surface-elevated)]/70 overflow-hidden flex flex-col">
{/* Global identity */}
{globalIdentity && (
<IdentityRow
profile={globalIdentity}
isDefault={defaultGitIdentityId === 'global'}
onEdit={() => openEditor('global')}
onToggleDefault={() => handleToggleDefault('global')}
isReadOnly
hasBorder={profiles.length > 0 || unimportedCredentials.length > 0}
/>
)}
{/* Custom profiles */}
{profiles.map((profile, i) => (
<IdentityRow
key={profile.id}
profile={profile}
isDefault={defaultGitIdentityId === profile.id}
onEdit={() => openEditor(profile.id)}
onToggleDefault={() => handleToggleDefault(profile.id)}
onDelete={() => setDeleteDialogProfile(profile)}
hasBorder={i < profiles.length - 1 || unimportedCredentials.length > 0}
/>
))}
{/* Empty state */}
{!globalIdentity && profiles.length === 0 && unimportedCredentials.length === 0 && (
<div className="py-8 px-4 text-center text-muted-foreground">
<Icon name="shield-keyhole" className="mx-auto mb-2 h-8 w-8 opacity-40" />
<p className="typography-ui-label">{t('settings.gitIdentities.page.empty.title')}</p>
<p className="typography-meta mt-1 opacity-75">{t('settings.gitIdentities.page.empty.description')}</p>
{/* Discovered credentials */}
{unimportedCredentials.length > 0 && (
<>
<div className="px-4 py-2 border-t border-[var(--surface-subtle)]">
<span className="typography-micro text-muted-foreground">
{t('settings.gitIdentities.page.discoveredCredentials.title')}
</span>
</div>
)}
{/* Discovered credentials */}
{unimportedCredentials.length > 0 && (
<>
<div className="px-4 py-2 border-t border-[var(--surface-subtle)]">
<span className="typography-micro text-muted-foreground">
{t('settings.gitIdentities.page.discoveredCredentials.title')}
</span>
</div>
{unimportedCredentials.map((cred, i) => (
<DiscoveredRow
key={`${cred.host}-${cred.username}`}
credential={cred}
onImport={() => openEditor('new', { host: cred.host, username: cred.username })}
hasBorder={i < unimportedCredentials.length - 1}
/>
))}
</>
)}
</div>
{unimportedCredentials.map((cred, i) => (
<DiscoveredRow
key={`${cred.host}-${cred.username}`}
credential={cred}
onImport={() => openEditor('new', { host: cred.host, username: cred.username })}
hasBorder={i < unimportedCredentials.length - 1}
/>
))}
</>
)}
</div>
</SettingsSection>
<GitSettings />
</div>
</ScrollableOverlay>
<GitSettings />
</SettingsPageLayout>
{/* Editor dialog */}
<GitIdentityEditorDialog
@@ -2,8 +2,6 @@ import React from 'react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { toast } from '@/components/ui';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Icon } from "@/components/icon/Icon";
import {
fetchMagicPromptOverrides,
getDefaultMagicPromptTemplate,
@@ -15,6 +13,9 @@ import {
} from '@/lib/magicPrompts';
import { useMagicPromptsStore } from '@/stores/useMagicPromptsStore';
import { useI18n } from '@/lib/i18n';
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
import { SettingsSection } from '@/components/sections/shared/SettingsSection';
import { SettingsInfoHint } from '@/components/sections/shared/SettingsInfoHint';
type PromptBlock = {
id: MagicPromptId;
@@ -317,114 +318,92 @@ export const MagicPromptsPage: React.FC = () => {
}
return (
<div className="h-full overflow-auto">
<div className="mx-auto w-full max-w-4xl px-6 py-6 space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<div className="flex items-center gap-2">
<h2 className="typography-ui-header font-semibold text-foreground">{tUnsafe(pageConfig.titleKey)}</h2>
<Tooltip>
<TooltipTrigger asChild>
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
{tUnsafe(pageConfig.descriptionKey)}
</TooltipContent>
</Tooltip>
</div>
</div>
<Button
data-settings-item="magic-prompts.reset-overrides"
variant="outline"
size="sm"
onClick={() => {
void handleResetAll();
}}
disabled={resettingAll || Object.keys(overrides).length === 0}
<SettingsPageLayout
title={tUnsafe(pageConfig.titleKey)}
titleAccessory={(
<SettingsInfoHint contentClassName="max-w-xs">{tUnsafe(pageConfig.descriptionKey)}</SettingsInfoHint>
)}
headerEnd={(
<Button
data-settings-item="magic-prompts.reset-overrides"
variant="outline"
size="sm"
onClick={() => {
void handleResetAll();
}}
disabled={resettingAll || Object.keys(overrides).length === 0}
>
{resettingAll ? t('settings.magicPrompts.page.actions.resetting') : t('settings.magicPrompts.page.actions.resetAllOverrides')}
</Button>
)}
showSaveStatus={false}
>
{pageConfig.blocks.map((block, index) => {
const definition = getMagicPromptDefinition(block.id);
const baseline = getBaseline(block.id);
const draft = getDraft(block.id);
const isOverridden = hasOwn(overrides, block.id);
const isDirty = draft !== baseline;
const isInvalidEmptyVisiblePrompt = isVisiblePromptId(block.id) && draft.trim().length === 0;
const saving = savingIds[block.id] === true;
const resetting = resettingIds[block.id] === true;
return (
<SettingsSection
key={block.id}
title={tUnsafe(block.titleKey)}
info={definition.description}
description={
definition.placeholders && definition.placeholders.length > 0
? `${t('settings.magicPrompts.page.placeholdersLabel')} ${definition.placeholders.map((item) => `{{${item.key}}}`).join(', ')}`
: undefined
}
divider={index > 0}
settingsItem={isVisiblePromptId(block.id) ? 'magic-prompts.visible-prompt' : 'magic-prompts.instructions'}
contentClassName="space-y-3"
>
{resettingAll ? t('settings.magicPrompts.page.actions.resetting') : t('settings.magicPrompts.page.actions.resetAllOverrides')}
</Button>
</div>
<Textarea
value={draft}
onChange={(event) => setDraft(block.id, event.target.value)}
className="min-h-[220px] font-mono text-sm"
/>
{isInvalidEmptyVisiblePrompt && (
<div className="typography-micro text-[var(--status-error)]">{t('settings.magicPrompts.page.validation.visiblePromptRequired')}</div>
)}
{pageConfig.blocks.map((block, index) => {
const definition = getMagicPromptDefinition(block.id);
const baseline = getBaseline(block.id);
const draft = getDraft(block.id);
const isOverridden = hasOwn(overrides, block.id);
const isDirty = draft !== baseline;
const isInvalidEmptyVisiblePrompt = isVisiblePromptId(block.id) && draft.trim().length === 0;
const saving = savingIds[block.id] === true;
const resetting = resettingIds[block.id] === true;
return (
<section
key={block.id}
data-settings-item={isVisiblePromptId(block.id) ? 'magic-prompts.visible-prompt' : 'magic-prompts.instructions'}
className={index > 0 ? 'space-y-3 pt-5 border-t border-border' : 'space-y-3'}
>
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="typography-ui-label text-foreground">{tUnsafe(block.titleKey)}</h3>
<Tooltip>
<TooltipTrigger asChild>
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
{definition.description}
</TooltipContent>
</Tooltip>
</div>
{definition.placeholders && definition.placeholders.length > 0 && (
<div className="typography-micro text-muted-foreground">
{t('settings.magicPrompts.page.placeholdersLabel')}{' '}
{definition.placeholders.map((item) => `{{${item.key}}}`).join(', ')}
</div>
)}
<div className="flex items-center justify-between gap-2">
<span className="typography-micro text-muted-foreground">
{isDirty
? t('settings.magicPrompts.page.status.unsavedChanges')
: isOverridden
? t('settings.magicPrompts.page.status.usingSavedOverride')
: t('settings.magicPrompts.page.status.usingBuiltinDefault')}
</span>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
void resetPrompt(block.id);
}}
disabled={!isOverridden || saving || resetting}
>
{resetting ? t('settings.magicPrompts.page.actions.resetting') : t('settings.magicPrompts.page.actions.resetToDefault')}
</Button>
<Button
size="sm"
onClick={() => {
void savePrompt(block.id);
}}
disabled={!isDirty || saving || resetting || isInvalidEmptyVisiblePrompt}
>
{saving ? t('settings.common.actions.saving') : t('settings.magicPrompts.page.actions.save')}
</Button>
</div>
<Textarea
value={draft}
onChange={(event) => setDraft(block.id, event.target.value)}
className="min-h-[220px] font-mono text-sm"
/>
{isInvalidEmptyVisiblePrompt && (
<div className="typography-micro text-[var(--status-error)]">{t('settings.magicPrompts.page.validation.visiblePromptRequired')}</div>
)}
<div className="flex items-center justify-between gap-2">
<span className="typography-micro text-muted-foreground">
{isDirty
? t('settings.magicPrompts.page.status.unsavedChanges')
: isOverridden
? t('settings.magicPrompts.page.status.usingSavedOverride')
: t('settings.magicPrompts.page.status.usingBuiltinDefault')}
</span>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
void resetPrompt(block.id);
}}
disabled={!isOverridden || saving || resetting}
>
{resetting ? t('settings.magicPrompts.page.actions.resetting') : t('settings.magicPrompts.page.actions.resetToDefault')}
</Button>
<Button
size="sm"
onClick={() => {
void savePrompt(block.id);
}}
disabled={!isDirty || saving || resetting || isInvalidEmptyVisiblePrompt}
>
{saving ? t('settings.common.actions.saving') : t('settings.magicPrompts.page.actions.save')}
</Button>
</div>
</div>
</section>
);
})}
</div>
</div>
</div>
</SettingsSection>
);
})}
</SettingsPageLayout>
);
};
@@ -3,6 +3,7 @@ import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { useMagicPromptsStore } from '@/stores/useMagicPromptsStore';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { SETTINGS_PANEL_TITLE_CLASS } from '@/components/sections/shared/SettingsSection';
interface MagicPromptsSidebarProps {
onItemSelect?: () => void;
@@ -62,7 +63,7 @@ export const MagicPromptsSidebar: React.FC<MagicPromptsSidebarProps> = ({ onItem
return (
<div className="flex h-full flex-col bg-background">
<div className="border-b px-3 pt-4 pb-3">
<h2 className="text-base font-semibold text-foreground">{t('settings.magicPrompts.sidebar.title')}</h2>
<h2 className={SETTINGS_PANEL_TITLE_CLASS}>{t('settings.magicPrompts.sidebar.title')}</h2>
<p className="typography-meta mt-1 text-muted-foreground">{t('settings.magicPrompts.sidebar.description')}</p>
</div>
@@ -3,6 +3,8 @@ import { Button } from '@/components/ui/button';
import { useMcpStore } from '@/stores/useMcpStore';
import { parseMcpOAuthCallbackContext, parseMcpOAuthCallbackStateKey } from '@/components/sections/mcp/mcpOAuth';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { SETTINGS_PAGE_TITLE_CLASS } from '@/components/sections/shared/SettingsSection';
import { cn } from '@/lib/utils';
const parseQueryParam = (params: URLSearchParams, key: string): string | null => {
const value = params.get(key);
@@ -94,14 +96,21 @@ export const McpOAuthCallbackPage: React.FC = () => {
<div className="flex min-h-screen items-center justify-center bg-background px-6 py-12 text-foreground">
<div className="w-full max-w-xl rounded-xl border border-[var(--interactive-border)] bg-[var(--surface-elevated)] p-8 shadow-sm">
<div className="space-y-3 text-center">
<div
className={status === 'error' ? 'text-[var(--status-error)]' : status === 'success' ? 'text-[var(--status-success)]' : 'text-[var(--status-info)]'}
<h1 className={SETTINGS_PAGE_TITLE_CLASS}>
{status === 'working' ? 'Completing Authorization' : status === 'success' ? 'Authorization Complete' : 'Authorization Failed'}
</h1>
<p
className={cn(
'typography-body',
status === 'error'
? 'text-[var(--status-error)]'
: status === 'success'
? 'text-[var(--status-success)]'
: 'text-[var(--status-info)]',
)}
>
<h1 className="typography-hero font-semibold">
{status === 'working' ? 'Completing Authorization' : status === 'success' ? 'Authorization Complete' : 'Authorization Failed'}
</h1>
</div>
<p className="typography-body text-muted-foreground">{message}</p>
{message}
</p>
</div>
{status !== 'working' && (
@@ -1,6 +1,5 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { toast } from '@/components/ui';
@@ -23,7 +22,18 @@ import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
import { cn } from '@/lib/utils';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
import {
SettingsSection,
SettingsFieldRow,
SettingsCheckboxRow,
SettingsStackedField,
SettingsChipGroup,
SettingsGroupTitle,
SETTINGS_SELECT_SIZE,
SETTINGS_FIELD_LABEL_CLASS,
} from '@/components/sections/shared/SettingsSection';
import { SettingsInfoHint } from '@/components/sections/shared/SettingsInfoHint';
import { MCP_OAUTH_CALLBACK_PATH, parseMcpOAuthCallbackContext, parseMcpOAuthCallbackStateKey } from '@/components/sections/mcp/mcpOAuth';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import {
@@ -1323,88 +1333,78 @@ export const McpPage: React.FC = () => {
};
return (
<ScrollableOverlay outerClassName="h-full" className="w-full">
<div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8">
{/* Header */}
<div className="mb-4">
<div className="min-w-0">
{isNewServer ? (
<h2 className="typography-ui-header font-semibold text-foreground truncate">{t('settings.mcp.page.header.newServer')}</h2>
) : (
<div className="flex items-center gap-2 min-w-0">
<h2 className="typography-ui-header font-semibold text-foreground truncate">{selectedMcpName}</h2>
<StatusBadge status={effectiveRuntimeStatus?.status} enabled={enabled} getStatusLabel={getStatusLabel} variant="pill" />
</div>
)}
<div className="flex items-center gap-2 mt-0.5">
<p className="typography-meta text-muted-foreground truncate">
{isNewServer
? t('settings.mcp.page.header.configureNewServer')
: t('settings.mcp.page.header.transport', { type: mcpType === 'local' ? t('settings.mcp.page.transport.local') : t('settings.mcp.page.transport.remote') })}
</p>
{!isNewServer && (
<>
<Button
variant={isConnected ? 'outline' : 'default'}
size="xs"
className="!font-normal"
onClick={handleToggleConnect}
disabled={isConnecting || !enabled}
>
{isConnecting ? t('settings.mcp.page.actions.working') : isConnected ? t('settings.mcp.page.actions.disconnect') : t('settings.mcp.page.actions.connect')}
</Button>
{mcpType === 'remote' && (
<>
<Button
variant={needsAuthorization ? 'default' : 'outline'}
size="xs"
className="!font-normal"
onClick={() => void handleStartAuthorization()}
disabled={isAuthorizing || !enabled}
>
{isAuthorizing
? t('settings.mcp.page.actions.starting')
: needsAuthorization
? t('settings.mcp.page.actions.authorize')
: t('settings.mcp.page.actions.reauthorize')}
</Button>
<Button
variant="ghost"
size="xs"
className="!font-normal gap-1 text-muted-foreground"
onClick={() => void handleClearAuthorization()}
disabled={isClearingAuth || !enabled}
>
{isClearingAuth ? t('settings.mcp.page.actions.clearing') : t('settings.mcp.page.actions.clearAuth')}
</Button>
</>
)}
{isConnected && (
<Button
variant="ghost"
size="xs"
className="!font-normal gap-1 text-muted-foreground"
onClick={() => void handleTestConnection()}
disabled={isTestingConnection || !enabled}
>
{isTestingConnection ? t('settings.mcp.page.actions.testing') : t('settings.mcp.page.actions.test')}
</Button>
)}
</>
)}
</div>
</div>
<>
<SettingsPageLayout
title={isNewServer ? t('settings.mcp.page.header.newServer') : selectedMcpName}
titleAccessory={!isNewServer ? (
<StatusBadge status={effectiveRuntimeStatus?.status} enabled={enabled} getStatusLabel={getStatusLabel} variant="pill" />
) : undefined}
description={isNewServer
? t('settings.mcp.page.header.configureNewServer')
: t('settings.mcp.page.header.transport', { type: mcpType === 'local' ? t('settings.mcp.page.transport.local') : t('settings.mcp.page.transport.remote') })}
headerEnd={!isNewServer ? (
<div className="flex flex-wrap items-center gap-2">
<Button
variant={isConnected ? 'outline' : 'default'}
size="xs"
className="!font-normal"
onClick={handleToggleConnect}
disabled={isConnecting || !enabled}
>
{isConnecting ? t('settings.mcp.page.actions.working') : isConnected ? t('settings.mcp.page.actions.disconnect') : t('settings.mcp.page.actions.connect')}
</Button>
{mcpType === 'remote' && (
<>
<Button
variant={needsAuthorization ? 'default' : 'outline'}
size="xs"
className="!font-normal"
onClick={() => void handleStartAuthorization()}
disabled={isAuthorizing || !enabled}
>
{isAuthorizing
? t('settings.mcp.page.actions.starting')
: needsAuthorization
? t('settings.mcp.page.actions.authorize')
: t('settings.mcp.page.actions.reauthorize')}
</Button>
<Button
variant="ghost"
size="xs"
className="!font-normal gap-1 text-muted-foreground"
onClick={() => void handleClearAuthorization()}
disabled={isClearingAuth || !enabled}
>
{isClearingAuth ? t('settings.mcp.page.actions.clearing') : t('settings.mcp.page.actions.clearAuth')}
</Button>
</>
)}
{isConnected && (
<Button
variant="ghost"
size="xs"
className="!font-normal gap-1 text-muted-foreground"
onClick={() => void handleTestConnection()}
disabled={isTestingConnection || !enabled}
>
{isTestingConnection ? t('settings.mcp.page.actions.testing') : t('settings.mcp.page.actions.test')}
</Button>
)}
</div>
) : undefined}
showSaveStatus={false}
>
{/* Runtime Status - Simplified for connected, expanded for errors */}
{!isNewServer && shouldShowFullStatusCard(effectiveRuntimeStatus?.status, authUrl, needsAuthorization, isAuthPolling) && (
<div className="mb-6 px-2">
<SettingsSection divider={false}>
<div className={cn('rounded-lg border p-3', statusCardClass(effectiveRuntimeStatus?.status))}>
<div className="space-y-4">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="typography-ui-label text-foreground">{t('settings.mcp.page.status.runtimeStatus')}</span>
<span className={SETTINGS_FIELD_LABEL_CLASS}>{t('settings.mcp.page.status.runtimeStatus')}</span>
<StatusBadge status={effectiveRuntimeStatus?.status} enabled={enabled} getStatusLabel={getStatusLabel} />
</div>
<p className="typography-meta text-muted-foreground">{runtimeDescription}</p>
@@ -1452,7 +1452,7 @@ export const McpPage: React.FC = () => {
<div className="rounded-md border border-[var(--interactive-border)] bg-[var(--surface-background)] px-3 py-3">
<div className="space-y-2">
<div>
<div className="typography-ui-label text-foreground">{t('settings.mcp.page.auth.manualFallbackTitle')}</div>
<SettingsGroupTitle as="div">{t('settings.mcp.page.auth.manualFallbackTitle')}</SettingsGroupTitle>
<p className="mt-1 typography-micro text-muted-foreground">
{t('settings.mcp.page.auth.manualFallbackDescription')}
</p>
@@ -1489,23 +1489,18 @@ export const McpPage: React.FC = () => {
</p>
)}
</div>
</div>
</SettingsSection>
)}
{/* Server Identity */}
<div data-settings-item="mcp.server" className="mb-6">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.mcp.page.server.title')}</h3>
</div>
<section className="px-2 pb-2 pt-0 space-y-0">
<SettingsSection
title={t('settings.mcp.page.server.title')}
divider={false}
settingsItem="mcp.server"
contentClassName="space-y-0"
>
{isNewServer && (
<div className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
<span className="typography-ui-label text-foreground">{t('settings.mcp.page.server.name')}</span>
</div>
<div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial">
<SettingsFieldRow label={t('settings.mcp.page.server.name')}>
<Input
value={draftName}
onChange={(e) => setDraftName(e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, '-'))}
@@ -1514,7 +1509,7 @@ export const McpPage: React.FC = () => {
autoFocus
/>
<Select value={draftScope} onValueChange={(value) => setDraftScope(value as McpScope)}>
<SelectTrigger className="!h-7 !w-7 !min-w-0 !px-0 !py-0 justify-center [&>svg:last-child]:hidden" title={draftScope === 'user' ? t('settings.common.scope.global') : t('settings.common.scope.project')}>
<SelectTrigger size={SETTINGS_SELECT_SIZE} className="!h-7 !w-7 !min-w-0 !px-0 !py-0 justify-center [&>svg:last-child]:hidden" title={draftScope === 'user' ? t('settings.common.scope.global') : t('settings.common.scope.project')}>
{draftScope === 'user' ? <Icon name="user-3" className="h-3.5 w-3.5" /> : <Icon name="folder" className="h-3.5 w-3.5" />}
</SelectTrigger>
<SelectContent align="end">
@@ -1532,8 +1527,7 @@ export const McpPage: React.FC = () => {
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</SettingsFieldRow>
)}
{/* Import JSON - prominent placement for new servers */}
@@ -1553,65 +1547,31 @@ export const McpPage: React.FC = () => {
</div>
)}
<div
className="group flex cursor-pointer items-center gap-2 py-1.5"
role="button"
tabIndex={0}
aria-pressed={enabled}
onClick={() => setEnabled(!enabled)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
setEnabled(!enabled);
}
}}
>
<Checkbox
checked={enabled}
onChange={setEnabled}
ariaLabel={t('settings.mcp.page.server.enableAria')}
<SettingsCheckboxRow
checked={enabled}
onChange={setEnabled}
label={t('settings.mcp.page.server.enable')}
ariaLabel={t('settings.mcp.page.server.enableAria')}
/>
<SettingsStackedField label={t('settings.mcp.page.server.transportMode')}>
<SettingsChipGroup
aria-label={t('settings.mcp.page.server.transportMode')}
value={mcpType}
onChange={setMcpType}
options={[
{ value: 'local', label: t('settings.mcp.page.transport.local') },
{ value: 'remote', label: t('settings.mcp.page.transport.remote') },
]}
/>
<span className="typography-ui-label text-foreground">{t('settings.mcp.page.server.enable')}</span>
</div>
</SettingsStackedField>
<div className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
<span className="typography-ui-label text-foreground">{t('settings.mcp.page.server.transportMode')}</span>
<div className="flex flex-wrap items-center gap-1">
<Button
variant="chip"
size="xs"
aria-pressed={mcpType === 'local'}
onClick={() => setMcpType('local')}
className="!font-normal"
>
{t('settings.mcp.page.transport.local')}
</Button>
<Button
variant="chip"
size="xs"
aria-pressed={mcpType === 'remote'}
onClick={() => setMcpType('remote')}
className="!font-normal"
>
{t('settings.mcp.page.transport.remote')}
</Button>
</div>
</div>
</div>
</SettingsSection>
</section>
</div>
{/* Connection */}
<div data-settings-item="mcp.command" className="mb-6">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">
{mcpType === 'local' ? t('settings.mcp.page.connection.command') : t('settings.mcp.page.connection.serverUrl')}
</h3>
</div>
<section className="px-2 pb-2 pt-0">
<SettingsSection
title={mcpType === 'local' ? t('settings.mcp.page.connection.command') : t('settings.mcp.page.connection.serverUrl')}
settingsItem="mcp.command"
>
{mcpType === 'local' ? (
<CommandTextarea
value={command}
@@ -1630,16 +1590,13 @@ export const McpPage: React.FC = () => {
className="font-mono typography-meta"
/>
)}
</section>
</div>
</SettingsSection>
{mcpType === 'remote' && (
<div data-settings-item="mcp.advanced" className="mb-6">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.mcp.page.advanced.title')}</h3>
</div>
<section className="px-2 pb-2 pt-0">
<SettingsSection
title={t('settings.mcp.page.advanced.title')}
settingsItem="mcp.advanced"
>
<Collapsible
open={isAdvancedRemoteOptionsOpen}
onOpenChange={setIsAdvancedRemoteOptionsOpen}
@@ -1660,9 +1617,10 @@ export const McpPage: React.FC = () => {
<CollapsibleContent className="pt-2">
<div className="space-y-4">
<div className="space-y-2">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-8">
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
<span className="typography-ui-label text-foreground">{t('settings.mcp.page.advanced.timeoutMs')}</span>
<div className="flex flex-col gap-2 @xl:flex-row @xl:items-center @xl:gap-8">
<div className="flex min-w-0 flex-row items-center gap-1 @xl:w-56 shrink-0">
<span className={SETTINGS_FIELD_LABEL_CLASS}>{t('settings.mcp.page.advanced.timeoutMs')}</span>
<SettingsInfoHint>{t('settings.mcp.page.advanced.timeoutHint')}</SettingsInfoHint>
</div>
<div className="flex items-center gap-2">
<Input
@@ -1678,18 +1636,15 @@ export const McpPage: React.FC = () => {
/>
</div>
</div>
<p className="typography-micro text-muted-foreground sm:pl-64">
{t('settings.mcp.page.advanced.timeoutHint')}
</p>
</div>
<div>
<div className="mb-2 typography-ui-label text-foreground">
<SettingsGroupTitle as="div" className="mb-2">
{t('settings.mcp.page.advanced.requestHeaders')}
{headerEntries.length > 0 && (
<span className="ml-1.5 typography-micro text-muted-foreground font-normal">({headerEntries.length})</span>
)}
</div>
</SettingsGroupTitle>
<EnvEditor
value={headerEntries}
onChange={setHeaderEntries}
@@ -1713,24 +1668,15 @@ export const McpPage: React.FC = () => {
</div>
<div className="space-y-3">
<div
className="group flex cursor-pointer items-center gap-2 py-1.5"
role="button"
tabIndex={0}
aria-pressed={oauthEnabled}
onClick={() => setOauthEnabled(!oauthEnabled)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
setOauthEnabled(!oauthEnabled);
}
}}
>
<Checkbox checked={oauthEnabled} onChange={setOauthEnabled} ariaLabel={t('settings.mcp.page.advanced.oauthAutoDetectionAria')} />
<span className="typography-ui-label text-foreground">{t('settings.mcp.page.advanced.oauthAutoDetection')}</span>
</div>
<SettingsCheckboxRow
checked={oauthEnabled}
onChange={setOauthEnabled}
label={t('settings.mcp.page.advanced.oauthAutoDetection')}
ariaLabel={t('settings.mcp.page.advanced.oauthAutoDetectionAria')}
info={t('settings.mcp.page.advanced.oauthHint')}
/>
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-3 @xl:grid-cols-2">
<Input
value={oauthClientId}
onChange={(e) => setOauthClientId(e.target.value)}
@@ -1769,9 +1715,6 @@ export const McpPage: React.FC = () => {
/>
</div>
<p className="typography-micro text-muted-foreground">
{t('settings.mcp.page.advanced.oauthHint')}
</p>
{suggestedRedirectUri && (
<p className="typography-micro text-muted-foreground">
{t('settings.mcp.page.advanced.oauthCallbackHint')}
@@ -1782,24 +1725,20 @@ export const McpPage: React.FC = () => {
</div>
</CollapsibleContent>
</Collapsible>
</section>
</div>
</SettingsSection>
)}
{/* Environment Variables */}
<div data-settings-item="mcp.environment" className="mb-2">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">
{t('settings.mcp.page.env.title')}
{envEntries.length > 0 && (
<span className="ml-1.5 typography-micro text-muted-foreground font-normal">
({envEntries.length})
</span>
)}
</h3>
</div>
<section className="px-2 pb-2 pt-0">
<SettingsSection
title={t('settings.mcp.page.env.title')}
titleAccessory={
envEntries.length > 0 ? (
<span className="typography-micro text-muted-foreground font-normal">
({envEntries.length})
</span>
) : null
}
settingsItem="mcp.environment"
>
{envEntries.length === 0 ? (
<Button
variant="outline"
@@ -1830,11 +1769,9 @@ export const McpPage: React.FC = () => {
removeVariableAria={t('settings.mcp.page.env.removeVariableAria')}
/>
)}
</section>
</div>
</SettingsSection>
{/* Actions */}
<div className="flex items-center gap-2 px-2 py-1">
<div className="flex items-center gap-2 pb-8">
<Button
onClick={handleSave}
disabled={isSaving || (!isDirty && !isNewServer)}
@@ -1853,8 +1790,8 @@ export const McpPage: React.FC = () => {
{t('settings.common.actions.delete')}
</Button>
)}
</div>
</div>
</div> </SettingsPageLayout>
{/* Import JSON dialog */}
<Dialog
@@ -1962,6 +1899,7 @@ export const McpPage: React.FC = () => {
</DialogFooter>
</DialogContent>
</Dialog>
</ScrollableOverlay>
</>
);
};
@@ -25,6 +25,7 @@ import {
} from '@/components/ui/dropdown-menu';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
import { useI18n } from '@/lib/i18n';
import { SETTINGS_PANEL_TITLE_CLASS } from '@/components/sections/shared/SettingsSection';
interface McpSidebarProps {
onItemSelect?: () => void;
@@ -183,7 +184,7 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
<div className={cn('flex h-full flex-col', bgClass)}>
<div className="border-b px-3 pt-4 pb-3">
<div className="mb-3 flex items-center justify-between gap-3">
<h2 className="text-base font-semibold text-foreground">{t('settings.mcp.sidebar.title')}</h2>
<h2 className={SETTINGS_PANEL_TITLE_CLASS}>{t('settings.mcp.sidebar.title')}</h2>
<button
type="button"
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
@@ -9,6 +9,11 @@ import { Icon } from "@/components/icon/Icon";
import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
import {
SettingsSection,
SETTINGS_BRAND_TITLE_CLASS,
SETTINGS_FIELD_LABEL_CLASS,
} from '@/components/sections/shared/SettingsSection';
const GITHUB_URL = 'https://github.com/openchamber/openchamber';
const DISCORD_URL = 'https://discord.gg/ZYRSdnwwKA';
@@ -125,7 +130,7 @@ export const AboutSettings: React.FC<AboutSettingsProps> = ({ initialUpdateDialo
<div className="w-full space-y-6 pb-2">
<div className="flex flex-col items-center text-center">
<OpenChamberLogo width={72} height={72} />
<h2 className="mt-4 typography-ui-header font-semibold text-foreground">OpenChamber</h2>
<h2 className={`mt-4 ${SETTINGS_BRAND_TITLE_CLASS}`}>OpenChamber</h2>
<div className="mt-2 space-y-1 typography-ui text-muted-foreground">
<p>{t('aboutDialog.openChamberVersionLabel', { version: currentVersion })}</p>
<p>{t('aboutDialog.openCodeVersionLabel', { version: openCodeVersion || t('settings.openchamber.about.state.unknown') })}</p>
@@ -221,23 +226,17 @@ export const AboutSettings: React.FC<AboutSettingsProps> = ({ initialUpdateDialo
);
}
// Desktop layout (redesigned)
// Desktop layout
return (
<div className="mb-8">
<div className="mb-3 px-1">
<h3 className="typography-ui-header font-semibold text-foreground">
{t('settings.openchamber.about.title')}
</h3>
</div>
<SettingsSection divider={false}>
<div className="rounded-lg bg-[var(--surface-elevated)]/70 overflow-hidden flex flex-col">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 px-4 py-3 border-b border-[var(--surface-subtle)]">
<div className="flex flex-col @xl:flex-row @xl:items-center justify-between gap-4 px-4 py-3 border-b border-border/40">
<div className="flex min-w-0 flex-col">
<span className="typography-ui-label text-foreground">{t('settings.openchamber.about.field.version')}</span>
<span className={SETTINGS_FIELD_LABEL_CLASS}>{t('settings.openchamber.about.field.version')}</span>
<span className="typography-meta text-muted-foreground font-mono">{currentVersion}</span>
</div>
<div className="flex min-w-0 flex-col">
<span className="typography-ui-label text-foreground">{t('settings.openchamber.about.field.openCodeVersion')}</span>
<span className={SETTINGS_FIELD_LABEL_CLASS}>{t('settings.openchamber.about.field.openCodeVersion')}</span>
<span className="typography-meta text-muted-foreground font-mono">{openCodeVersion || t('settings.openchamber.about.state.unknown')}</span>
</div>
@@ -274,7 +273,7 @@ export const AboutSettings: React.FC<AboutSettingsProps> = ({ initialUpdateDialo
</div>
{updateStore.error && (
<div className="px-3 py-2 border-b border-[var(--surface-subtle)]">
<div className="px-3 py-2 border-b border-border/40">
<p className="typography-meta text-[var(--status-error)]">{updateStore.error}</p>
</div>
)}
@@ -314,6 +313,6 @@ export const AboutSettings: React.FC<AboutSettingsProps> = ({ initialUpdateDialo
onRestart={updateStore.restartToUpdate}
runtimeType={updateStore.runtimeType}
/>
</div>
</SettingsSection>
);
};
@@ -2,12 +2,22 @@ import React from 'react';
import { ModelSelector } from '@/components/sections/agents/ModelSelector';
import { AgentSelector } from '@/components/sections/commands/AgentSelector';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import {
SettingsSection,
SettingsFieldRow,
SettingsCheckboxRow,
SettingsInset,
SettingsGroupTitle,
SETTINGS_CUSTOM_TRIGGER_CLASS,
SETTINGS_SELECT_ROW_TRIGGER_CLASS,
SETTINGS_SELECT_SIZE,
SETTINGS_OPTION_STACK_CLASS,
} from '@/components/sections/shared/SettingsSection';
import { SettingsInfoHint } from '@/components/sections/shared/SettingsInfoHint';
import { updateDesktopSettings } from '@/lib/persistence';
import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { parseModelIdentifier } from '@/lib/modelIdentifier';
import { runtimeFetch } from '@/lib/runtime-fetch';
@@ -277,143 +287,118 @@ export const DefaultsSettings: React.FC = () => {
}
return (
<div className="mb-6">
<div className="mb-0.5 px-1">
<div className="flex items-center gap-2">
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.defaults.title')}</h3>
</div>
</div>
<section className="px-2 pb-2 pt-0 space-y-0">
<div className="mt-0 mb-1 typography-meta text-muted-foreground">
{t('settings.openchamber.defaults.summaryPrefix')}
{' '}
{parsedModel.providerId ? (
<span className="text-foreground">
{parsedModel.providerId}/{parsedModel.modelId}
{supportsVariants ? ` (${defaultVariant ?? t('settings.openchamber.defaults.option.defaultLowercase')})` : ''}
</span>
) : (
<span className="text-foreground">{t('settings.openchamber.defaults.summaryOpenCodeDefault')}</span>
)}
{defaultAgent && (
<>
{' / '}
<span className="text-foreground">{defaultAgent}</span>
</>
)}
</div>
<div data-settings-item="sessions.default-model" className={cn('flex flex-col gap-2 py-1 sm:flex-row sm:items-center sm:gap-8')}>
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
<span className="typography-ui-label text-foreground">{t('settings.openchamber.defaults.field.defaultModel')}</span>
<>
<SettingsSection title={t('settings.openchamber.defaults.title')} divider={false}>
<div className="space-y-0">
<div className="mt-0 mb-1 typography-meta text-muted-foreground">
{t('settings.openchamber.defaults.summaryPrefix')}
{' '}
{parsedModel.providerId ? (
<span className="text-foreground">
{parsedModel.providerId}/{parsedModel.modelId}
{supportsVariants ? ` (${defaultVariant ?? t('settings.openchamber.defaults.option.defaultLowercase')})` : ''}
</span>
) : (
<span className="text-foreground">{t('settings.openchamber.defaults.summaryOpenCodeDefault')}</span>
)}
{defaultAgent && (
<>
{' / '}
<span className="text-foreground">{defaultAgent}</span>
</>
)}
</div>
<div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial">
<ModelSelector providerId={parsedModel.providerId} modelId={parsedModel.modelId} onChange={handleModelChange} />
</div>
</div>
<div data-settings-item="sessions.default-thinking" className="flex flex-col gap-2 py-1 sm:flex-row sm:items-center sm:gap-8">
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
<span className="typography-ui-label text-foreground">{t('settings.openchamber.defaults.field.defaultThinking')}</span>
</div>
<div className="flex items-center gap-2 sm:w-fit">
<Select value={defaultVariant ?? DEFAULT_VARIANT_VALUE} onValueChange={handleVariantChange} disabled={!supportsVariants}>
<SelectTrigger className="w-fit min-w-[120px]">
<SelectValue placeholder={t('settings.openchamber.defaults.field.thinkingPlaceholder')}>
{formatVariantLabel(defaultVariant ?? DEFAULT_VARIANT_VALUE)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value={DEFAULT_VARIANT_VALUE}>{t('settings.openchamber.defaults.option.default')}</SelectItem>
{availableVariants.map((variant) => (
<SelectItem key={variant} value={variant}>
{formatVariantLabel(variant)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div data-settings-item="sessions.default-agent" className="flex flex-col gap-2 py-1 sm:flex-row sm:items-center sm:gap-8">
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
<span className="typography-ui-label text-foreground">{t('settings.openchamber.defaults.field.defaultAgent')}</span>
</div>
<div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial">
<AgentSelector agentName={defaultAgent || ''} onChange={handleAgentChange} />
</div>
</div>
<div
data-settings-item="sessions.deletion-dialog"
className="group flex cursor-pointer items-center gap-2 py-1"
role="button"
tabIndex={0}
aria-pressed={showDeletionDialog}
onClick={() => setShowDeletionDialog(!showDeletionDialog)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
setShowDeletionDialog(!showDeletionDialog);
}
}}
>
<Checkbox checked={showDeletionDialog} onChange={setShowDeletionDialog} ariaLabel={t('settings.openchamber.defaults.field.showDeletionDialogAria')} />
<span className="typography-ui-label text-foreground">{t('settings.openchamber.defaults.field.showDeletionDialog')}</span>
</div>
</section>
<div className="mt-6 mb-0.5 px-1">
<div className="flex items-center gap-2">
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.defaults.smallModel.title')}</h3>
</div>
</div>
<section className="px-2 pb-2 pt-0 space-y-0">
<div className="mt-0 mb-1 typography-meta text-muted-foreground">
{t('settings.openchamber.defaults.smallModel.description')}
</div>
<div
data-settings-item="sessions.small-model"
className="group flex cursor-pointer items-center gap-2 py-1"
role="button"
tabIndex={0}
aria-pressed={smallModelUseDefault}
onClick={() => void handleSmallModelUseDefaultChange(!smallModelUseDefault)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
void handleSmallModelUseDefaultChange(!smallModelUseDefault);
}
}}
>
<Checkbox
checked={smallModelUseDefault}
onChange={(checked) => void handleSmallModelUseDefaultChange(checked)}
ariaLabel={t('settings.openchamber.defaults.smallModel.useDefaultAria')}
/>
<span className="typography-ui-label text-foreground">{t('settings.openchamber.defaults.smallModel.useDefault')}</span>
</div>
{!smallModelUseDefault ? (
<div className="flex flex-col gap-2 py-1 sm:flex-row sm:items-center sm:gap-8">
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
<span className="typography-ui-label text-foreground">{t('settings.openchamber.defaults.smallModel.overrideModel')}</span>
</div>
<div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial">
<div>
<SettingsFieldRow
settingsItem="sessions.default-model"
label={t('settings.openchamber.defaults.field.defaultModel')}
>
<ModelSelector
providerId={parsedSmallModel.providerId}
modelId={parsedSmallModel.modelId}
onChange={handleSmallModelOverrideChange}
allowedProviderIds={smallModelProviders}
providerId={parsedModel.providerId}
modelId={parsedModel.modelId}
onChange={handleModelChange}
className={SETTINGS_CUSTOM_TRIGGER_CLASS}
/>
</div>
</SettingsFieldRow>
<SettingsFieldRow
settingsItem="sessions.default-thinking"
label={t('settings.openchamber.defaults.field.defaultThinking')}
>
<Select value={defaultVariant ?? DEFAULT_VARIANT_VALUE} onValueChange={handleVariantChange} disabled={!supportsVariants}>
<SelectTrigger size={SETTINGS_SELECT_SIZE} className={SETTINGS_SELECT_ROW_TRIGGER_CLASS}>
<SelectValue placeholder={t('settings.openchamber.defaults.field.thinkingPlaceholder')}>
{formatVariantLabel(defaultVariant ?? DEFAULT_VARIANT_VALUE)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value={DEFAULT_VARIANT_VALUE}>{t('settings.openchamber.defaults.option.default')}</SelectItem>
{availableVariants.map((variant) => (
<SelectItem key={variant} value={variant}>
{formatVariantLabel(variant)}
</SelectItem>
))}
</SelectContent>
</Select>
</SettingsFieldRow>
<SettingsFieldRow
settingsItem="sessions.default-agent"
label={t('settings.openchamber.defaults.field.defaultAgent')}
>
<AgentSelector
agentName={defaultAgent || ''}
onChange={handleAgentChange}
className={SETTINGS_CUSTOM_TRIGGER_CLASS}
/>
</SettingsFieldRow>
</div>
) : null}
</section>
</div>
<SettingsInset className={SETTINGS_OPTION_STACK_CLASS}>
<SettingsCheckboxRow
settingsItem="sessions.deletion-dialog"
checked={showDeletionDialog}
onChange={setShowDeletionDialog}
label={t('settings.openchamber.defaults.field.showDeletionDialog')}
ariaLabel={t('settings.openchamber.defaults.field.showDeletionDialogAria')}
/>
</SettingsInset>
<div className="space-y-3 pt-6">
<div className="flex items-center gap-1.5">
<SettingsGroupTitle>
{t('settings.openchamber.defaults.smallModel.title')}
</SettingsGroupTitle>
<SettingsInfoHint>
{t('settings.openchamber.defaults.smallModel.description')}
</SettingsInfoHint>
</div>
<SettingsCheckboxRow
settingsItem="sessions.small-model"
checked={smallModelUseDefault}
onChange={(checked) => {
void handleSmallModelUseDefaultChange(checked);
}}
label={t('settings.openchamber.defaults.smallModel.useDefault')}
ariaLabel={t('settings.openchamber.defaults.smallModel.useDefaultAria')}
/>
{!smallModelUseDefault ? (
<SettingsFieldRow label={t('settings.openchamber.defaults.smallModel.overrideModel')}>
<ModelSelector
providerId={parsedSmallModel.providerId}
modelId={parsedSmallModel.modelId}
onChange={handleSmallModelOverrideChange}
allowedProviderIds={smallModelProviders}
className={SETTINGS_CUSTOM_TRIGGER_CLASS}
/>
</SettingsFieldRow>
) : null}
</div>
</div>
</SettingsSection>
</>
);
};
@@ -1,7 +1,7 @@
import * as React from 'react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Icon } from '@/components/icon/Icon';
import { Input } from '@/components/ui/input';
import {
getDesktopLanAddress,
@@ -22,7 +22,15 @@ import { updateDesktopSettings } from '@/lib/persistence';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
import { useUIStore } from '@/stores/useUIStore';
import { cn } from '@/lib/utils';
import {
SettingsSection,
SettingsCheckboxRow,
SettingsChipGroup,
SettingsFieldRow,
SETTINGS_OPTION_STACK_CLASS,
SettingsStackedField,
SETTINGS_ICON_BUTTON_CLASS,
} from '@/components/sections/shared/SettingsSection';
const WINDOW_CONTROLS_POSITION_OPTIONS: Array<{ id: DesktopWindowControlsPosition; labelKey: string }> = [
{ id: 'auto', labelKey: 'settings.openchamber.desktopNetwork.option.windowControlsAuto' },
@@ -41,6 +49,7 @@ export const DesktopNetworkSettings: React.FC = () => {
const [draftValue, setDraftValue] = React.useState(false);
const [savedPassword, setSavedPassword] = React.useState('');
const [draftPassword, setDraftPassword] = React.useState('');
const [showPassword, setShowPassword] = React.useState(false);
const [lanAccessActive, setLanAccessActive] = React.useState(false);
const [lanAccessBlockedReason, setLanAccessBlockedReason] = React.useState<string | null>(null);
const [isLoading, setIsLoading] = React.useState(true);
@@ -215,10 +224,6 @@ export const DesktopNetworkSettings: React.FC = () => {
const lanBlockedByMissingPassword = savedValue && !lanAccessActive && lanAccessBlockedReason === 'missing-password';
const saveDisabled = isLoading || isSaving || !isDirty || lanRequiresPassword;
const handleToggle = React.useCallback(() => {
setDraftValue((current) => !current);
}, []);
const handlePasswordChange = React.useCallback((value: string) => {
setDraftPassword(value);
if (!value.trim()) {
@@ -349,223 +354,164 @@ export const DesktopNetworkSettings: React.FC = () => {
}
return (
<div className="mb-8">
<>
{showWindowControlsPosition ? (
<>
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.desktopNetwork.field.windowControlsPosition')}</h3>
</div>
<section className="space-y-2 px-2 pb-2 pt-0">
<div data-settings-item="sessions.desktop-window-controls-position" className="space-y-1 py-1.5">
<div className="typography-micro text-muted-foreground/70">
{t('settings.openchamber.desktopNetwork.field.windowControlsPositionDescription')}
</div>
<div
className="mt-1 flex flex-wrap items-center gap-1"
role="group"
aria-label={t('settings.openchamber.desktopNetwork.field.windowControlsPositionAria')}
>
{WINDOW_CONTROLS_POSITION_OPTIONS.map((option) => {
const selected = desktopWindowControlsPosition === option.id;
return (
<Button
key={option.id}
type="button"
variant="outline"
size="xs"
className={cn(
'!font-normal',
selected ? 'border-[var(--primary-base)] text-[var(--primary-base)] bg-[var(--primary-base)]/10' : 'text-foreground',
)}
aria-pressed={selected}
onClick={() => handleWindowControlsPositionChange(option.id)}
>
{tUnsafe(option.labelKey)}
</Button>
);
})}
</div>
</div>
</section>
</>
<SettingsSection title={t('settings.openchamber.desktopNetwork.field.windowControlsPosition')}>
<SettingsFieldRow
settingsItem="sessions.desktop-window-controls-position"
label={t('settings.openchamber.desktopNetwork.field.windowControlsPositionDescription')}
alignEnd={false}
controlClassName="flex-col items-stretch"
>
<SettingsChipGroup
value={desktopWindowControlsPosition}
options={WINDOW_CONTROLS_POSITION_OPTIONS.map((option) => ({
value: option.id,
label: tUnsafe(option.labelKey),
}))}
onChange={handleWindowControlsPositionChange}
aria-label={t('settings.openchamber.desktopNetwork.field.windowControlsPositionAria')}
/>
</SettingsFieldRow>
</SettingsSection>
) : null}
{!isLocalDesktop ? null : (
<>
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.desktopNetwork.title')}</h3>
</div>
{isLocalDesktop ? (
<SettingsSection title={t('settings.openchamber.desktopNetwork.title')}>
<div className="space-y-3">
{(launchAtLoginSupported || minimizeToTraySupported || keepAwakeSupported) ? (
<div className={SETTINGS_OPTION_STACK_CLASS}>
{launchAtLoginSupported ? (
<SettingsCheckboxRow
settingsItem="sessions.desktop-launch-at-login"
checked={launchAtLoginEnabled}
onChange={(checked) => {
if (checked === launchAtLoginEnabled) return;
void handleLaunchAtLoginToggle();
}}
disabled={isSavingLaunchAtLogin}
label={t('settings.openchamber.desktopNetwork.field.launchAtLogin')}
info={t('settings.openchamber.desktopNetwork.field.launchAtLoginDescription')}
ariaLabel={t('settings.openchamber.desktopNetwork.field.launchAtLoginAria')}
/>
) : null}
<section className="space-y-2 px-2 pb-2 pt-0">
{launchAtLoginSupported ? (
<div
data-settings-item="sessions.desktop-launch-at-login"
className="group flex cursor-pointer items-start gap-2 py-1.5"
role="button"
tabIndex={0}
onClick={handleLaunchAtLoginToggle}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleLaunchAtLoginToggle();
}
}}
>
<Checkbox
checked={launchAtLoginEnabled}
onChange={handleLaunchAtLoginToggle}
ariaLabel={t('settings.openchamber.desktopNetwork.field.launchAtLoginAria')}
disabled={isSavingLaunchAtLogin}
/>
<div className="min-w-0 flex-1">
<div className="typography-ui-label text-foreground">{t('settings.openchamber.desktopNetwork.field.launchAtLogin')}</div>
<div className="typography-micro text-muted-foreground/70">
{t('settings.openchamber.desktopNetwork.field.launchAtLoginDescription')}
</div>
</div>
</div>
) : null}
{minimizeToTraySupported ? (
<SettingsCheckboxRow
settingsItem="sessions.desktop-minimize-to-tray"
checked={minimizeToTrayEnabled}
onChange={(checked) => {
if (checked === minimizeToTrayEnabled) return;
void handleMinimizeToTrayToggle();
}}
disabled={isSavingMinimizeToTray}
label={t('settings.openchamber.desktopNetwork.field.minimizeToTray')}
info={t('settings.openchamber.desktopNetwork.field.minimizeToTrayDescription')}
ariaLabel={t('settings.openchamber.desktopNetwork.field.minimizeToTrayAria')}
/>
) : null}
{minimizeToTraySupported ? (
<div
data-settings-item="sessions.desktop-minimize-to-tray"
className="group flex cursor-pointer items-start gap-2 py-1.5"
role="button"
tabIndex={0}
onClick={handleMinimizeToTrayToggle}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleMinimizeToTrayToggle();
}
}}
>
<Checkbox
checked={minimizeToTrayEnabled}
onChange={handleMinimizeToTrayToggle}
ariaLabel={t('settings.openchamber.desktopNetwork.field.minimizeToTrayAria')}
disabled={isSavingMinimizeToTray}
/>
<div className="min-w-0 flex-1">
<div className="typography-ui-label text-foreground">{t('settings.openchamber.desktopNetwork.field.minimizeToTray')}</div>
<div className="typography-micro text-muted-foreground/70">
{t('settings.openchamber.desktopNetwork.field.minimizeToTrayDescription')}
</div>
</div>
</div>
) : null}
{keepAwakeSupported ? (
<div
data-settings-item="sessions.desktop-keep-awake"
className="group flex cursor-pointer items-start gap-2 py-1.5"
role="button"
tabIndex={0}
onClick={handleKeepAwakeToggle}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleKeepAwakeToggle();
}
}}
>
<Checkbox
checked={keepAwakeEnabled}
onChange={handleKeepAwakeToggle}
ariaLabel={t('settings.openchamber.desktopNetwork.field.keepAwakeAria')}
disabled={isSavingKeepAwake}
/>
<div className="min-w-0 flex-1">
<div className="typography-ui-label text-foreground">{t('settings.openchamber.desktopNetwork.field.keepAwake')}</div>
<div className="typography-micro text-muted-foreground/70">
{t('settings.openchamber.desktopNetwork.field.keepAwakeDescription')}
</div>
</div>
</div>
) : null}
<div data-settings-item="sessions.desktop-ui-password" className="space-y-1 py-1.5">
<label className="typography-ui-label text-foreground" htmlFor="desktop-ui-password">
{t('settings.openchamber.desktopPassword.field.password')}
</label>
<Input
id="desktop-ui-password"
type="password"
className="h-7 max-w-sm"
value={draftPassword}
onChange={(event) => handlePasswordChange(event.target.value)}
placeholder={t('settings.openchamber.desktopPassword.field.passwordPlaceholder')}
disabled={isLoading || isSaving}
required={draftValue}
aria-invalid={lanRequiresPassword}
/>
<div className="typography-micro text-muted-foreground/70">
{t('settings.openchamber.desktopPassword.field.passwordDescription')}
</div>
</div>
<div
data-settings-item="sessions.desktop-lan-access"
className="group flex cursor-pointer items-start gap-2 py-1.5"
role="button"
tabIndex={0}
onClick={handleToggle}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleToggle();
}
}}
>
<Checkbox
checked={draftValue}
onChange={handleToggle}
ariaLabel={t('settings.openchamber.desktopNetwork.field.allowLanAccessAria')}
disabled={isLoading || isSaving}
/>
<div className="min-w-0 flex-1">
<div className="typography-ui-label text-foreground">{t('settings.openchamber.desktopNetwork.field.allowLanAccess')}</div>
<div className="typography-micro text-muted-foreground/70">
{t('settings.openchamber.desktopNetwork.field.allowLanAccessDescription')}
</div>
<div className="typography-micro text-[var(--status-warning)]/85">
{t('settings.openchamber.desktopNetwork.field.warning')}
</div>
{lanRequiresPassword || lanBlockedByMissingPassword ? (
<div className="typography-micro text-[var(--status-warning)]/85">
{t('settings.openchamber.desktopNetwork.field.passwordRequiredWarning')}
{keepAwakeSupported ? (
<SettingsCheckboxRow
settingsItem="sessions.desktop-keep-awake"
checked={keepAwakeEnabled}
onChange={(checked) => {
if (checked === keepAwakeEnabled) return;
void handleKeepAwakeToggle();
}}
disabled={isSavingKeepAwake}
label={t('settings.openchamber.desktopNetwork.field.keepAwake')}
info={t('settings.openchamber.desktopNetwork.field.keepAwakeDescription')}
ariaLabel={t('settings.openchamber.desktopNetwork.field.keepAwakeAria')}
/>
) : null}
</div>
) : null}
<SettingsStackedField
settingsItem="sessions.desktop-ui-password"
label={(
<label htmlFor="desktop-ui-password">
{t('settings.openchamber.desktopPassword.field.password')}
</label>
)}
info={t('settings.openchamber.desktopPassword.field.passwordDescription')}
>
<Input
id="desktop-ui-password"
type={showPassword ? 'text' : 'password'}
className="h-8 min-w-0 flex-1"
value={draftPassword}
onChange={(event) => handlePasswordChange(event.target.value)}
placeholder={t('settings.openchamber.desktopPassword.field.passwordPlaceholder')}
disabled={isLoading || isSaving}
required={draftValue}
aria-invalid={lanRequiresPassword}
/>
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => setShowPassword((current: boolean) => !current)}
className={SETTINGS_ICON_BUTTON_CLASS}
aria-label={t(showPassword ? 'settings.openchamber.desktopPassword.actions.hidePassword' : 'settings.openchamber.desktopPassword.actions.showPassword')}
aria-pressed={showPassword}
>
<Icon name={showPassword ? 'eye-off' : 'eye'} className="h-4 w-4" />
</Button>
</SettingsStackedField>
<div className={SETTINGS_OPTION_STACK_CLASS}>
<SettingsCheckboxRow
settingsItem="sessions.desktop-lan-access"
checked={draftValue}
onChange={setDraftValue}
disabled={isLoading || isSaving}
label={t('settings.openchamber.desktopNetwork.field.allowLanAccess')}
info={t('settings.openchamber.desktopNetwork.field.allowLanAccessDescription')}
description={(
<>
<span className="block text-[var(--status-warning)]/85">
{t('settings.openchamber.desktopNetwork.field.warning')}
</span>
{lanRequiresPassword || lanBlockedByMissingPassword ? (
<span className="block text-[var(--status-warning)]/85">
{t('settings.openchamber.desktopNetwork.field.passwordRequiredWarning')}
</span>
) : null}
</>
)}
ariaLabel={t('settings.openchamber.desktopNetwork.field.allowLanAccessAria')}
/>
</div>
{error ? (
<div className="typography-micro text-[var(--status-error)]">{error}</div>
) : null}
{lanUrl ? (
<div className="typography-micro text-muted-foreground/80">
{isDirty && !savedValue
? t('settings.openchamber.desktopNetwork.hint.openAfterRestart')
: t('settings.openchamber.desktopNetwork.hint.openNow')}
<span className="font-mono text-foreground">{lanUrl}</span>
</div>
) : null}
<div className="flex justify-start py-1.5">
<Button
type="button"
size="xs"
onClick={handleSaveAndRestart}
disabled={saveDisabled}
className="shrink-0 !font-normal"
>
{isSaving ? t('settings.common.actions.saving') : t('settings.openchamber.desktopNetwork.actions.saveAndRestart')}
</Button>
</div>
</div>
</div>
{error ? (
<div className="px-2 typography-micro text-[var(--status-error)]">{error}</div>
) : null}
{lanUrl ? (
<div className="px-2 typography-micro text-muted-foreground/80">
{isDirty && !savedValue
? t('settings.openchamber.desktopNetwork.hint.openAfterRestart')
: t('settings.openchamber.desktopNetwork.hint.openNow')}
<span className="font-mono text-foreground">{lanUrl}</span>
</div>
) : null}
<div className="flex justify-start py-1.5">
<Button
type="button"
size="xs"
onClick={handleSaveAndRestart}
disabled={saveDisabled}
className="shrink-0 !font-normal"
>
{isSaving ? t('settings.common.actions.saving') : t('settings.openchamber.desktopNetwork.actions.saveAndRestart')}
</Button>
</div>
</section>
</>
)}
</div>
</SettingsSection>
) : null}
</>
);
};
@@ -8,9 +8,9 @@ import { useDeviceInfo } from '@/lib/device';
import { cn } from '@/lib/utils';
import { openExternalUrl } from '@/lib/url';
import { useI18n } from '@/lib/i18n';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { Icon } from "@/components/icon/Icon";
import { SettingsSection, SettingsGroupTitle } from '@/components/sections/shared/SettingsSection';
type GitHubUser = {
login: string;
@@ -270,21 +270,13 @@ export const GitHubSettings: React.FC = () => {
: t('settings.github.page.accountSource.oauth');
return (
<div className="mb-8">
<div className="mb-3 px-1 flex items-start justify-between gap-4">
<div className="flex items-center gap-2">
<h3 className="typography-ui-header font-semibold text-foreground">{t('settings.github.page.oauth.title')}</h3>
<Tooltip>
<TooltipTrigger asChild>
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
{t('settings.github.page.tooltip.connectAccount')}
</TooltipContent>
</Tooltip>
</div>
</div>
<>
<SettingsSection
title={t('settings.github.page.oauth.title')}
divider={false}
settingsItem="git.github-account"
info={t('settings.github.page.tooltip.connectAccount')}
>
<div className="rounded-lg bg-[var(--surface-elevated)]/70 overflow-hidden flex flex-col">
{connected ? (
<div className={cn("px-4 py-3", isMobile ? "flex flex-col gap-3" : "flex items-center justify-between gap-4")}>
@@ -422,7 +414,7 @@ export const GitHubSettings: React.FC = () => {
{flow && (
<div className="mt-4 rounded-lg bg-[var(--surface-elevated)]/70 p-4 border border-[var(--interactive-border)]">
<div className="space-y-1">
<h4 className="typography-ui-label text-foreground">{t('settings.github.page.flow.title')}</h4>
<SettingsGroupTitle>{t('settings.github.page.flow.title')}</SettingsGroupTitle>
<p className="typography-meta text-muted-foreground">
{t('settings.github.page.flow.description')}
</p>
@@ -453,11 +445,10 @@ export const GitHubSettings: React.FC = () => {
</div>
)}
</SettingsSection>
{ghCli?.available && !ghCli?.active && (!ghCli.user || ghCli.disabled) && (
<div className="mt-6">
<h3 className="typography-ui-header font-semibold text-foreground mb-3 px-1">
{t('settings.github.page.ghCli.title')}
</h3>
<SettingsSection title={t('settings.github.page.ghCli.title')}>
<div className="rounded-lg bg-[var(--surface-elevated)]/70 overflow-hidden">
<div className={cn("px-4 py-3", isMobile ? "flex flex-col gap-3" : "flex items-center justify-between gap-4")}>
<div className={cn("flex min-w-0 items-center gap-4", isMobile ? "w-full" : undefined)}>
@@ -508,8 +499,8 @@ export const GitHubSettings: React.FC = () => {
</Button>
</div>
</div>
</div>
</SettingsSection>
)}
</div>
</>
);
};
@@ -1,6 +1,4 @@
import React from 'react';
import { Checkbox } from '@/components/ui/checkbox';
import { Radio } from '@/components/ui/radio';
import { updateDesktopSettings } from '@/lib/persistence';
import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
@@ -8,6 +6,14 @@ import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { setFilesViewShowGitignored, useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
import {
SettingsSection,
SettingsControlGroup,
SettingsRadioGroup,
SettingsRadioOption,
SettingsCheckboxRow,
SETTINGS_OPTION_STACK_CLASS,
} from '@/components/sections/shared/SettingsSection';
export const GitSettings: React.FC = () => {
const { t } = useI18n();
@@ -116,94 +122,45 @@ export const GitSettings: React.FC = () => {
}
return (
<div className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.git.title')}</h3>
<SettingsSection title={t('settings.openchamber.git.title')}>
<div className={SETTINGS_OPTION_STACK_CLASS}>
<SettingsControlGroup
settingsItem="git.changes-view"
title={t('settings.openchamber.git.changesViewTitle')}
>
<SettingsRadioGroup aria-label={t('settings.openchamber.git.changesViewAria')}>
{viewOptions.map((option) => (
<SettingsRadioOption
key={option.id}
selected={gitChangesViewMode === option.id}
onSelect={() => {
handleGitChangesViewModeChange(option.id);
}}
label={option.label}
ariaLabel={t('settings.openchamber.git.optionAria', { option: option.label })}
/>
))}
</SettingsRadioGroup>
</SettingsControlGroup>
<SettingsCheckboxRow
settingsItem="git.gitmoji"
checked={settingsGitmojiEnabled}
onChange={(checked) => {
void handleGitmojiChange(checked);
}}
label={t('settings.openchamber.git.enableGitmoji')}
ariaLabel={t('settings.openchamber.git.enableGitmojiAria')}
/>
<SettingsCheckboxRow
settingsItem="git.gitignored-files"
checked={showGitignored}
onChange={setFilesViewShowGitignored}
label={t('settings.openchamber.git.showGitignored')}
ariaLabel={t('settings.openchamber.git.showGitignoredAria')}
/>
</div>
<section className="px-2 pb-2 pt-0 space-y-0.5">
<div data-settings-item="git.changes-view" className="pt-1 pb-1">
<h4 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.git.changesViewTitle')}</h4>
<div role="radiogroup" aria-label={t('settings.openchamber.git.changesViewAria')} className="mt-0.5 space-y-0">
{viewOptions.map((option) => {
const selected = gitChangesViewMode === option.id;
return (
<div
key={option.id}
role="button"
tabIndex={0}
aria-pressed={selected}
onClick={() => { handleGitChangesViewModeChange(option.id); }}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
handleGitChangesViewModeChange(option.id);
}
}}
className="flex w-full items-center gap-2 py-0 text-left"
>
<Radio
checked={selected}
onChange={() => { handleGitChangesViewModeChange(option.id); }}
ariaLabel={t('settings.openchamber.git.optionAria', { option: option.label })}
/>
<span className={selected ? 'typography-ui-label font-normal text-foreground' : 'typography-ui-label font-normal text-foreground/50'}>
{option.label}
</span>
</div>
);
})}
</div>
</div>
<div
data-settings-item="git.gitmoji"
className="group flex cursor-pointer items-center gap-2 py-1.5"
role="button"
tabIndex={0}
aria-pressed={settingsGitmojiEnabled}
onClick={() => {
void handleGitmojiChange(!settingsGitmojiEnabled);
}}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
void handleGitmojiChange(!settingsGitmojiEnabled);
}
}}
>
<Checkbox
checked={settingsGitmojiEnabled}
onChange={(checked) => {
void handleGitmojiChange(checked);
}}
ariaLabel={t('settings.openchamber.git.enableGitmojiAria')}
/>
<span className="typography-ui-label text-foreground">{t('settings.openchamber.git.enableGitmoji')}</span>
</div>
<div
data-settings-item="git.gitignored-files"
className="group flex cursor-pointer items-center gap-2 py-1.5"
role="button"
tabIndex={0}
aria-pressed={showGitignored}
onClick={() => setFilesViewShowGitignored(!showGitignored)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
setFilesViewShowGitignored(!showGitignored);
}
}}
>
<Checkbox
checked={showGitignored}
onChange={setFilesViewShowGitignored}
ariaLabel={t('settings.openchamber.git.showGitignoredAria')}
/>
<span className="typography-ui-label text-foreground">{t('settings.openchamber.git.showGitignored')}</span>
</div>
</section>
</div>
</SettingsSection>
);
};
@@ -1,8 +1,10 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Icon } from "@/components/icon/Icon";
import {
SettingsSection,
SettingsFieldRow,
} from '@/components/sections/shared/SettingsSection';
import { useUIStore } from '@/stores/useUIStore';
import { cn } from '@/lib/utils';
import { updateDesktopSettings } from '@/lib/persistence';
@@ -157,41 +159,34 @@ export const KeyboardShortcutsSettings: React.FC = () => {
}, [clearShortcutOverride, persistShortcutOverrides, shortcutOverrides]);
return (
<div data-settings-item="shortcuts.keyboard-shortcuts" className="mb-8">
<div className="mb-1 px-1">
<div className="flex items-center gap-2">
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.keyboardShortcuts.title')}</h3>
<Button
type="button"
variant="outline"
size="xs"
className="!font-normal"
onClick={() => {
resetAllShortcutOverrides();
persistShortcutOverrides({});
setDraftByAction({});
setPendingOverwrite(null);
setErrorText('');
setWarningText('');
}}
>
{t('settings.openchamber.keyboardShortcuts.actions.resetAll')}
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
{t('settings.openchamber.keyboardShortcuts.tooltip')}
</TooltipContent>
</Tooltip>
</div>
</div>
<SettingsSection
settingsItem="shortcuts.keyboard-shortcuts"
title={t('settings.openchamber.keyboardShortcuts.title')}
divider={false}
info={t('settings.openchamber.keyboardShortcuts.tooltip')}
headerAction={(
<Button
type="button"
variant="outline"
size="xs"
className="!font-normal"
onClick={() => {
resetAllShortcutOverrides();
persistShortcutOverrides({});
setDraftByAction({});
setPendingOverwrite(null);
setErrorText('');
setWarningText('');
}}
>
{t('settings.openchamber.keyboardShortcuts.actions.resetAll')}
</Button>
)}
>
{(errorText || warningText || pendingOverwrite) && (
<div className="mb-2 space-y-2 px-1">
<div className="mb-2 space-y-2">
{pendingOverwrite && (
<div className="rounded-lg border border-[var(--status-warning-border)] bg-[var(--status-warning-background)] p-3 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="rounded-lg border border-[var(--status-warning-border)] bg-[var(--status-warning-background)] p-3 flex flex-col @xl:flex-row @xl:items-center justify-between gap-3">
<span className="typography-meta text-foreground">
{t('settings.openchamber.keyboardShortcuts.overwritePrompt')}
</span>
@@ -214,7 +209,7 @@ export const KeyboardShortcutsSettings: React.FC = () => {
</div>
)}
<section className="px-2 pb-2 pt-0 space-y-0.5">
<div>
{actions.map((action, index) => {
const effective = getEffectiveShortcutCombo(action.id, shortcutOverrides);
const draft = draftByAction[action.id];
@@ -222,11 +217,11 @@ export const KeyboardShortcutsSettings: React.FC = () => {
const hasDraft = typeof draft === 'string' && normalizeCombo(draft) !== normalizeCombo(effective);
return (
<div key={action.id} className={cn("flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8", index > 0 && "border-t border-[var(--surface-subtle)]")}>
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
<span className="typography-ui-label text-foreground">{actionLabel(action.id, action.label)}</span>
</div>
<div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial">
<div key={action.id} className={cn("py-1.5", index > 0 && "border-t border-border/40")}>
<SettingsFieldRow
label={actionLabel(action.id, action.label)}
alignEnd={false}
>
<Input
readOnly
value={capturingActionId === action.id ? t('settings.openchamber.keyboardShortcuts.field.pressKeys') : formatShortcutForDisplay(displayCombo)}
@@ -283,11 +278,11 @@ export const KeyboardShortcutsSettings: React.FC = () => {
<Button type="button" size="xs" className="!font-normal" variant="ghost" onClick={() => resetOne(action.id)}>
{t('settings.common.actions.reset')}
</Button>
</div>
</SettingsFieldRow>
</div>
);
})}
</section>
</div>
</div>
</SettingsSection>
);
};
@@ -1,14 +1,19 @@
import React from 'react';
import { useUIStore } from '@/stores/useUIStore';
import { isDesktopShell, isVSCodeRuntime } from '@/lib/desktop';
import { Checkbox } from '@/components/ui/checkbox';
import { toast } from '@/components/ui';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { getClientPlatform } from '@/lib/platform';
import { useI18n } from '@/lib/i18n';
import {
SettingsSection,
SettingsTwoColumn,
SettingsCheckboxRow,
SettingsGroupTitle,
SETTINGS_OPTION_STACK_CLASS,
} from '@/components/sections/shared/SettingsSection';
const DEFAULT_NOTIFICATION_TEMPLATES = {
completion: {
@@ -448,67 +453,40 @@ export const NotificationSettings: React.FC = () => {
};
return (
<div className="space-y-8">
{/* --- Global Delivery Settings --- */}
<div data-settings-item="notifications.delivery" className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">
{t('settings.notifications.page.delivery.title')}
</h3>
</div>
<section className="px-2 pb-2 pt-0 space-y-0.5">
<div
className="group flex cursor-pointer items-center gap-2 py-1.5"
role="button"
tabIndex={0}
aria-pressed={nativeNotificationsEnabled && canShowNotifications}
onClick={() => {
void handleToggleChange(!(nativeNotificationsEnabled && canShowNotifications));
<>
<SettingsSection
settingsItem="notifications.delivery"
title={t('settings.notifications.page.delivery.title')}
divider={false}
>
<div className={SETTINGS_OPTION_STACK_CLASS}>
<SettingsCheckboxRow
checked={nativeNotificationsEnabled && canShowNotifications}
onChange={(checked) => {
void handleToggleChange(checked);
}}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
void handleToggleChange(!(nativeNotificationsEnabled && canShowNotifications));
}
}}
>
<Checkbox
checked={nativeNotificationsEnabled && canShowNotifications}
onChange={(checked) => {
void handleToggleChange(checked);
}}
ariaLabel={t('settings.notifications.page.delivery.enableAria')}
/>
<span className="typography-ui-label text-foreground">{t('settings.notifications.page.delivery.enableLabel')}</span>
</div>
label={t('settings.notifications.page.delivery.enableLabel')}
info={
isBrowser
? t('settings.notifications.page.delivery.browserPermissionHint')
: isVSCode
? t('settings.notifications.page.delivery.vscodeHint')
: undefined
}
ariaLabel={t('settings.notifications.page.delivery.enableAria')}
/>
{/* The native Capacitor app never notifies while focused (hard rule) and uses
generic, non-customizable text, so the "notify while focused" toggle and the
test button are hidden there. */}
{nativeNotificationsEnabled && canShowNotifications && !isNativeApp && (
<>
<div
className="group flex cursor-pointer items-center gap-2 py-1.5"
role="button"
tabIndex={0}
aria-pressed={notificationMode === 'always'}
onClick={() => setNotificationMode(notificationMode === 'always' ? 'hidden-only' : 'always')}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
setNotificationMode(notificationMode === 'always' ? 'hidden-only' : 'always');
}
}}
>
<Checkbox
checked={notificationMode === 'always'}
onChange={(checked) => setNotificationMode(checked ? 'always' : 'hidden-only')}
ariaLabel={t('settings.notifications.page.delivery.focusedAria')}
/>
<span className="typography-ui-label text-foreground">{t('settings.notifications.page.delivery.focusedLabel')}</span>
</div>
<SettingsCheckboxRow
checked={notificationMode === 'always'}
onChange={(checked) => setNotificationMode(checked ? 'always' : 'hidden-only')}
label={t('settings.notifications.page.delivery.focusedLabel')}
ariaLabel={t('settings.notifications.page.delivery.focusedAria')}
/>
<div className="py-2">
<Button
@@ -522,13 +500,10 @@ export const NotificationSettings: React.FC = () => {
</div>
</>
)}
</section>
</div>
{isBrowser && (
<div className="mt-1 px-2">
<p className="typography-meta text-muted-foreground/70">
{t('settings.notifications.page.delivery.browserPermissionHint')}
</p>
<div className="mt-1">
{notificationPermission === 'denied' && (
<p className="typography-meta text-[var(--status-error)] mt-1">
{t('settings.notifications.page.delivery.permissionDenied')}
@@ -541,104 +516,50 @@ export const NotificationSettings: React.FC = () => {
)}
</div>
)}
{isVSCode && (
<div className="mt-1 px-2">
<p className="typography-meta text-muted-foreground/70">
{t('settings.notifications.page.delivery.vscodeHint')}
</p>
</div>
)}
</div>
</SettingsSection>
{nativeNotificationsEnabled && canShowNotifications && (
<>
{/* --- Events --- */}
<div data-settings-item="notifications.events" className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">
{t('settings.notifications.page.events.title')}
</h3>
<SettingsSection
settingsItem="notifications.events"
title={t('settings.notifications.page.events.title')}
>
<div className={SETTINGS_OPTION_STACK_CLASS}>
<SettingsCheckboxRow
checked={notifyOnCompletion}
onChange={setNotifyOnCompletion}
label={t('settings.notifications.page.events.completionLabel')}
ariaLabel={t('settings.notifications.page.events.completionAria')}
/>
<SettingsCheckboxRow
checked={notifyOnSubtasks}
onChange={setNotifyOnSubtasks}
label={t('settings.notifications.page.events.subtaskLabel')}
ariaLabel={t('settings.notifications.page.events.subtaskAria')}
/>
<SettingsCheckboxRow
checked={notifyOnError}
onChange={setNotifyOnError}
label={t('settings.notifications.page.events.errorLabel')}
ariaLabel={t('settings.notifications.page.events.errorAria')}
/>
<SettingsCheckboxRow
checked={notifyOnQuestion}
onChange={setNotifyOnQuestion}
label={t('settings.notifications.page.events.questionLabel')}
ariaLabel={t('settings.notifications.page.events.questionAria')}
/>
</div>
</SettingsSection>
<section className="px-2 pb-2 pt-0 space-y-0.5">
<div
className="group flex cursor-pointer items-center gap-2 py-1.5"
role="button"
tabIndex={0}
aria-pressed={notifyOnCompletion}
onClick={() => setNotifyOnCompletion(!notifyOnCompletion)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
setNotifyOnCompletion(!notifyOnCompletion);
}
}}
>
<Checkbox checked={notifyOnCompletion} onChange={setNotifyOnCompletion} ariaLabel={t('settings.notifications.page.events.completionAria')} />
<span className="typography-ui-label text-foreground">{t('settings.notifications.page.events.completionLabel')}</span>
</div>
<div
className="group flex cursor-pointer items-center gap-2 py-1.5"
role="button"
tabIndex={0}
aria-pressed={notifyOnSubtasks}
onClick={() => setNotifyOnSubtasks(!notifyOnSubtasks)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
setNotifyOnSubtasks(!notifyOnSubtasks);
}
}}
>
<Checkbox checked={notifyOnSubtasks} onChange={setNotifyOnSubtasks} ariaLabel={t('settings.notifications.page.events.subtaskAria')} />
<span className="typography-ui-label text-foreground">{t('settings.notifications.page.events.subtaskLabel')}</span>
</div>
<div
className="group flex cursor-pointer items-center gap-2 py-1.5"
role="button"
tabIndex={0}
aria-pressed={notifyOnError}
onClick={() => setNotifyOnError(!notifyOnError)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
setNotifyOnError(!notifyOnError);
}
}}
>
<Checkbox checked={notifyOnError} onChange={setNotifyOnError} ariaLabel={t('settings.notifications.page.events.errorAria')} />
<span className="typography-ui-label text-foreground">{t('settings.notifications.page.events.errorLabel')}</span>
</div>
<div
className="group flex cursor-pointer items-center gap-2 py-1.5"
role="button"
tabIndex={0}
aria-pressed={notifyOnQuestion}
onClick={() => setNotifyOnQuestion(!notifyOnQuestion)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
setNotifyOnQuestion(!notifyOnQuestion);
}
}}
>
<Checkbox checked={notifyOnQuestion} onChange={setNotifyOnQuestion} ariaLabel={t('settings.notifications.page.events.questionAria')} />
<span className="typography-ui-label text-foreground">{t('settings.notifications.page.events.questionLabel')}</span>
</div>
</section>
</div>
{/* --- Template Customization (not on the native app — it uses generic text) --- */}
{!isNativeApp && (
<div className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">
{t('settings.notifications.page.template.title')}
</h3>
<p className="typography-meta text-muted-foreground mt-0.5">
<SettingsSection
title={t('settings.notifications.page.template.title')}
description={(
<>
{t('settings.notifications.page.template.variablesLabel')}{' '}
<code className="text-[var(--primary-base)]">{'{project_name}'}</code>{' '}
<code className="text-[var(--primary-base)]">{'{worktree}'}</code>{' '}
@@ -647,15 +568,15 @@ export const NotificationSettings: React.FC = () => {
<code className="text-[var(--primary-base)]">{'{agent_name}'}</code>{' '}
<code className="text-[var(--primary-base)]">{'{model_name}'}</code>{' '}
<code className="text-[var(--primary-base)]">{'{last_message}'}</code>
</p>
</div>
<div className="grid grid-cols-1 gap-2 md:grid-cols-2 md:gap-3">
</>
)}
>
<SettingsTwoColumn className="gap-2 md:grid-cols-2 md:gap-3 lg:gap-3">
{(['completion', 'subtask', 'error', 'question'] as const).map((event: NotificationTemplateEvent) => (
<section key={event} className="p-2">
<span className="typography-ui-label text-foreground font-normal capitalize block">
<SettingsGroupTitle className="capitalize">
{t(TEMPLATE_EVENT_LABEL_KEYS[event])}
</span>
</SettingsGroupTitle>
<div className="mt-1.5 space-y-2">
<div>
<label className="typography-micro text-muted-foreground block mb-1">{t('settings.notifications.page.template.field.title')}</label>
@@ -678,56 +599,40 @@ export const NotificationSettings: React.FC = () => {
</div>
</section>
))}
</div>
</div>
</SettingsTwoColumn>
</SettingsSection>
)}
</>
)}
{/* --- Background Push Notifications --- */}
{isBrowser && (
<div data-settings-item="notifications.push" className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">
{t('settings.notifications.page.push.title')}
</h3>
</div>
<section className="px-2 pb-2 pt-0">
<div className="flex items-start gap-2 py-1.5">
<Checkbox
checked={pushSupported ? pushSubscribed : false}
disabled={!pushSupported || pushBusy}
onChange={(checked: boolean) => {
if (checked) {
void handleEnableBackgroundNotifications();
} else {
void handleDisableBackgroundNotifications();
}
}}
ariaLabel={t('settings.notifications.page.push.enableAria')}
/>
<div className="flex min-w-0 flex-col">
<span className={cn("typography-ui-label", !pushSupported ? "text-muted-foreground" : "text-foreground")}>
{t('settings.notifications.page.push.enableLabel')}
</span>
<span className="typography-meta text-muted-foreground">
{!pushSupported
? t('settings.notifications.page.push.unsupportedHint')
: t('settings.notifications.page.push.supportedHint')}
</span>
</div>
{pushBusy && (
<div className="pt-0.5 text-muted-foreground">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-current animate-busy-pulse" aria-label={t('settings.notifications.page.push.loadingAria')} />
</div>
)}
</div>
</section>
</div>
<SettingsSection
settingsItem="notifications.push"
title={t('settings.notifications.page.push.title')}
>
<SettingsCheckboxRow
checked={pushSupported ? pushSubscribed : false}
disabled={!pushSupported || pushBusy}
onChange={(checked) => {
if (checked) {
void handleEnableBackgroundNotifications();
} else {
void handleDisableBackgroundNotifications();
}
}}
label={t('settings.notifications.page.push.enableLabel')}
description={!pushSupported ? t('settings.notifications.page.push.unsupportedHint') : undefined}
info={pushSupported ? t('settings.notifications.page.push.supportedHint') : undefined}
ariaLabel={t('settings.notifications.page.push.enableAria')}
labelAccessory={
pushBusy ? (
<span className="inline-block h-1.5 w-1.5 rounded-full bg-current text-muted-foreground animate-busy-pulse" aria-label={t('settings.notifications.page.push.loadingAria')} />
) : null
}
/>
</SettingsSection>
)}
</div>
</>
);
};
@@ -12,9 +12,11 @@ import { TunnelSettings } from './TunnelSettings';
import { OpenCodeCliSettings } from './OpenCodeCliSettings';
import { DesktopNetworkSettings } from './DesktopNetworkSettings';
import { KeyboardShortcutsSettings } from './KeyboardShortcutsSettings';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
import { useDeviceInfo } from '@/lib/device';
import { isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime, isWebRuntime, usesFramelessElectronChrome } from '@/lib/desktop';
import { isCapacitorApp } from '@/lib/platform';
import { useI18n } from '@/lib/i18n';
import { subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
import type { OpenChamberSection } from './types';
@@ -34,6 +36,7 @@ interface OpenChamberPageProps {
}
export const OpenChamberPage: React.FC<OpenChamberPageProps> = ({ section }) => {
const { t } = useI18n();
const { isMobile } = useDeviceInfo();
const runtimeEndpointEpoch = useRuntimeEndpointEpoch();
const showAbout = isMobile && isWebRuntime();
@@ -44,44 +47,23 @@ export const OpenChamberPage: React.FC<OpenChamberPageProps> = ({ section }) =>
// If no section specified, show all (mobile/legacy behavior)
if (!section) {
return (
<ScrollableOverlay
outerClassName="h-full"
className="w-full"
>
<div className="openchamber-page-body mx-auto max-w-3xl space-y-3 p-3 sm:space-y-6 sm:p-6 sm:pt-8">
<OpenChamberVisualSettings />
<div className="border-t border-border/40 pt-6">
<DefaultsSettings />
</div>
{showDesktopNetworkSettings && (
<div className="border-t border-border/40 pt-6">
<DesktopNetworkSettings />
</div>
)}
{!isVSCode && (
<div className="border-t border-border/40 pt-6">
<OpenCodeCliSettings />
</div>
)}
<div className="border-t border-border/40 pt-6">
<SessionRetentionSettings />
</div>
<div className="border-t border-border/40 pt-6">
<PasskeySettings />
</div>
{showAbout && (
<div className="border-t border-border/40 pt-6">
<AboutSettings />
</div>
)}
</div>
</ScrollableOverlay>
<SettingsPageLayout showSaveStatus className="openchamber-page-body space-y-3 sm:space-y-6">
<OpenChamberVisualSettings />
<DefaultsSettings />
{showDesktopNetworkSettings && <DesktopNetworkSettings />}
{!isVSCode && <OpenCodeCliSettings />}
<SessionRetentionSettings />
{isWebRuntime() && !isDesktopShell() && !isVSCode && !isCapacitorApp() && <PasskeySettings />}
{showAbout && <AboutSettings />}
</SettingsPageLayout>
);
}
// Show specific section content
const renderSectionContent = () => {
switch (section) {
case 'general':
return <GeneralSectionContent />;
case 'visual':
return <VisualSectionContent />;
case 'chat':
@@ -105,15 +87,41 @@ export const OpenChamberPage: React.FC<OpenChamberPageProps> = ({ section }) =>
}
};
const pageTitle = {
general: t('settings.page.general.title'),
visual: t('settings.page.appearance.title'),
chat: t('settings.page.chat.title'),
sessions: t('settings.page.sessions.title'),
shortcuts: t('settings.page.shortcuts.title'),
git: t('settings.page.git.title'),
github: t('settings.page.git.title'),
notifications: t('settings.page.notifications.title'),
voice: t('settings.page.voice.title'),
tunnel: t('settings.page.tunnel.title'),
}[section];
const pageDescription = {
general: t('settings.page.general.description'),
visual: t('settings.page.appearance.description'),
chat: t('settings.page.chat.description'),
sessions: t('settings.page.sessions.description'),
shortcuts: t('settings.page.shortcuts.description'),
git: undefined,
github: undefined,
notifications: t('settings.page.notifications.description'),
voice: t('settings.page.voice.description'),
tunnel: t('settings.page.tunnel.description'),
}[section];
return (
<ScrollableOverlay
outerClassName="h-full"
className="w-full"
<SettingsPageLayout
title={pageTitle}
description={pageDescription}
showSaveStatus
className="openchamber-page-body"
>
<div className="openchamber-page-body mx-auto max-w-3xl space-y-6 p-3 sm:p-6 sm:pt-8">
{renderSectionContent()}
</div>
</ScrollableOverlay>
{renderSectionContent()}
</SettingsPageLayout>
);
};
@@ -121,6 +129,34 @@ const ShortcutsSectionContent: React.FC = () => {
return <KeyboardShortcutsSettings />;
};
// General section: app-level settings — startup/tray/network, access password,
// passkeys, OpenCode CLI binary, message stream transport, privacy.
const GeneralSectionContent: React.FC = () => {
const isVSCode = isVSCodeRuntime();
const runtimeEndpointEpoch = useRuntimeEndpointEpoch();
void runtimeEndpointEpoch;
const showDesktopNetworkSettings = isDesktopShell() && (isDesktopLocalOriginActive() || usesFramelessElectronChrome());
// Passkeys only work against the browser's WebAuthn UI on the web surface —
// desktop shell, VS Code, and the Capacitor app never show the login screen.
const showPasskeySettings = isWebRuntime() && !isDesktopShell() && !isVSCode && !isCapacitorApp();
return (
<>
{showDesktopNetworkSettings && <DesktopNetworkSettings />}
{showPasskeySettings && <PasskeySettings />}
{!isVSCode && <OpenCodeCliSettings />}
<OpenChamberVisualSettings visibleSettings={[
'fileEditorKeymap',
'expandedEditorToolbar',
...(!isVSCode ? ['terminalQuickKeys' as const] : []),
...(!isVSCode ? ['terminalShell' as const] : []),
...(!isVSCode ? ['terminalLoginShell' as const] : []),
'messageTransport',
'reportUsage',
]} />
</>
);
};
// Visual section: Theme Mode, Font Size, Spacing, Input Bar Offset (mobile), Nav Rail
const VisualSectionContent: React.FC = () => {
const isVSCode = isVSCodeRuntime();
@@ -134,14 +170,8 @@ const VisualSectionContent: React.FC = () => {
'fontSize',
'terminalFontSize',
'editorFontSize',
'fileEditorKeymap',
'spacing',
'inputBarOffset',
'expandedEditorToolbar',
...(!isVSCode ? ['terminalQuickKeys' as const] : []),
...(!isVSCode ? ['terminalShell' as const] : []),
...(!isVSCode ? ['terminalLoginShell' as const] : []),
'reportUsage',
]} />;
};
@@ -154,7 +184,6 @@ const ChatSectionContent: React.FC = () => {
'sessionGoal',
'sessionAssist',
'chatRenderMode',
'messageTransport',
'activityRenderMode',
'userMessageRendering',
'mermaidRendering',
@@ -182,40 +211,17 @@ const ChatSectionContent: React.FC = () => {
// Sessions section: Default model & agent, Session retention
const SessionsSectionContent: React.FC = () => {
const isVSCode = isVSCodeRuntime();
const runtimeEndpointEpoch = useRuntimeEndpointEpoch();
void runtimeEndpointEpoch;
const showDesktopNetworkSettings = isDesktopShell() && (isDesktopLocalOriginActive() || usesFramelessElectronChrome());
return (
<div className="space-y-6">
<>
<DefaultsSettings />
{showDesktopNetworkSettings && (
<div className="border-t border-border/40 pt-6">
<DesktopNetworkSettings />
</div>
)}
{!isVSCode && (
<div className="border-t border-border/40 pt-6">
<OpenCodeCliSettings />
</div>
)}
<div className="border-t border-border/40 pt-6">
<SessionRetentionSettings />
</div>
<div className="border-t border-border/40 pt-6">
<PasskeySettings />
</div>
</div>
<SessionRetentionSettings />
</>
);
};
// Git section: Commit message model, Worktree settings
const GitSectionContent: React.FC = () => {
return (
<div className="space-y-6">
<GitSettings />
</div>
);
return <GitSettings />;
};
// GitHub section: Connect account for PR/issue workflows
File diff suppressed because it is too large Load Diff
@@ -1,9 +1,15 @@
import * as React from 'react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Icon } from "@/components/icon/Icon";
import {
SettingsSection,
SettingsFieldRow,
SettingsCheckboxRow,
SettingsInset,
SETTINGS_ICON_BUTTON_CLASS,
SETTINGS_OPTION_STACK_CLASS,
} from '@/components/sections/shared/SettingsSection';
import { isDesktopShell, requestFileAccess } from '@/lib/desktop';
import { updateDesktopSettings } from '@/lib/persistence';
import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
@@ -96,90 +102,69 @@ export const OpenCodeCliSettings: React.FC = () => {
}, [setShowOpenCodeUpdateNotifications]);
return (
<div className="mb-8">
<div className="mb-1 px-1">
<div className="flex items-center gap-2">
<h3 className="typography-ui-header font-medium text-foreground">
{t('settings.openchamber.opencodeCli.title')}
</h3>
<Tooltip>
<TooltipTrigger asChild>
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
{t('settings.openchamber.opencodeCli.tooltipPrefix')}
<SettingsSection title={t('settings.openchamber.opencodeCli.title')}>
<div className="space-y-0.5">
<SettingsFieldRow
settingsItem="sessions.opencode-binary"
label={t('settings.openchamber.opencodeCli.field.binaryPath')}
info={(
<>
{t('settings.openchamber.opencodeCli.tipPrefix')}
{' '}
<code className="font-mono text-xs">opencode</code>
{t('settings.openchamber.opencodeCli.tooltipSuffix')}
</TooltipContent>
</Tooltip>
</div>
</div>
<section className="px-2 pb-2 pt-0 space-y-0.5">
<div data-settings-item="sessions.opencode-binary" className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-3">
<div className="flex min-w-0 flex-col shrink-0">
<span className="typography-ui-label text-foreground">{t('settings.openchamber.opencodeCli.field.binaryPath')}</span>
</div>
<div className="flex min-w-0 items-center gap-2 sm:w-[20rem]">
<Input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={t('settings.openchamber.opencodeCli.field.binaryPathPlaceholder')}
disabled={isLoading || isSaving}
className="h-7 min-w-0 flex-1 font-mono text-xs"
/>
<Button
type="button"
variant="outline"
size="xs"
onClick={handleBrowse}
disabled={isLoading || isSaving || !isDesktopShell()}
className="h-7 w-7 p-0"
aria-label={t('settings.openchamber.opencodeCli.actions.browseAria')}
title={t('settings.openchamber.opencodeCli.actions.browse')}
>
<Icon name="folder" className="h-4 w-4" />
</Button>
</div>
</div>
<div className="py-1.5">
<div className="typography-micro text-muted-foreground/70">
{t('settings.openchamber.opencodeCli.tipPrefix')}
{' '}
<span className="font-mono">OPENCODE_BINARY</span>
{' '}
{t('settings.openchamber.opencodeCli.tipMiddle')}
{' '}
<span className="font-mono">~/.config/openchamber/settings.json</span>
{'.'}
</div>
</div>
<label data-settings-item="sessions.opencode-update-notifications" className="flex cursor-pointer items-center gap-2 py-1.5">
<Checkbox
checked={showOpenCodeUpdateNotifications}
onChange={handleShowUpdateNotificationsChange}
ariaLabel={t('settings.openchamber.opencodeCli.field.showUpdateNotificationsAria')}
<span className="font-mono">OPENCODE_BINARY</span>
{' '}
{t('settings.openchamber.opencodeCli.tipMiddle')}
{' '}
<span className="font-mono">~/.config/openchamber/settings.json</span>
{'.'}
</>
)}
alignEnd={false}
controlClassName="@xl:w-[20rem]"
>
<Input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={t('settings.openchamber.opencodeCli.field.binaryPathPlaceholder')}
disabled={isLoading || isSaving}
className="h-8 min-w-0 flex-1 font-mono text-xs"
/>
<span className="typography-ui-label text-foreground">
{t('settings.openchamber.opencodeCli.field.showUpdateNotifications')}
</span>
</label>
<div className="flex justify-start py-1.5">
<Button
type="button"
variant="outline"
size="xs"
onClick={handleSaveAndReload}
disabled={isLoading || isSaving}
className="shrink-0 !font-normal"
onClick={handleBrowse}
disabled={isLoading || isSaving || !isDesktopShell()}
className={SETTINGS_ICON_BUTTON_CLASS}
aria-label={t('settings.openchamber.opencodeCli.actions.browseAria')}
title={t('settings.openchamber.opencodeCli.actions.browse')}
>
{isSaving ? t('settings.common.actions.saving') : t('settings.openchamber.opencodeCli.actions.saveAndReload')}
<Icon name="folder" className="h-4 w-4" />
</Button>
</div>
</section>
</div>
</SettingsFieldRow>
<SettingsInset className={SETTINGS_OPTION_STACK_CLASS}>
<SettingsCheckboxRow
settingsItem="sessions.opencode-update-notifications"
checked={showOpenCodeUpdateNotifications}
onChange={handleShowUpdateNotificationsChange}
label={t('settings.openchamber.opencodeCli.field.showUpdateNotifications')}
ariaLabel={t('settings.openchamber.opencodeCli.field.showUpdateNotificationsAria')}
/>
<div className="flex justify-start py-1.5">
<Button
type="button"
size="xs"
onClick={handleSaveAndReload}
disabled={isLoading || isSaving}
className="shrink-0 !font-normal"
>
{isSaving ? t('settings.common.actions.saving') : t('settings.openchamber.opencodeCli.actions.saveAndReload')}
</Button>
</div>
</SettingsInset>
</div>
</SettingsSection>
);
};
@@ -14,6 +14,7 @@ import {
type PasskeyStatus,
type StoredPasskey,
} from '@/lib/passkeys';
import { SettingsSection, SettingsFieldRow } from '@/components/sections/shared/SettingsSection';
import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
import { useUIStore, type TimeFormatPreference } from '@/stores/useUIStore';
@@ -172,39 +173,30 @@ export const PasskeySettings: React.FC = () => {
}, [t]);
return (
<div className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.passkeys.title')}</h3>
</div>
<section className="px-2 pb-2 pt-0 space-y-2">
<div className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
<span className="typography-ui-label text-foreground">{t('settings.openchamber.passkeys.field.currentDevice')}</span>
</div>
<div className="flex items-center gap-2 sm:w-fit">
<Button
type="button"
variant={isRegistering ? 'secondary' : 'outline'}
size="xs"
onClick={() => void handleRegisterPasskey()}
disabled={isLoading || isResetting}
className="!font-normal"
>
{isRegistering ? t('settings.openchamber.passkeys.actions.cancelSetup') : t('settings.openchamber.passkeys.actions.add')}
</Button>
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => void handleResetAllAuth()}
disabled={isLoading || isRegistering || isResetting}
className="!font-normal text-muted-foreground hover:text-foreground"
>
{isResetting ? t('settings.openchamber.passkeys.actions.signingOut') : t('settings.openchamber.passkeys.actions.signOutEverywhere')}
</Button>
</div>
</div>
<SettingsSection title={t('settings.openchamber.passkeys.title')}>
<div className="space-y-2">
<SettingsFieldRow label={t('settings.openchamber.passkeys.field.currentDevice')}>
<Button
type="button"
variant={isRegistering ? 'secondary' : 'outline'}
size="xs"
onClick={() => void handleRegisterPasskey()}
disabled={isLoading || isResetting}
className="!font-normal"
>
{isRegistering ? t('settings.openchamber.passkeys.actions.cancelSetup') : t('settings.openchamber.passkeys.actions.add')}
</Button>
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => void handleResetAllAuth()}
disabled={isLoading || isRegistering || isResetting}
className="!font-normal text-muted-foreground hover:text-foreground"
>
{isResetting ? t('settings.openchamber.passkeys.actions.signingOut') : t('settings.openchamber.passkeys.actions.signOutEverywhere')}
</Button>
</SettingsFieldRow>
{!status.enabled && (
<p className="typography-meta text-muted-foreground">
@@ -225,42 +217,42 @@ export const PasskeySettings: React.FC = () => {
) : (
<div className="space-y-1 pt-1">
{passkeys.map((passkey) => (
<div key={passkey.id} className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
<span className="typography-ui-label text-foreground truncate">{passkey.label}</span>
</div>
<div className="flex min-w-0 flex-1 items-center justify-between gap-3">
<span className="typography-meta text-muted-foreground truncate">
{passkey.lastUsedAt
? t('settings.openchamber.passkeys.item.lastUsed', {
time: formatTimestamp(passkey.lastUsedAt, t('settings.openchamber.passkeys.time.neverUsed'), timeFormatPreference),
})
: t('settings.openchamber.passkeys.item.added', {
time: formatTimestamp(passkey.createdAt, t('settings.openchamber.passkeys.time.neverUsed'), timeFormatPreference),
})}
</span>
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => void handleRevokePasskey(passkey.id)}
disabled={revokingId === passkey.id}
className="!font-normal text-muted-foreground hover:text-foreground"
>
{revokingId === passkey.id ? t('settings.openchamber.passkeys.actions.removing') : t('settings.common.actions.delete')}
</Button>
</div>
</div>
<SettingsFieldRow
key={passkey.id}
label={<span className="truncate">{passkey.label}</span>}
alignEnd={false}
controlClassName="justify-between sm:flex-1"
>
<span className="typography-meta text-muted-foreground truncate">
{passkey.lastUsedAt
? t('settings.openchamber.passkeys.item.lastUsed', {
time: formatTimestamp(passkey.lastUsedAt, t('settings.openchamber.passkeys.time.neverUsed'), timeFormatPreference),
})
: t('settings.openchamber.passkeys.item.added', {
time: formatTimestamp(passkey.createdAt, t('settings.openchamber.passkeys.time.neverUsed'), timeFormatPreference),
})}
</span>
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => void handleRevokePasskey(passkey.id)}
disabled={revokingId === passkey.id}
className="!font-normal text-muted-foreground hover:text-foreground"
>
{revokingId === passkey.id ? t('settings.openchamber.passkeys.actions.removing') : t('settings.common.actions.delete')}
</Button>
</SettingsFieldRow>
))}
</div>
)}
</section>
</div>
{errorMessage && (
<div className="mt-1 px-2 py-1.5">
<div className="mt-1 py-1.5">
<p className="typography-meta text-[var(--status-error)]">{errorMessage}</p>
</div>
)}
</div>
</SettingsSection>
);
};
@@ -1,21 +1,27 @@
import React from 'react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { toast } from '@/components/ui';
import { NumberInput } from '@/components/ui/number-input';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Icon } from "@/components/icon/Icon";
import {
SettingsSection,
SettingsFieldRow,
SettingsCheckboxRow,
SettingsChipGroup,
SettingsInset,
SETTINGS_ICON_BUTTON_CLASS,
} from '@/components/sections/shared/SettingsSection';
import { useUIStore } from '@/stores/useUIStore';
import { useSessionAutoCleanup } from '@/hooks/useSessionAutoCleanup';
import { useI18n } from '@/lib/i18n';
import { useI18n, type I18nKey } from '@/lib/i18n';
const MIN_DAYS = 1;
const MAX_DAYS = 365;
const DEFAULT_RETENTION_DAYS = 30;
const RETENTION_ACTION_OPTIONS = [
const RETENTION_ACTION_OPTIONS: Array<{ value: 'archive' | 'delete'; labelKey: I18nKey }> = [
{ value: 'archive', labelKey: 'settings.openchamber.sessionRetention.action.archive' },
{ value: 'delete', labelKey: 'settings.openchamber.sessionRetention.action.delete' },
] as const;
];
export const SessionRetentionSettings: React.FC = () => {
const { t } = useI18n();
@@ -57,121 +63,83 @@ export const SessionRetentionSettings: React.FC = () => {
}, [runCleanup, t]);
return (
<div className="mb-8">
<div className="mb-1 px-1">
<div className="flex items-center gap-2">
<h3 className="typography-ui-header font-medium text-foreground">
{t('settings.openchamber.sessionRetention.title')}
</h3>
<Tooltip>
<TooltipTrigger asChild>
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
{t('settings.openchamber.sessionRetention.tooltip')}
</TooltipContent>
</Tooltip>
</div>
</div>
<SettingsSection
title={t('settings.openchamber.sessionRetention.title')}
info={t('settings.openchamber.sessionRetention.tooltip')}
>
<SettingsCheckboxRow
settingsItem="sessions.auto-cleanup"
checked={autoDeleteEnabled}
onChange={setAutoDeleteEnabled}
label={t('settings.openchamber.sessionRetention.field.enableAutoCleanup')}
ariaLabel={t('settings.openchamber.sessionRetention.field.enableAutoCleanupAria')}
/>
<section className="px-2 pb-2 pt-0 space-y-0.5">
<div
data-settings-item="sessions.auto-cleanup"
className="group flex cursor-pointer items-center gap-2 py-1.5"
role="button"
tabIndex={0}
aria-pressed={autoDeleteEnabled}
onClick={() => setAutoDeleteEnabled(!autoDeleteEnabled)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
setAutoDeleteEnabled(!autoDeleteEnabled);
}
}}
<SettingsInset className="space-y-0">
<SettingsFieldRow
settingsItem="sessions.retention-period"
label={t('settings.openchamber.sessionRetention.field.retentionPeriod')}
>
<Checkbox
checked={autoDeleteEnabled}
onChange={setAutoDeleteEnabled}
ariaLabel={t('settings.openchamber.sessionRetention.field.enableAutoCleanupAria')}
<NumberInput
value={autoDeleteAfterDays}
onValueChange={setAutoDeleteAfterDays}
min={MIN_DAYS}
max={MAX_DAYS}
step={1}
aria-label={t('settings.openchamber.sessionRetention.field.retentionPeriodAria')}
className="w-20 tabular-nums"
/>
<span className="typography-ui-label text-foreground">{t('settings.openchamber.sessionRetention.field.enableAutoCleanup')}</span>
</div>
<span className="typography-ui-label text-muted-foreground">{t('settings.openchamber.sessionRetention.field.days')}</span>
<Button
size="sm"
type="button"
variant="ghost"
onClick={() => setAutoDeleteAfterDays(DEFAULT_RETENTION_DAYS)}
disabled={autoDeleteAfterDays === DEFAULT_RETENTION_DAYS}
className={SETTINGS_ICON_BUTTON_CLASS}
aria-label={t('settings.openchamber.sessionRetention.actions.resetRetentionAria')}
title={t('settings.common.actions.reset')}
>
<Icon name="restart" className="h-3.5 w-3.5" />
</Button>
</SettingsFieldRow>
<div data-settings-item="sessions.retention-period" className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
<span className="typography-ui-label text-foreground">{t('settings.openchamber.sessionRetention.field.retentionPeriod')}</span>
</div>
<div className="flex items-center gap-2 sm:w-fit">
<NumberInput
value={autoDeleteAfterDays}
onValueChange={setAutoDeleteAfterDays}
min={MIN_DAYS}
max={MAX_DAYS}
step={1}
aria-label={t('settings.openchamber.sessionRetention.field.retentionPeriodAria')}
className="w-20 tabular-nums"
/>
<span className="typography-ui-label text-muted-foreground">{t('settings.openchamber.sessionRetention.field.days')}</span>
<Button size="sm"
type="button"
variant="ghost"
onClick={() => setAutoDeleteAfterDays(DEFAULT_RETENTION_DAYS)}
disabled={autoDeleteAfterDays === DEFAULT_RETENTION_DAYS}
className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
aria-label={t('settings.openchamber.sessionRetention.actions.resetRetentionAria')}
title={t('settings.common.actions.reset')}
>
<Icon name="restart" className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<SettingsFieldRow
settingsItem="sessions.retention-action"
label={t('settings.openchamber.sessionRetention.field.whenSessionsExpire')}
>
<SettingsChipGroup
value={sessionRetentionAction}
onChange={setSessionRetentionAction}
options={RETENTION_ACTION_OPTIONS.map((option) => ({
value: option.value,
label: t(option.labelKey),
}))}
/>
</SettingsFieldRow>
</SettingsInset>
<div data-settings-item="sessions.retention-action" className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
<span className="typography-ui-label text-foreground">{t('settings.openchamber.sessionRetention.field.whenSessionsExpire')}</span>
</div>
<div className="flex flex-wrap items-center gap-1 sm:w-fit">
{RETENTION_ACTION_OPTIONS.map((option) => (
<Button
key={option.value}
type="button"
variant="chip"
size="xs"
aria-pressed={sessionRetentionAction === option.value}
className="!font-normal"
onClick={() => setSessionRetentionAction(option.value)}
>
{t(option.labelKey)}
</Button>
))}
</div>
</div>
</section>
<div className="mt-1 px-2 py-1.5 space-y-1">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-8">
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
<p className="typography-meta text-foreground font-medium">{t('settings.openchamber.sessionRetention.manualCleanup.title')}</p>
</div>
<div className="flex items-center gap-2 sm:w-fit">
<Button
type="button"
variant="outline"
size="xs"
onClick={handleRunCleanup}
disabled={isRunning}
className="!font-normal"
>
{isRunning ? t('settings.openchamber.sessionRetention.actions.cleaningUp') : t('settings.openchamber.sessionRetention.actions.runCleanupNow')}
</Button>
</div>
</div>
<div className="mt-1 py-1.5 space-y-1">
<SettingsFieldRow
label={t('settings.openchamber.sessionRetention.manualCleanup.title')}
>
<Button
type="button"
variant="outline"
size="xs"
onClick={handleRunCleanup}
disabled={isRunning}
className="!font-normal"
>
{isRunning ? t('settings.openchamber.sessionRetention.actions.cleaningUp') : t('settings.openchamber.sessionRetention.actions.runCleanupNow')}
</Button>
</SettingsFieldRow>
<p className="typography-meta text-muted-foreground">
{action === 'archive'
? t('settings.openchamber.sessionRetention.manualCleanup.eligibleArchiveNow', { count: pendingCount })
: t('settings.openchamber.sessionRetention.manualCleanup.eligibleDeleteNow', { count: pendingCount })}
</p>
</div>
</div>
</SettingsSection>
);
};
@@ -16,6 +16,8 @@ import { openExternalUrl } from '@/lib/url';
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
import { formatTimeForPreference } from '@/lib/timeFormat';
import { useUIStore, type TimeFormatPreference } from '@/stores/useUIStore';
import { SettingsSection, SettingsGroupTitle, SETTINGS_SELECT_SIZE, SETTINGS_FIELD_LABEL_CLASS, SETTINGS_CALLOUT_TITLE_CLASS } from '@/components/sections/shared/SettingsSection';
import { SettingsInfoHint } from '@/components/sections/shared/SettingsInfoHint';
type TunnelState =
| 'checking'
@@ -1236,26 +1238,24 @@ export const TunnelSettings: React.FC = () => {
}
return (
<div className="space-y-6">
<div>
<h3 className="typography-ui-header font-semibold text-foreground">{t('settings.openchamber.tunnel.title')}</h3>
<p className="typography-meta mt-0 text-muted-foreground/70">
{t('settings.openchamber.tunnel.description')}
</p>
<p className="typography-meta mt-0 text-muted-foreground/60">
{t('settings.openchamber.tunnel.note.serverSideEnforced')}
</p>
<p className="typography-meta mt-0 text-muted-foreground/60">
{t('settings.openchamber.tunnel.note.connectLinksOneTime')}
</p>
</div>
<SettingsSection
title={t('settings.openchamber.tunnel.title')}
info={(
<div className="space-y-1">
<p>{t('settings.openchamber.tunnel.description')}</p>
<p>{t('settings.openchamber.tunnel.note.serverSideEnforced')}</p>
<p>{t('settings.openchamber.tunnel.note.connectLinksOneTime')}</p>
</div>
)}
divider={false}
>
<div className="space-y-6">
{renderedSessionRecords.length > 0 && (
<section className="space-y-2 px-2 pb-2 pt-0">
<div className="rounded-lg border border-[var(--status-info-border)] bg-[var(--status-info-background)]/30 p-3">
<div className="mb-2 flex items-center gap-2">
<Icon name="information" className="size-4 text-[var(--status-info)]" />
<p className="typography-ui-label text-foreground">{t('settings.openchamber.tunnel.section.redeemedAccessLinks')}</p>
<p className={SETTINGS_CALLOUT_TITLE_CLASS}>{t('settings.openchamber.tunnel.section.redeemedAccessLinks')}</p>
</div>
<div className="space-y-1">
{renderedSessionRecords.map((record) => {
@@ -1307,7 +1307,7 @@ export const TunnelSettings: React.FC = () => {
<div className="flex items-start gap-2 rounded-lg border border-[var(--status-warning)]/30 bg-[var(--status-warning)]/5 p-3">
<Icon name="error-warning" className="mt-0.5 size-4 shrink-0 text-[var(--status-warning)]" />
<div className="space-y-1">
<p className="typography-meta font-medium text-foreground">
<p className={SETTINGS_CALLOUT_TITLE_CLASS}>
{t('settings.openchamber.tunnel.notAvailable.dependencyNotFound', { dependency: displayedDependencyInstallInfo.dependency })}
</p>
<p className="typography-meta text-muted-foreground/70">{t('settings.openchamber.tunnel.notAvailable.installHint')}</p>
@@ -1323,7 +1323,7 @@ export const TunnelSettings: React.FC = () => {
<section className="space-y-4 px-2 pb-2 pt-0">
<div className="space-y-3">
<div data-settings-item="tunnel.provider" className="space-y-1.5">
<p className="typography-ui-label text-foreground">{t('settings.openchamber.tunnel.field.provider')}</p>
<p className={SETTINGS_FIELD_LABEL_CLASS}>{t('settings.openchamber.tunnel.field.provider')}</p>
<Select
value={tunnelProvider}
onValueChange={(value) => {
@@ -1331,7 +1331,7 @@ export const TunnelSettings: React.FC = () => {
}}
disabled={isSavingMode || state === 'starting' || state === 'stopping'}
>
<SelectTrigger className="max-w-[16rem]">
<SelectTrigger size={SETTINGS_SELECT_SIZE} className="max-w-[16rem]">
<SelectValue placeholder={t('settings.openchamber.tunnel.field.providerPlaceholder')}>
{getProviderLabel(tunnelProvider)}
</SelectValue>
@@ -1354,7 +1354,7 @@ export const TunnelSettings: React.FC = () => {
</div>
<div data-settings-item="tunnel.type" className="space-y-1.5">
<p className="typography-ui-label text-foreground">{t('settings.openchamber.tunnel.field.tunnelType')}</p>
<p className={SETTINGS_FIELD_LABEL_CLASS}>{t('settings.openchamber.tunnel.field.tunnelType')}</p>
<div className="flex flex-wrap items-center gap-1">
{tunnelModeOptions.map((option) => (
<Tooltip key={option.value}>
@@ -1383,7 +1383,7 @@ export const TunnelSettings: React.FC = () => {
<div data-settings-item="tunnel.ttl" className="mt-2 grid grid-cols-1 gap-2 py-1.5 md:grid-cols-[14rem_auto] md:gap-x-8 md:gap-y-2">
<div className="flex min-w-0 items-center gap-2">
<span className="typography-ui-label shrink-0 text-foreground">{t('settings.openchamber.tunnel.field.connectLinkTtl')}</span>
<span className={cn(SETTINGS_FIELD_LABEL_CLASS, 'shrink-0')}>{t('settings.openchamber.tunnel.field.connectLinkTtl')}</span>
<Select
value={ttlOptionValue(BOOTSTRAP_TTL_OPTIONS, bootstrapTtlMs, '1800000')}
onValueChange={(value) => {
@@ -1391,7 +1391,7 @@ export const TunnelSettings: React.FC = () => {
}}
disabled={isSavingTtl || isSavingMode || state === 'starting' || state === 'stopping'}
>
<SelectTrigger className="max-w-[11rem] min-w-0">
<SelectTrigger size={SETTINGS_SELECT_SIZE} className="max-w-[11rem] min-w-0">
<SelectValue className="truncate">
{ttlOptionLabel(BOOTSTRAP_TTL_OPTIONS, bootstrapTtlMs, '1800000')}
</SelectValue>
@@ -1405,7 +1405,7 @@ export const TunnelSettings: React.FC = () => {
</div>
<div className="flex min-w-0 items-center gap-2">
<span className="typography-ui-label shrink-0 text-foreground">{t('settings.openchamber.tunnel.field.tunnelSessionTtl')}</span>
<span className={cn(SETTINGS_FIELD_LABEL_CLASS, 'shrink-0')}>{t('settings.openchamber.tunnel.field.tunnelSessionTtl')}</span>
<Select
value={ttlOptionValue(SESSION_TTL_OPTIONS, sessionTtlMs, '28800000')}
onValueChange={(value) => {
@@ -1413,7 +1413,7 @@ export const TunnelSettings: React.FC = () => {
}}
disabled={isSavingTtl || isSavingMode || state === 'starting' || state === 'stopping'}
>
<SelectTrigger className="max-w-[11rem] min-w-0">
<SelectTrigger size={SETTINGS_SELECT_SIZE} className="max-w-[11rem] min-w-0">
<SelectValue className="truncate">
{ttlOptionLabel(SESSION_TTL_OPTIONS, sessionTtlMs, '28800000')}
</SelectValue>
@@ -1456,7 +1456,7 @@ export const TunnelSettings: React.FC = () => {
)}
<div className="mb-1 flex items-center justify-between gap-3">
<p className="typography-ui-label text-foreground">{t('settings.openchamber.tunnel.section.savedManagedRemoteTunnels')}</p>
<SettingsGroupTitle>{t('settings.openchamber.tunnel.section.savedManagedRemoteTunnels')}</SettingsGroupTitle>
<Button
variant="ghost"
size="xs"
@@ -1634,20 +1634,9 @@ export const TunnelSettings: React.FC = () => {
<div className="flex items-center gap-1.5">
<p className="typography-meta text-muted-foreground/80">{t('settings.openchamber.tunnel.note.tokensSavedPerTunnel')}</p>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="rounded p-0.5 text-muted-foreground/70 hover:text-foreground"
aria-label={t('settings.openchamber.tunnel.field.managedRemoteTokenInfoAria')}
>
<Icon name="information" className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
{t('settings.openchamber.tunnel.tooltip.tokensSavedPath')}
</TooltipContent>
</Tooltip>
<SettingsInfoHint>
{t('settings.openchamber.tunnel.tooltip.tokensSavedPath')}
</SettingsInfoHint>
</div>
{!selectedPreset && managedRemoteValidationError && (
@@ -1659,7 +1648,7 @@ export const TunnelSettings: React.FC = () => {
{tunnelMode === 'managed-local' && (
<div data-settings-item="tunnel.managed-local-config" className="space-y-2 rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)] p-3">
<div className="space-y-1.5">
<p className="typography-ui-label text-foreground">{t('settings.openchamber.tunnel.field.configurationFile')}</p>
<p className={SETTINGS_FIELD_LABEL_CLASS}>{t('settings.openchamber.tunnel.field.configurationFile')}</p>
<input
ref={managedLocalConfigFileInputRef}
type="file"
@@ -1772,7 +1761,7 @@ export const TunnelSettings: React.FC = () => {
{tunnelMode === 'managed-remote' && (
<div className="space-y-1.5">
<p className="typography-ui-label text-foreground">{t('settings.openchamber.tunnel.field.managedRemoteTunnelToConnect')}</p>
<p className={SETTINGS_FIELD_LABEL_CLASS}>{t('settings.openchamber.tunnel.field.managedRemoteTunnelToConnect')}</p>
<Select
value={selectedPresetId || (managedRemoteTunnelPresets[0]?.id ?? '')}
onValueChange={(presetId) => {
@@ -1785,7 +1774,7 @@ export const TunnelSettings: React.FC = () => {
|| managedRemoteTunnelPresets.length <= 1
}
>
<SelectTrigger>
<SelectTrigger size={SETTINGS_SELECT_SIZE}>
<SelectValue placeholder={t('settings.openchamber.tunnel.field.selectSavedTunnelPlaceholder')}>
{selectedPreset?.name}
</SelectValue>
@@ -1909,6 +1898,7 @@ export const TunnelSettings: React.FC = () => {
<Button size="sm" variant="ghost" onClick={handleStart}>{t('settings.openchamber.tunnel.actions.retry')}</Button>
</section>
)}
</div>
</div>
</SettingsSection>
);
};
@@ -9,12 +9,23 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { Radio } from '@/components/ui/radio';
import { Button } from '@/components/ui/button';
import { NumberInput } from '@/components/ui/number-input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Icon } from "@/components/icon/Icon";
import {
SettingsSection,
SettingsCheckboxRow,
SettingsFieldRow,
SettingsControlGroup,
SettingsChipGroup,
SETTINGS_SELECT_SIZE,
SETTINGS_SELECT_ROW_TRIGGER_CLASS,
SETTINGS_CONTROL_CLUSTER_CLASS,
SETTINGS_FIELD_LABEL_CLASS,
SETTINGS_HELPER_CLASS,
} from '@/components/sections/shared/SettingsSection';
import { SettingsInfoHint } from '@/components/sections/shared/SettingsInfoHint';
import { browserVoiceService } from '@/lib/voice/browserVoiceService';
import { cn } from '@/lib/utils';
import { runtimeFetch } from '@/lib/runtime-fetch';
@@ -153,92 +164,104 @@ const LocalModelPicker = ({
};
return (
<div role="radiogroup" aria-label={t('settings.voice.page.field.model')} className="mt-1 space-y-0">
<div
role="radiogroup"
aria-label={t('settings.voice.page.field.model')}
className="mt-1.5 grid w-full grid-cols-1 gap-3 @xl:grid-cols-2"
>
{LOCAL_STT_MODELS.map((entry) => {
const selected = selectedModelId === entry.id;
const state = models.get(entry.id) ?? null;
return (
<div key={entry.id} className="flex w-full items-start gap-2 py-1.5">
<div className="pt-0.5">
<Radio
checked={selected}
onChange={() => onSelect(entry.id)}
ariaLabel={tUnsafe(entry.labelKey)}
/>
</div>
<div
className="min-w-0 flex-1 cursor-pointer"
role="button"
tabIndex={0}
onClick={() => onSelect(entry.id)}
onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); onSelect(entry.id); } }}
>
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
<span className={cn('typography-ui-label font-normal', selected ? 'text-foreground' : 'text-foreground/50')}>
{tUnsafe(entry.labelKey)}
</span>
{entry.badgeKey ? (
<span
className="rounded px-1 text-[9px] font-medium uppercase leading-[14px] tracking-wide"
style={{
backgroundColor: 'var(--status-success-background)',
color: 'var(--status-success)',
border: '1px solid var(--status-success-border)',
}}
>
{tUnsafe(entry.badgeKey)}
<div
key={entry.id}
className={cn(
'rounded-lg border border-[var(--interactive-border)] p-3',
selected && 'border-[var(--primary-base)] bg-[var(--primary-base)]/5',
)}
>
<div className="flex items-start justify-between gap-2">
<div
className="flex min-w-0 flex-1 cursor-pointer items-start gap-2"
role="button"
tabIndex={0}
onClick={() => onSelect(entry.id)}
onKeyDown={(e) => {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
onSelect(entry.id);
}
}}
>
<div className="pt-0.5">
<Radio
checked={selected}
onChange={() => onSelect(entry.id)}
ariaLabel={tUnsafe(entry.labelKey)}
/>
</div>
<div className="min-w-0 flex-1 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<span className={cn('typography-ui-label font-normal', selected ? 'text-foreground' : 'text-foreground/50')}>
{tUnsafe(entry.labelKey)}
</span>
{entry.badgeKey ? (
<span className="rounded border border-[var(--status-success-border)] bg-[var(--status-success-background)] px-1 text-[9px] font-medium uppercase leading-[14px] tracking-wide text-[var(--status-success)]">
{tUnsafe(entry.badgeKey)}
</span>
) : null}
</div>
<div className="flex flex-col gap-1.5">
<RatingBar value={entry.accuracy} label={t('settings.voice.page.stt.meta.accuracy')} />
<RatingBar value={entry.speed} label={t('settings.voice.page.stt.meta.speed')} />
<span className="typography-ui-compact tabular-nums text-muted-foreground">{entry.size}</span>
</div>
{state?.downloadError ? (
<p className="typography-meta text-[var(--status-error)]">{state.downloadError}</p>
) : null}
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
{state?.installed ? (
<>
<Button
variant="ghost"
size="xs"
className="h-6 w-6 p-0 text-muted-foreground hover:text-[var(--status-error)]"
disabled={requestingId === entry.id}
onClick={() => { void handleDelete(entry.id); }}
title={t('settings.voice.page.stt.modelDelete')}
aria-label={t('settings.voice.page.stt.modelDelete')}
>
<Icon name="delete-bin" className="h-4 w-4" />
</Button>
<Icon
name="checkbox-circle"
className="h-4 w-4 flex-shrink-0 text-[var(--status-success)]"
aria-label={t('settings.voice.page.stt.modelInstalled')}
/>
</>
) : state?.downloading ? (
<span className="flex items-center gap-1.5">
<Icon name="loader-4" className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
<span className="typography-ui-compact tabular-nums text-muted-foreground">
{typeof state.downloadProgress === 'number' ? `${state.downloadProgress}%` : ''}
</span>
</span>
) : null}
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-x-4 gap-y-0.5">
<RatingBar value={entry.accuracy} label={t('settings.voice.page.stt.meta.accuracy')} />
<RatingBar value={entry.speed} label={t('settings.voice.page.stt.meta.speed')} />
<span className="typography-ui-compact tabular-nums text-muted-foreground">{entry.size}</span>
</div>
{state?.downloadError ? (
<p className="mt-0.5 typography-meta text-[var(--status-error)]">{state.downloadError}</p>
) : null}
</div>
<div className="flex w-14 flex-shrink-0 items-center justify-end gap-1 pt-0.5">
{state?.installed ? (
<>
) : (
<Button
variant="ghost"
size="xs"
className="h-6 w-6 p-0 text-muted-foreground hover:text-[var(--status-error)]"
className="h-6 w-6 p-0"
disabled={requestingId === entry.id}
onClick={() => { void handleDelete(entry.id); }}
title={t('settings.voice.page.stt.modelDelete')}
aria-label={t('settings.voice.page.stt.modelDelete')}
onClick={() => { void handleDownload(entry.id); }}
title={t('settings.voice.page.stt.modelDownload')}
aria-label={t('settings.voice.page.stt.modelDownload')}
>
<Icon name="delete-bin" className="h-4 w-4" />
<Icon name="download" className="h-4 w-4" />
</Button>
<Icon
name="checkbox-circle"
className="h-4 w-4 flex-shrink-0 text-[var(--status-success)]"
aria-label={t('settings.voice.page.stt.modelInstalled')}
/>
</>
) : state?.downloading ? (
<span className="flex items-center gap-1.5">
<Icon name="loader-4" className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
<span className="typography-ui-compact tabular-nums text-muted-foreground">
{typeof state.downloadProgress === 'number' ? `${state.downloadProgress}%` : ''}
</span>
</span>
) : (
<Button
variant="ghost"
size="xs"
className="h-6 w-6 p-0"
disabled={requestingId === entry.id}
onClick={() => { void handleDownload(entry.id); }}
title={t('settings.voice.page.stt.modelDownload')}
aria-label={t('settings.voice.page.stt.modelDownload')}
>
<Icon name="download" className="h-4 w-4" />
</Button>
)}
)}
</div>
</div>
</div>
);
@@ -759,118 +782,73 @@ export const VoiceSettings: React.FC = () => {
const sliderClass = "flex-1 min-w-0 h-1.5 bg-[var(--interactive-border)] rounded-full appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-[var(--primary-base)] [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-[var(--primary-base)] [&::-moz-range-thumb]:border-0 disabled:opacity-50";
return (
<div className="space-y-8">
<>
<SettingsSection
settingsItem="voice.playback"
title={t('settings.voice.page.section.playbackAndSummary')}
divider={false}
contentClassName="space-y-4"
>
<SettingsCheckboxRow
checked={showMessageTTSButtons}
onChange={setShowMessageTTSButtons}
label={t('settings.voice.page.field.messageReadAloudButton')}
ariaLabel={t('settings.voice.page.field.messageReadAloudButtonAria')}
/>
{/* Playback (read messages aloud) */}
<div data-settings-item="voice.playback" className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">
{t('settings.voice.page.section.playbackAndSummary')}
</h3>
</div>
<section className="px-2 pb-2 pt-0 space-y-0">
<div
className="group flex cursor-pointer items-center gap-2 py-1.5"
role="button"
tabIndex={0}
aria-pressed={showMessageTTSButtons}
onClick={() => setShowMessageTTSButtons(!showMessageTTSButtons)}
onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); setShowMessageTTSButtons(!showMessageTTSButtons); } }}
>
<Checkbox checked={showMessageTTSButtons} onChange={setShowMessageTTSButtons} ariaLabel={t('settings.voice.page.field.messageReadAloudButtonAria')} />
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.messageReadAloudButton')}</span>
</div>
{showMessageTTSButtons && (
<>
<div className="pb-1.5 pt-0.5">
<div className="flex min-w-0 flex-col gap-1.5">
<div className="flex items-center gap-1.5">
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.provider')}</span>
<Tooltip>
<TooltipTrigger asChild>
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
<ul className="space-y-1">
<li><strong>{t('settings.voice.page.provider.browser')}</strong> {t('settings.voice.page.tooltip.browser')}</li>
<li><strong>{t('settings.voice.page.provider.local')}</strong> {t('settings.voice.page.tooltip.localTts')}</li>
<li><strong>OpenAI:</strong> {t('settings.voice.page.tooltip.openai')}</li>
<li><strong>{t('settings.voice.page.provider.custom')}</strong> {t('settings.voice.page.tooltip.custom')}</li>
<li><strong>{t('settings.voice.page.provider.say')}</strong> {t('settings.voice.page.tooltip.say')}</li>
</ul>
</TooltipContent>
</Tooltip>
</div>
<div className="flex flex-wrap items-center gap-1">
<Button
variant="chip"
size="xs"
aria-pressed={voiceProvider === 'browser'}
onClick={() => setVoiceProvider('browser')}
className="!font-normal"
>
{t('settings.voice.page.provider.browser')}
</Button>
<Button
variant="chip"
size="xs"
aria-pressed={voiceProvider === 'local'}
onClick={() => setVoiceProvider('local')}
className="!font-normal"
>
{t('settings.voice.page.provider.local')}
</Button>
<Button
variant="chip"
size="xs"
aria-pressed={voiceProvider === 'openai'}
onClick={() => setVoiceProvider('openai')}
className="!font-normal"
>
OpenAI
</Button>
<Button
variant="chip"
size="xs"
aria-pressed={voiceProvider === 'openai-compatible'}
onClick={() => setVoiceProvider('openai-compatible')}
className="!font-normal"
>
{t('settings.voice.page.provider.custom')}
</Button>
{isSayAvailable && (
<Button
variant="chip"
size="xs"
aria-pressed={voiceProvider === 'say'}
onClick={() => setVoiceProvider('say')}
className="!font-normal"
>
<Icon name="apple" className="w-3.5 h-3.5 mr-0.5" />
{t('settings.voice.page.provider.say')}
</Button>
)}
</div>
</div>
</div>
{showMessageTTSButtons && (
<>
<SettingsControlGroup
title={t('settings.voice.page.field.provider')}
info={(
<ul className="space-y-1">
<li><strong>{t('settings.voice.page.provider.browser')}</strong> {t('settings.voice.page.tooltip.browser')}</li>
<li><strong>{t('settings.voice.page.provider.local')}</strong> {t('settings.voice.page.tooltip.localTts')}</li>
<li><strong>{t('settings.voice.page.provider.openai')}</strong> {t('settings.voice.page.tooltip.openai')}</li>
<li><strong>{t('settings.voice.page.provider.custom')}</strong> {t('settings.voice.page.tooltip.custom')}</li>
<li><strong>{t('settings.voice.page.provider.say')}</strong> {t('settings.voice.page.tooltip.say')}</li>
</ul>
)}
>
<SettingsChipGroup
value={voiceProvider}
onChange={setVoiceProvider}
aria-label={t('settings.voice.page.field.provider')}
className="w-full gap-1.5 sm:gap-2"
options={[
{ value: 'browser', label: t('settings.voice.page.provider.browser') },
{ value: 'local', label: t('settings.voice.page.provider.local') },
{ value: 'openai', label: t('settings.voice.page.provider.openai') },
{ value: 'openai-compatible', label: t('settings.voice.page.provider.custom') },
...(isSayAvailable
? [{
value: 'say' as const,
label: (
<>
<Icon name="apple" className="mr-0.5 h-3.5 w-3.5" />
{t('settings.voice.page.provider.say')}
</>
),
}]
: []),
]}
/>
</SettingsControlGroup>
{/* OpenAI API Key */}
{voiceProvider === 'openai' && (
<div className="py-1.5">
<span className={cn("typography-ui-label text-foreground", !isOpenAIAvailable && "text-[var(--status-error)]")}>
<div className="space-y-1.5">
<span className={cn(SETTINGS_FIELD_LABEL_CLASS, !isOpenAIAvailable && "text-[var(--status-error)]")}>
{t('settings.voice.page.field.apiKey')}
</span>
<span className={cn("typography-meta ml-2", !isOpenAIAvailable ? "text-[var(--status-error)]/80" : "text-muted-foreground")}>
<span className={cn(SETTINGS_HELPER_CLASS, !isOpenAIAvailable && "text-[var(--status-error)]/80")}>
{isOpenAIAvailable && !openaiApiKey
? t('settings.voice.page.field.apiKeyHintUsingConfig')
: !isOpenAIAvailable
? t('settings.voice.page.field.apiKeyHintRequired')
: t('settings.voice.page.field.apiKeyHintProvide')}
</span>
<div className="relative mt-1.5 max-w-xs">
<div className={cn('relative', SETTINGS_CONTROL_CLUSTER_CLASS)}>
<input
type="password"
value={openaiApiKey}
@@ -893,15 +871,15 @@ export const VoiceSettings: React.FC = () => {
{/* OpenAI-compatible custom server */}
{voiceProvider === 'openai-compatible' && (
<div className="py-1.5 space-y-2">
<div>
<span className={cn("typography-ui-label text-foreground", !openaiCompatibleUrl.trim() && "text-[var(--status-error)]")}>
{t('settings.voice.page.field.serverUrl')}
<div className="space-y-3">
<div className="space-y-1.5">
<span className="flex items-center gap-1.5">
<span className={cn(SETTINGS_FIELD_LABEL_CLASS, !openaiCompatibleUrl.trim() && "text-[var(--status-error)]")}>
{t('settings.voice.page.field.serverUrl')}
</span>
<SettingsInfoHint>{t('settings.voice.page.field.serverUrlHint')}</SettingsInfoHint>
</span>
<span className="typography-meta ml-2 text-muted-foreground">
{t('settings.voice.page.field.serverUrlHint')}
</span>
<div className="relative mt-1.5 max-w-xs">
<div className={cn('relative', SETTINGS_CONTROL_CLUSTER_CLASS)}>
<input
type="text"
value={openaiCompatibleUrl}
@@ -920,12 +898,12 @@ export const VoiceSettings: React.FC = () => {
)}
</div>
</div>
<div>
<span className="typography-ui-label text-foreground">API Key</span>
<span className="typography-meta ml-2 text-muted-foreground">
<div className="space-y-1.5">
<span className={SETTINGS_FIELD_LABEL_CLASS}>API Key</span>
<span className={SETTINGS_HELPER_CLASS}>
Optional
</span>
<div className="relative mt-1.5 max-w-xs">
<div className={cn('relative', SETTINGS_CONTROL_CLUSTER_CLASS)}>
<input
type="password"
value={openaiCompatibleApiKey}
@@ -944,9 +922,9 @@ export const VoiceSettings: React.FC = () => {
)}
</div>
</div>
<div>
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.model')}</span>
<div className="relative mt-1.5 max-w-xs">
<div className="space-y-1.5">
<span className={SETTINGS_FIELD_LABEL_CLASS}>{t('settings.voice.page.field.model')}</span>
<div className={cn('relative', SETTINGS_CONTROL_CLUSTER_CLASS)}>
<input
type="text"
value={openaiCompatibleTtsModel}
@@ -956,13 +934,13 @@ export const VoiceSettings: React.FC = () => {
/>
</div>
</div>
<div>
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.voice')}</span>
<span className="typography-meta ml-2 text-muted-foreground">
{t('settings.voice.page.field.voiceIdentifierHint')}
<div className="space-y-1.5">
<span className="flex items-center gap-1.5">
<span className={SETTINGS_FIELD_LABEL_CLASS}>{t('settings.voice.page.field.voice')}</span>
<SettingsInfoHint>{t('settings.voice.page.field.voiceIdentifierHint')}</SettingsInfoHint>
</span>
<div className="flex items-center gap-2 mt-1.5">
<div className="relative max-w-xs flex-1">
<div className={cn('flex items-center gap-2', SETTINGS_CONTROL_CLUSTER_CLASS)}>
<div className="relative min-w-0 flex-1">
<input
type="text"
value={openaiCompatibleVoice}
@@ -983,16 +961,14 @@ export const VoiceSettings: React.FC = () => {
{voiceProvider === 'local' && <LocalTtsModelStatus />}
{/* Voice Selection */}
<div className="flex items-center gap-8 py-1.5">
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">{t('settings.voice.page.field.voice')}</span>
<div className="flex items-center gap-2 w-fit">
<SettingsFieldRow label={t('settings.voice.page.field.voice')}>
{voiceProvider === 'local' && (
<>
<Select
value={String(localTtsVoiceId)}
onValueChange={(value) => setLocalTtsVoiceId(Number.parseInt(value, 10) || 0)}
>
<SelectTrigger className="w-fit">
<SelectTrigger size={SETTINGS_SELECT_SIZE} className={SETTINGS_SELECT_ROW_TRIGGER_CLASS}>
<SelectValue placeholder={t('settings.voice.page.field.selectVoicePlaceholder')}>
{(value) => KOKORO_VOICE_OPTIONS.find((v) => String(v.id) === value)?.label ?? value}
</SelectValue>
@@ -1015,7 +991,7 @@ export const VoiceSettings: React.FC = () => {
{voiceProvider === 'openai' && isOpenAIAvailable && (
<>
<Select value={openaiVoice} onValueChange={setOpenaiVoice}>
<SelectTrigger className="w-fit">
<SelectTrigger size={SETTINGS_SELECT_SIZE} className={SETTINGS_SELECT_ROW_TRIGGER_CLASS}>
<SelectValue placeholder={t('settings.voice.page.field.selectVoicePlaceholder')} />
</SelectTrigger>
<SelectContent>
@@ -1031,13 +1007,13 @@ export const VoiceSettings: React.FC = () => {
)}
{voiceProvider === 'openai-compatible' && (
<span className="typography-meta text-muted-foreground">{t('settings.voice.page.field.configuredAbove')}</span>
<span className={SETTINGS_HELPER_CLASS}>{t('settings.voice.page.field.configuredAbove')}</span>
)}
{voiceProvider === 'say' && isSayAvailable && sayVoices.length > 0 && (
<>
<Select value={sayVoice} onValueChange={setSayVoice}>
<SelectTrigger className="w-fit">
<SelectTrigger size={SETTINGS_SELECT_SIZE} className={SETTINGS_SELECT_ROW_TRIGGER_CLASS}>
<SelectValue placeholder={t('settings.voice.page.field.selectVoicePlaceholder')} />
</SelectTrigger>
<SelectContent>
@@ -1055,7 +1031,7 @@ export const VoiceSettings: React.FC = () => {
{voiceProvider === 'browser' && filteredBrowserVoices.length > 0 && (
<>
<Select value={browserVoice || '$auto'} onValueChange={(value) => setBrowserVoice(value === '$auto' ? '' : value)}>
<SelectTrigger className="w-fit max-w-[200px]">
<SelectTrigger size={SETTINGS_SELECT_SIZE} className={SETTINGS_SELECT_ROW_TRIGGER_CLASS}>
<SelectValue placeholder={t('settings.voice.page.field.auto')} />
</SelectTrigger>
<SelectContent>
@@ -1070,31 +1046,22 @@ export const VoiceSettings: React.FC = () => {
</Button>
</>
)}
</div>
</div>
</SettingsFieldRow>
{/* Speech Rate */}
<div className="flex items-center gap-8 py-1.5">
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">{t('settings.voice.page.field.speechRate')}</span>
<div className="flex items-center gap-2 w-fit">
<SettingsFieldRow label={t('settings.voice.page.field.speechRate')}>
{!isMobile && <input type="range" min={0.5} max={2} step={0.1} value={speechRate} onChange={(e) => setSpeechRate(Number(e.target.value))} className={sliderClass} />}
<NumberInput value={speechRate} onValueChange={setSpeechRate} min={0.5} max={2} step={0.1} className="w-16 tabular-nums" />
</div>
</div>
</SettingsFieldRow>
{/* Speech Pitch */}
<div className="flex items-center gap-8 py-1.5">
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">{t('settings.voice.page.field.speechPitch')}</span>
<div className="flex items-center gap-2 w-fit">
<SettingsFieldRow label={t('settings.voice.page.field.speechPitch')}>
{!isMobile && <input type="range" min={0.5} max={2} step={0.1} value={speechPitch} onChange={(e) => setSpeechPitch(Number(e.target.value))} className={sliderClass} />}
<NumberInput value={speechPitch} onValueChange={setSpeechPitch} min={0.5} max={2} step={0.1} className="w-16 tabular-nums" />
</div>
</div>
</SettingsFieldRow>
{/* Speech Volume */}
<div className="flex items-center gap-8 py-1.5">
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">{t('settings.voice.page.field.speechVolume')}</span>
<div className="flex items-center gap-2 w-fit">
<SettingsFieldRow label={t('settings.voice.page.field.speechVolume')}>
{!isMobile && <input type="range" min={0} max={1} step={0.1} value={speechVolume} onChange={(e) => setSpeechVolume(Number(e.target.value))} className={sliderClass} />}
{isMobile ? (
<NumberInput value={Math.round(speechVolume * 100)} onValueChange={(v) => setSpeechVolume(v / 100)} min={0} max={100} step={10} className="w-16 tabular-nums" />
@@ -1103,131 +1070,77 @@ export const VoiceSettings: React.FC = () => {
{Math.round(speechVolume * 100)}%
</span>
)}
</div>
</div>
</SettingsFieldRow>
{/* TTS input mode */}
<div className="pb-1.5 pt-0.5">
<div className="flex min-w-0 flex-col gap-1.5">
<div className="flex items-center gap-1.5">
<span className="typography-ui-label text-foreground">
{t('settings.voice.page.field.ttsInputMode')}
</span>
</div>
<div className="flex flex-wrap items-center gap-1">
<Button
variant="chip"
size="xs"
aria-pressed={ttsInputMode === 'sanitized'}
onClick={() => setTtsInputMode('sanitized')}
className="!font-normal"
>
{t('settings.voice.page.field.ttsInputModeSanitized')}
</Button>
<Button
variant="chip"
size="xs"
aria-pressed={ttsInputMode === 'raw'}
onClick={() => setTtsInputMode('raw')}
className="!font-normal"
>
{t('settings.voice.page.field.ttsInputModeRaw')}
</Button>
<Button
variant="chip"
size="xs"
aria-pressed={ttsInputMode === 'summarized'}
onClick={() => setTtsInputMode('summarized')}
className="!font-normal"
>
{t('settings.voice.page.field.ttsInputModeSummarized')}
</Button>
</div>
</div>
</div>
</>
)}
</section>
</div>
<SettingsControlGroup title={t('settings.voice.page.field.ttsInputMode')}>
<SettingsChipGroup
value={ttsInputMode}
onChange={setTtsInputMode}
aria-label={t('settings.voice.page.field.ttsInputMode')}
className="w-full gap-1.5 sm:gap-2"
options={[
{ value: 'sanitized', label: t('settings.voice.page.field.ttsInputModeSanitized') },
{ value: 'raw', label: t('settings.voice.page.field.ttsInputModeRaw') },
{ value: 'summarized', label: t('settings.voice.page.field.ttsInputModeSummarized') },
]}
/>
</SettingsControlGroup>
</>
)}
</SettingsSection>
{/* Speech Recognition */}
<div data-settings-item="voice.speech-recognition" className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">
{t('settings.voice.page.section.speechRecognition')}
</h3>
</div>
<SettingsSection
settingsItem="voice.speech-recognition"
title={t('settings.voice.page.section.speechRecognition')}
contentClassName="space-y-4"
>
<SettingsCheckboxRow
checked={dictationEnabled}
onChange={setDictationEnabled}
label={t('settings.voice.page.field.enableVoiceInput')}
ariaLabel={t('settings.voice.page.field.enableVoiceInputAria')}
/>
<section className="px-2 pb-2 pt-0 space-y-0">
<div
className="group flex cursor-pointer items-center gap-2 py-1.5"
role="button"
tabIndex={0}
aria-pressed={dictationEnabled}
onClick={() => setDictationEnabled(!dictationEnabled)}
onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); setDictationEnabled(!dictationEnabled); } }}
{dictationEnabled && (
<>
<SettingsControlGroup
title={t('settings.voice.page.field.provider')}
info={(
<ul className="space-y-1">
<li><strong>{t('settings.voice.page.provider.local')}</strong> {t('settings.voice.page.tooltip.sttLocal')}</li>
<li><strong>{t('settings.voice.page.provider.server')}</strong> {t('settings.voice.page.tooltip.sttServer')}</li>
</ul>
)}
>
<Checkbox checked={dictationEnabled} onChange={setDictationEnabled} ariaLabel={t('settings.voice.page.field.enableVoiceInputAria')} />
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.enableVoiceInput')}</span>
</div>
{dictationEnabled && (<>
<div className="pb-1.5 pt-0.5">
<div className="flex min-w-0 flex-col gap-1.5">
<div className="flex items-center gap-1.5">
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.provider')}</span>
<Tooltip>
<TooltipTrigger asChild>
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
<ul className="space-y-1">
<li><strong>{t('settings.voice.page.provider.local')}</strong> {t('settings.voice.page.tooltip.sttLocal')}</li>
<li><strong>{t('settings.voice.page.provider.server')}</strong> {t('settings.voice.page.tooltip.sttServer')}</li>
</ul>
</TooltipContent>
</Tooltip>
</div>
<div className="flex flex-wrap items-center gap-1">
<Button
variant="chip"
size="xs"
aria-pressed={sttProvider === 'local'}
onClick={() => setSttProvider('local')}
className="!font-normal"
>
{t('settings.voice.page.provider.local')}
</Button>
<Button
variant="chip"
size="xs"
aria-pressed={sttProvider === 'openai-compatible'}
onClick={() => setSttProvider('openai-compatible')}
className="!font-normal"
>
{t('settings.voice.page.provider.server')}
</Button>
</div>
</div>
</div>
<SettingsChipGroup
value={sttProvider}
onChange={setSttProvider}
aria-label={t('settings.voice.page.field.provider')}
className="w-full gap-1.5 sm:gap-2"
options={[
{ value: 'local', label: t('settings.voice.page.provider.local') },
{ value: 'openai-compatible', label: t('settings.voice.page.provider.server') },
]}
/>
</SettingsControlGroup>
{sttProvider === 'local' && (
<div className="py-1.5">
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.model')}</span>
<div className="space-y-1.5">
<span className={SETTINGS_FIELD_LABEL_CLASS}>{t('settings.voice.page.field.model')}</span>
<LocalModelPicker selectedModelId={sttLocalModel} onSelect={setSttLocalModel} />
</div>
)}
{sttProvider === 'openai-compatible' && (
<div className="py-1.5 space-y-2">
<div>
<span className={cn("typography-ui-label text-foreground", !sttServerUrl.trim() && "text-[var(--status-error)]")}>
{t('settings.voice.page.field.serverUrl')}
<div className="space-y-3">
<div className="space-y-1.5">
<span className="flex items-center gap-1.5">
<span className={cn(SETTINGS_FIELD_LABEL_CLASS, !sttServerUrl.trim() && "text-[var(--status-error)]")}>
{t('settings.voice.page.field.serverUrl')}
</span>
<SettingsInfoHint>{t('settings.voice.page.field.sttServerUrlHint')}</SettingsInfoHint>
</span>
<span className="typography-meta ml-2 text-muted-foreground">
{t('settings.voice.page.field.sttServerUrlHint')}
</span>
<div className="relative mt-1.5 max-w-xs">
<div className={cn('relative', SETTINGS_CONTROL_CLUSTER_CLASS)}>
<input
type="text"
value={sttServerUrl}
@@ -1246,12 +1159,12 @@ export const VoiceSettings: React.FC = () => {
)}
</div>
</div>
<div>
<span className="typography-ui-label text-foreground">API Key</span>
<span className="typography-meta ml-2 text-muted-foreground">
<div className="space-y-1.5">
<span className={SETTINGS_FIELD_LABEL_CLASS}>API Key</span>
<span className={SETTINGS_HELPER_CLASS}>
Optional
</span>
<div className="relative mt-1.5 max-w-xs">
<div className={cn('relative', SETTINGS_CONTROL_CLUSTER_CLASS)}>
<input
type="password"
value={sttApiKey}
@@ -1270,9 +1183,9 @@ export const VoiceSettings: React.FC = () => {
)}
</div>
</div>
<div>
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.model')}</span>
<div className="relative mt-1.5 max-w-xs">
<div className="space-y-1.5">
<span className={SETTINGS_FIELD_LABEL_CLASS}>{t('settings.voice.page.field.model')}</span>
<div className={cn('relative', SETTINGS_CONTROL_CLUSTER_CLASS)}>
<input
type="text"
value={sttModel}
@@ -1282,12 +1195,12 @@ export const VoiceSettings: React.FC = () => {
/>
</div>
</div>
<div>
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.language')}</span>
<span className="typography-meta ml-2 text-muted-foreground">
{t('settings.voice.page.field.sttLanguageHint')}
<div className="space-y-1.5">
<span className="flex items-center gap-1.5">
<span className={SETTINGS_FIELD_LABEL_CLASS}>{t('settings.voice.page.field.language')}</span>
<SettingsInfoHint>{t('settings.voice.page.field.sttLanguageHint')}</SettingsInfoHint>
</span>
<div className="relative mt-1.5 max-w-[8rem]">
<div className="relative max-w-[8rem]">
<input
type="text"
value={sttLanguage}
@@ -1299,12 +1212,9 @@ export const VoiceSettings: React.FC = () => {
</div>
</div>
)}
</>)}
</section>
</div>
</div>
</>
)}
</SettingsSection>
</>
);
};
@@ -2,8 +2,8 @@ import React from 'react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { toast } from '@/components/ui';
import { SettingsInfoHint } from '@/components/sections/shared/SettingsInfoHint';
import { Icon } from "@/components/icon/Icon";
import type { Session } from '@opencode-ai/sdk/v2';
import { useProjectsStore } from '@/stores/useProjectsStore';
@@ -326,29 +326,19 @@ export const WorktreeSectionContent: React.FC<WorktreeSectionContentProps> = ({
}, [sessionsKey, isGitRepoLocal, projectPath, refreshWorktrees]);
const setupTooltip = (
<Tooltip>
<TooltipTrigger asChild>
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
{t('settings.openchamber.worktrees.setup.tooltipPrefix')}
{' '}
<code className="font-mono text-xs bg-sidebar-accent/50 px-1 rounded">$ROOT_PROJECT_PATH</code>
{' '}
{t('settings.openchamber.worktrees.setup.tooltipSuffix')}
</TooltipContent>
</Tooltip>
<SettingsInfoHint>
{t('settings.openchamber.worktrees.setup.tooltipPrefix')}
{' '}
<code className="font-mono text-xs bg-sidebar-accent/50 px-1 rounded">$ROOT_PROJECT_PATH</code>
{' '}
{t('settings.openchamber.worktrees.setup.tooltipSuffix')}
</SettingsInfoHint>
);
const listTooltip = (
<Tooltip>
<TooltipTrigger asChild>
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
{t('settings.openchamber.worktrees.list.tooltip')}
</TooltipContent>
</Tooltip>
<SettingsInfoHint>
{t('settings.openchamber.worktrees.list.tooltip')}
</SettingsInfoHint>
);
if (!projectPath) {
@@ -397,14 +387,16 @@ export const WorktreeSectionContent: React.FC<WorktreeSectionContentProps> = ({
placeholder={t('settings.openchamber.worktrees.setup.commandPlaceholder')}
className="h-7 min-w-0 flex-1 font-mono text-xs"
/>
<button
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveCommand(index)}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-destructive/10 hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
className="h-7 w-7 shrink-0 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
aria-label={t('settings.openchamber.worktrees.setup.removeCommandAria')}
>
<Icon name="close" className="h-4 w-4" />
</button>
</Button>
</div>
))}
<Button
@@ -1,4 +1,5 @@
export type OpenChamberSection =
| 'general'
| 'visual'
| 'chat'
| 'shortcuts'
@@ -7,6 +7,7 @@ import { Icon } from '@/components/icon/Icon';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
import { SettingsSection } from '@/components/sections/shared/SettingsSection';
import { RegistryBanner } from './RegistryBanner';
import {
usePluginsStore,
@@ -194,29 +195,28 @@ export const PluginsPage: React.FC = () => {
};
return (
<SettingsPageLayout>
<div>
<div className="flex items-center gap-2 min-w-0">
<h2 className="typography-ui-header font-semibold text-foreground truncate">
{t('settings.plugins.page.header.entry')}
</h2>
<ScopeBadge
scope={selectedEntry.scope}
label={
selectedEntry.scope === 'project'
? t('settings.plugins.sidebar.group.projectEntries')
: t('settings.plugins.sidebar.group.userEntries')
}
/>
</div>
</div>
<SettingsPageLayout
title={t('settings.plugins.page.header.entry')}
titleAccessory={(
<ScopeBadge
scope={selectedEntry.scope}
label={
selectedEntry.scope === 'project'
? t('settings.plugins.sidebar.group.projectEntries')
: t('settings.plugins.sidebar.group.userEntries')
}
/>
)}
showSaveStatus={false}
>
<SettingsSection divider={false}>
<RegistryBanner entryId={selectedEntry.id} spec={selectedEntry.spec} />
</SettingsSection>
<RegistryBanner entryId={selectedEntry.id} spec={selectedEntry.spec} />
<div data-settings-item="plugins.spec" className="space-y-1.5">
<label className="typography-meta text-muted-foreground">
{t('settings.plugins.page.field.spec')}
</label>
<SettingsSection
title={t('settings.plugins.page.field.spec')}
settingsItem="plugins.spec"
>
<Input
value={draft.spec}
onChange={(e) =>
@@ -226,12 +226,12 @@ export const PluginsPage: React.FC = () => {
className="font-mono typography-meta"
spellCheck={false}
/>
</div>
</SettingsSection>
<div data-settings-item="plugins.options" className="space-y-1.5">
<label className="typography-meta text-muted-foreground">
{t('settings.plugins.page.field.options')}
</label>
<SettingsSection
title={t('settings.plugins.page.field.options')}
settingsItem="plugins.options"
>
<Textarea
value={draft.optionsJson}
onChange={(e) =>
@@ -250,26 +250,25 @@ export const PluginsPage: React.FC = () => {
{t('settings.plugins.page.field.options.invalidJson')}
</p>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="default"
size="sm"
onClick={() => void handleEntrySave()}
disabled={!isDirty || !optionsValid || isSaving}
>
{t('settings.plugins.page.action.save')}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleEntryDiscard}
disabled={!isDirty || isSaving}
>
{t('settings.plugins.page.action.discard')}
</Button>
</div>
<div className="flex items-center gap-2 pt-3">
<Button
variant="default"
size="sm"
onClick={() => void handleEntrySave()}
disabled={!isDirty || !optionsValid || isSaving}
>
{t('settings.plugins.page.action.save')}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleEntryDiscard}
disabled={!isDirty || isSaving}
>
{t('settings.plugins.page.action.discard')}
</Button>
</div>
</SettingsSection>
</SettingsPageLayout>
);
}
@@ -312,12 +311,10 @@ export const PluginsPage: React.FC = () => {
};
return (
<SettingsPageLayout>
<div>
<div className="flex items-center gap-2 min-w-0 flex-wrap">
<h2 className="typography-ui-header font-semibold text-foreground truncate">
{t('settings.plugins.page.header.file')}
</h2>
<SettingsPageLayout
title={t('settings.plugins.page.header.file')}
titleAccessory={(
<>
<ScopeBadge
scope={selectedFile.scope}
label={
@@ -335,13 +332,15 @@ export const PluginsPage: React.FC = () => {
>
{selectedFile.fileName}
</span>
</div>
</div>
<div data-settings-item="plugins.content" className="space-y-1.5">
<label className="typography-meta text-muted-foreground">
{t('settings.plugins.page.field.content')}
</label>
</>
)}
showSaveStatus={false}
>
<SettingsSection
title={t('settings.plugins.page.field.content')}
divider={false}
settingsItem="plugins.content"
>
<Textarea
value={draft.content}
onChange={(e) =>
@@ -352,26 +351,25 @@ export const PluginsPage: React.FC = () => {
spellCheck={false}
disabled={isLoadingFile}
/>
</div>
<div className="flex items-center gap-2">
<Button
variant="default"
size="sm"
onClick={() => void handleFileSave()}
disabled={!isDirty || isSaving || isLoadingFile}
>
{t('settings.plugins.page.action.save')}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleFileDiscard}
disabled={isSaving || isLoadingFile}
>
{t('settings.plugins.page.action.discard')}
</Button>
</div>
<div className="flex items-center gap-2 pt-3">
<Button
variant="default"
size="sm"
onClick={() => void handleFileSave()}
disabled={!isDirty || isSaving || isLoadingFile}
>
{t('settings.plugins.page.action.save')}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleFileDiscard}
disabled={isSaving || isLoadingFile}
>
{t('settings.plugins.page.action.discard')}
</Button>
</div>
</SettingsSection>
</SettingsPageLayout>
);
}
@@ -22,6 +22,7 @@ import {
type PluginEntry,
type PluginFile,
} from '@/stores/usePluginsStore';
import { SETTINGS_PANEL_TITLE_CLASS } from '@/components/sections/shared/SettingsSection';
interface PluginsSidebarProps {
onItemSelect?: () => void;
@@ -277,7 +278,7 @@ export const PluginsSidebar: React.FC<PluginsSidebarProps> = ({
header={
<div className="border-b px-3 pt-4 pb-3">
<div className="mb-3 flex items-center justify-between gap-3">
<h2 className="text-base font-semibold text-foreground">
<h2 className={SETTINGS_PANEL_TITLE_CLASS}>
{t('settings.plugins.sidebar.title')}
</h2>
</div>
@@ -20,7 +20,6 @@ import {
} from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { toast } from '@/components/ui';
import { Icon } from "@/components/icon/Icon";
import { useDesktopSshStore } from '@/stores/useDesktopSshStore';
@@ -41,6 +40,8 @@ import {
PROJECT_SETTINGS_CONTROL_WIDTH,
ProjectSettingsSubsection,
} from '@/components/sections/projects/ProjectSettingsSubsection';
import { SETTINGS_SELECT_SIZE } from '@/components/sections/shared/SettingsSection';
import { SettingsInfoHint } from '@/components/sections/shared/SettingsInfoHint';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
@@ -228,7 +229,7 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
return (
<ProjectSettingsSubsection
title={t('settings.projects.actions.title')}
description={t('settings.projects.actions.description')}
info={t('settings.projects.actions.description')}
settingsItem="projects.actions"
headerAction={(
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={handleAddAction}>
@@ -387,14 +388,9 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
placeholder={t('settings.projects.actions.field.overrideUrlPlaceholder')}
className="h-7 w-full max-w-[24rem]"
/>
<Tooltip>
<TooltipTrigger asChild>
<Icon name="information" className="h-3.5 w-3.5 shrink-0 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
{t('settings.projects.actions.field.overrideUrlTooltip')}
</TooltipContent>
</Tooltip>
<SettingsInfoHint contentClassName="max-w-xs">
{t('settings.projects.actions.field.overrideUrlTooltip')}
</SettingsInfoHint>
</div>
{isDesktopShellApp ? (
@@ -414,7 +410,7 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
}));
}}
>
<SelectTrigger className="h-7 w-full">
<SelectTrigger size={SETTINGS_SELECT_SIZE} className="w-full">
<SelectValue placeholder={t('settings.projects.actions.field.useOutputManualUrl')} />
</SelectTrigger>
<SelectContent>
@@ -64,25 +64,26 @@ export const ProjectIdentityFields: React.FC<ProjectIdentityFieldsProps> = ({ fo
<ProjectSettingsSubsection
title={t('settings.projects.page.field.projectName')}
settingsItem="projects.name"
divider={false}
>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder={t('settings.projects.page.field.projectNamePlaceholder')}
className={cn('h-7', PROJECT_SETTINGS_CONTROL_WIDTH)}
className={cn('h-8 rounded-md px-3', PROJECT_SETTINGS_CONTROL_WIDTH)}
/>
</ProjectSettingsSubsection>
<ProjectSettingsSubsection
title={t('settings.projects.page.field.defaultModel')}
description={t('settings.projects.page.field.defaultModelDescription')}
info={t('settings.projects.page.field.defaultModelDescription')}
settingsItem="projects.default-model"
>
<ModelSelector
providerId={parsedDefaultModel.providerId}
modelId={parsedDefaultModel.modelId}
onChange={handleDefaultModelChange}
className={PROJECT_SETTINGS_CONTROL_WIDTH}
className={cn('h-8 min-h-8 rounded-md px-3 max-w-48', PROJECT_SETTINGS_CONTROL_WIDTH)}
/>
</ProjectSettingsSubsection>
@@ -125,6 +126,7 @@ export const ProjectIdentityFields: React.FC<ProjectIdentityFieldsProps> = ({ fo
<ProjectSettingsSubsection
title={t('settings.projects.page.field.projectIcon')}
settingsItem="projects.icon"
contentClassName="space-y-3"
>
<input
ref={fileInputRef}
@@ -176,7 +178,7 @@ export const ProjectIdentityFields: React.FC<ProjectIdentityFieldsProps> = ({ fo
})}
</div>
{effectiveHasImageIcon && showImagePreview && (
<div className="flex items-center gap-2 pt-1">
<div className="flex items-center gap-2">
<span className="typography-meta text-muted-foreground">{t('settings.projects.page.field.preview')}</span>
<span className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-border/60 bg-[var(--surface-elevated)] p-1">
<span
@@ -8,7 +8,6 @@ import {
} from '@/components/sections/projects/useProjectIdentityForm';
import { useProjectIdentityAutoSave } from '@/components/sections/projects/useProjectIdentityAutoSave';
import type { ProjectEntry } from '@/lib/api/types';
import { useI18n } from '@/lib/i18n';
type ProjectSettingsPanelProps = {
project: ProjectEntry | null;
@@ -19,7 +18,6 @@ export const ProjectSettingsPanel: React.FC<ProjectSettingsPanelProps> = ({
project,
onIdentitySave,
}) => {
const { t } = useI18n();
const form = useProjectIdentityForm(project);
const projectRef = React.useMemo(() => {
@@ -39,19 +37,8 @@ export const ProjectSettingsPanel: React.FC<ProjectSettingsPanelProps> = ({
return null;
}
const headerLabel = project.label ?? t('settings.projects.page.title.default');
return (
<div className="space-y-0">
<div className="mb-5 px-1">
<h2 className="typography-ui-header font-semibold text-foreground truncate">
{headerLabel}
</h2>
<p className="typography-meta text-muted-foreground truncate" title={project.path}>
{project.path}
</p>
</div>
<ProjectIdentityFields form={form} />
<ProjectActionsSection projectRef={projectRef} />
<WorktreeSectionContent projectRef={projectRef} />
@@ -1,47 +1,48 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { SettingsSection } from '@/components/sections/shared/SettingsSection';
export const PROJECT_SETTINGS_CONTROL_WIDTH = 'w-full max-w-[30rem]';
export { PROJECT_SETTINGS_CONTROL_WIDTH } from './projectSettingsConstants';
type ProjectSettingsSubsectionProps = {
title: string;
description?: string;
/** Helper text hidden behind an info icon next to the title. */
info?: React.ReactNode;
settingsItem?: string;
titleAccessory?: React.ReactNode;
headerAction?: React.ReactNode;
children: React.ReactNode;
className?: string;
contentClassName?: string;
/** Use false for the first subsection under a page header. @default true */
divider?: boolean;
};
export const ProjectSettingsSubsection: React.FC<ProjectSettingsSubsectionProps> = ({
title,
description,
info,
settingsItem,
titleAccessory,
headerAction,
children,
className,
contentClassName,
divider = true,
}) => {
return (
<section
data-settings-item={settingsItem}
className={cn('border-b border-border/50 py-5 first:pt-0 last:border-b-0', className)}
<SettingsSection
title={title}
description={description}
info={info}
settingsItem={settingsItem}
titleAccessory={titleAccessory}
headerAction={headerAction}
divider={divider}
className={className}
contentClassName={contentClassName}
>
<div className="mb-3 flex items-start justify-between gap-3 px-1">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="typography-ui-header font-medium text-foreground">{title}</h3>
{titleAccessory}
</div>
{description ? (
<p className="mt-0.5 typography-meta text-muted-foreground">{description}</p>
) : null}
</div>
{headerAction ? <div className="shrink-0">{headerAction}</div> : null}
</div>
<div className={cn('space-y-2 px-2', contentClassName)}>{children}</div>
</section>
{children}
</SettingsSection>
);
};
@@ -1,5 +1,5 @@
import React from 'react';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useUIStore } from '@/stores/useUIStore';
import { ProjectSettingsPanel } from '@/components/sections/projects/ProjectSettingsPanel';
@@ -42,19 +42,22 @@ export const ProjectsPage: React.FC = () => {
if (!selectedProject) {
return (
<ScrollableOverlay outerClassName="h-full" className="w-full">
<div className="mx-auto w-full max-w-4xl p-3 sm:p-6 sm:pt-8">
<p className="typography-meta text-muted-foreground">{t('settings.projects.page.empty.noProjects')}</p>
</div>
</ScrollableOverlay>
<SettingsPageLayout title={t('settings.page.projects.title')} showSaveStatus>
<p className="typography-meta text-muted-foreground">{t('settings.projects.page.empty.noProjects')}</p>
</SettingsPageLayout>
);
}
const headerLabel = selectedProject.label ?? t('settings.projects.page.title.default');
return (
<ScrollableOverlay outerClassName="h-full" className="w-full bg-background">
<div className="mx-auto w-full max-w-4xl p-3 sm:p-6 sm:pt-8">
<ProjectSettingsPanel project={selectedProject} onIdentitySave={handleIdentitySave} />
</div>
</ScrollableOverlay>
<SettingsPageLayout
title={headerLabel}
description={selectedProject.path}
showSaveStatus
outerClassName="bg-background"
>
<ProjectSettingsPanel project={selectedProject} onIdentitySave={handleIdentitySave} />
</SettingsPageLayout>
);
};
@@ -11,6 +11,7 @@ import { isVSCodeRuntime } from '@/lib/desktop';
import { sessionEvents } from '@/lib/sessionEvents';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { useI18n } from '@/lib/i18n';
import { SETTINGS_PANEL_TITLE_CLASS } from '@/components/sections/shared/SettingsSection';
export const ProjectsSidebar: React.FC<{ onItemSelect?: () => void }> = ({ onItemSelect }) => {
const { t } = useI18n();
@@ -43,7 +44,7 @@ export const ProjectsSidebar: React.FC<{ onItemSelect?: () => void }> = ({ onIte
variant="background"
header={
<div className={cn('border-b px-3', 'pt-4 pb-3')}>
<h2 className="text-base font-semibold text-foreground mb-3">{t('settings.page.projects.title')}</h2>
<h2 className={`${SETTINGS_PANEL_TITLE_CLASS} mb-3`}>{t('settings.page.projects.title')}</h2>
<div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground">{t('settings.projects.sidebar.total', { count: projects.length })}</span>
{!isVSCode && (
@@ -0,0 +1,4 @@
import { SETTINGS_CONTROL_CLUSTER_CLASS } from '@/components/sections/shared/SettingsSection';
// Keep project controls on the shared settings control width.
export const PROJECT_SETTINGS_CONTROL_WIDTH = SETTINGS_CONTROL_CLUSTER_CLASS;
@@ -1,5 +1,8 @@
import React from 'react';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
import { SettingsSection, SETTINGS_CUSTOM_TRIGGER_CLASS } from '@/components/sections/shared/SettingsSection';
import { SettingsInfoHint } from '@/components/sections/shared/SettingsInfoHint';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
@@ -12,7 +15,6 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { toast } from '@/components/ui';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Icon } from "@/components/icon/Icon";
import type { IconName } from "@/components/icon/icons";
import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
@@ -24,7 +26,6 @@ import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { opencodeClient } from '@/lib/opencode/client';
import { shouldLoadAvailableProviders } from './providerAvailability';
import { QuotaCredentials } from './QuotaCredentials';
const formatCompactNumber = (value: number) => new Intl.NumberFormat(getCurrentIntlLocale(), {
notation: 'compact',
@@ -512,18 +513,15 @@ export const ProvidersPage: React.FC = () => {
if (isAddMode) {
return (
<ScrollableOverlay outerClassName="h-full" className="w-full">
<div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8">
<div data-settings-item="providers.connect" className="mb-4">
<h1 className="typography-ui-header font-semibold text-foreground">{t('settings.providers.page.connect.title')}</h1>
</div>
<div className="mb-8">
<div className="mb-1 px-1">
<h2 className="typography-ui-header font-medium text-foreground">{t('settings.providers.page.connect.selectProviderTitle')}</h2>
</div>
<section className="px-2 pb-2 pt-0">
<SettingsPageLayout
title={t('settings.providers.page.connect.title')}
showSaveStatus={false}
>
<SettingsSection
title={t('settings.providers.page.connect.selectProviderTitle')}
divider={false}
settingsItem="providers.connect"
>
<div className="flex flex-wrap items-center gap-2 py-1.5">
<span className="typography-ui-label text-foreground">{t('settings.providers.page.connect.providerField')}</span>
{availableLoading ? (
@@ -540,9 +538,7 @@ export const ProvidersPage: React.FC = () => {
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
"flex items-center justify-between gap-2 rounded-lg border border-input bg-transparent px-2 py-2 typography-ui-label whitespace-nowrap shadow-none outline-none hover:bg-interactive-hover h-6 w-fit",
)}
className={SETTINGS_CUSTOM_TRIGGER_CLASS}
>
<span className="flex items-center gap-2 min-w-0">
{candidateProviderId ? <ProviderLogo providerId={candidateProviderId} className="h-3.5 w-3.5 flex-shrink-0" /> : null}
@@ -609,32 +605,24 @@ export const ProvidersPage: React.FC = () => {
</DropdownMenu>
)}
</div>
</section>
</div>
</SettingsSection>
{candidateProviderId && (
<div data-settings-item="providers.auth" className="mb-8">
<div className="mb-1 px-1">
<h2 className="typography-ui-header font-medium text-foreground">{t('settings.providers.page.auth.title')}</h2>
</div>
<SettingsSection
title={t('settings.providers.page.auth.title')}
settingsItem="providers.auth"
contentClassName="space-y-4"
>
{authLoading ? (
<p className="typography-meta text-muted-foreground px-2">{t('settings.providers.page.auth.loadingMethods')}</p>
<p className="typography-meta text-muted-foreground">{t('settings.providers.page.auth.loadingMethods')}</p>
) : (
<section className="px-2 pb-2 pt-0 space-y-4">
<>
<div className="py-1.5">
<label className="typography-ui-label text-foreground flex items-center gap-1.5">
{t('settings.providers.page.auth.apiKeyLabel')}
<Tooltip>
<TooltipTrigger asChild>
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
{t('settings.providers.page.auth.apiKeyTooltip')}
</TooltipContent>
</Tooltip>
<SettingsInfoHint>{t('settings.providers.page.auth.apiKeyTooltip')}</SettingsInfoHint>
</label>
<div className="flex flex-col sm:flex-row sm:items-center gap-2 mt-1.5">
<div className="flex flex-col @xl:flex-row @xl:items-center gap-2 mt-1.5">
<Input
type="password"
value={apiKeyInputs[candidateProviderId] ?? ''}
@@ -750,12 +738,11 @@ export const ProvidersPage: React.FC = () => {
</div>
);
})()}
</section>
</>
)}
</div>
</SettingsSection>
)}
</div>
</ScrollableOverlay>
</SettingsPageLayout>
);
}
@@ -784,42 +771,32 @@ export const ProvidersPage: React.FC = () => {
});
return (
<ScrollableOverlay outerClassName="h-full" className="w-full">
<div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8">
{/* Header */}
<div className="mb-4 flex items-center gap-3">
<ProviderLogo providerId={selectedProvider.id} className="h-5 w-5 shrink-0" />
<div className="min-w-0">
<h2 className="typography-ui-header font-semibold text-foreground truncate">
{selectedProvider.name || selectedProvider.id}
</h2>
<p className="typography-meta text-muted-foreground truncate">
<span className="font-mono">{selectedProvider.id}</span>
</p>
</div>
</div>
{/* Authentication */}
<div data-settings-item="providers.auth" className="mb-8">
<div className="mb-1 px-1 flex items-center justify-between gap-2">
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.providers.page.auth.title')}</h3>
<Button
variant="outline"
size="xs"
className="!font-normal"
onClick={() => setShowAuthPanel((prev) => !prev)}
>
{showAuthPanel ? t('settings.providers.page.actions.hide') : t('settings.providers.page.actions.reconnect')}
</Button>
</div>
<section className="px-2 pb-2 pt-0">
<SettingsPageLayout
title={selectedProvider.name || selectedProvider.id}
titleLeading={<ProviderLogo providerId={selectedProvider.id} className="h-5 w-5 shrink-0" />}
description={<span className="font-mono typography-settings-description text-muted-foreground">{selectedProvider.id}</span>}
showSaveStatus={false}
>
<SettingsSection
title={t('settings.providers.page.auth.title')}
divider={false}
headerAction={(
<Button
variant="outline"
size="xs"
className="!font-normal"
onClick={() => setShowAuthPanel((prev) => !prev)}
>
{showAuthPanel ? t('settings.providers.page.actions.hide') : t('settings.providers.page.actions.reconnect')}
</Button>
)}
settingsItem="providers.auth"
>
{!showAuthPanel ? (
<div className="flex items-center gap-1.5 py-1.5">
<Icon name="check" className="w-4 h-4 text-[var(--status-success)] shrink-0" />
<span className="typography-ui-label text-foreground">{t('settings.providers.page.auth.connected')}</span>
<span className="typography-meta text-muted-foreground ml-1">{t('settings.providers.page.auth.useReconnectHint')}</span>
<SettingsInfoHint>{t('settings.providers.page.auth.useReconnectHint')}</SettingsInfoHint>
</div>
) : authLoading ? (
<div className="py-1.5 typography-meta text-muted-foreground">{t('settings.providers.page.auth.loadingMethods')}</div>
@@ -828,16 +805,9 @@ export const ProvidersPage: React.FC = () => {
<div className="py-1.5">
<label className="typography-ui-label text-foreground flex items-center gap-1.5">
{t('settings.providers.page.auth.apiKeyLabel')}
<Tooltip>
<TooltipTrigger asChild>
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
{t('settings.providers.page.auth.apiKeyTooltip')}
</TooltipContent>
</Tooltip>
<SettingsInfoHint>{t('settings.providers.page.auth.apiKeyTooltip')}</SettingsInfoHint>
</label>
<div className="flex flex-col sm:flex-row sm:items-center gap-2 mt-1.5">
<div className="flex flex-col @xl:flex-row @xl:items-center gap-2 mt-1.5">
<Input
type="password"
value={apiKeyInputs[selectedProvider.id] ?? ''}
@@ -944,21 +914,14 @@ export const ProvidersPage: React.FC = () => {
)}
</div>
)}
</section>
</div>
</SettingsSection>
{(selectedProvider.id === 'opencode' || selectedProvider.id === 'opencode-go') && <QuotaCredentials providerId="opencode-go" providerName="OpenCode Go" />}
{(selectedProvider.id === 'ollama' || selectedProvider.id === 'ollama-cloud') && <QuotaCredentials providerId="ollama-cloud" providerName="Ollama Cloud" />}
{selectedProvider.id === 'cursor' && <QuotaCredentials providerId="cursor" providerName="Cursor" />}
{/* Connection Details */}
<div data-settings-item="providers.connection-details" className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.providers.page.connectionDetails.title')}</h3>
</div>
<section className="px-2 pb-2 pt-0">
<div className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:justify-between sm:gap-8">
<SettingsSection
title={t('settings.providers.page.connectionDetails.title')}
settingsItem="providers.connection-details"
>
<div className="flex flex-col gap-2 py-1.5 @xl:flex-row @xl:items-center @xl:justify-between @xl:gap-8">
<div className="flex min-w-0 flex-col">
{selectedSources && (selectedSources.auth.exists || selectedSources.user.exists || selectedSources.project.exists || selectedSources.custom?.exists) ? (
<span className="typography-meta text-muted-foreground">
@@ -985,46 +948,44 @@ export const ProvidersPage: React.FC = () => {
{authBusyKey === `disconnect:${selectedProvider.id}` ? t('settings.providers.page.actions.disconnecting') : t('settings.providers.page.actions.disconnect')}
</Button>
</div>
</section>
</div>
</SettingsSection>
{/* Models */}
<div data-settings-item="providers.models" className="mb-8">
<div className="mb-1 px-1 flex items-center justify-between gap-2">
<h3 className="typography-ui-header font-medium text-foreground">
{t('settings.providers.page.models.title')}
{providerModels.length > 0 && (
<span className="ml-1.5 typography-micro text-muted-foreground font-normal">
({providerModels.length})
</span>
)}
</h3>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="xs"
className="!font-normal"
onClick={() => {
const allIds = providerModels
.map((model) => (typeof model?.id === 'string' ? model.id : ''))
.filter((id) => id.length > 0);
hideAllModels(selectedProvider.id, allIds);
}}
>
{t('settings.providers.page.actions.hideAll')}
</Button>
<Button
variant="outline"
size="xs"
className="!font-normal"
onClick={() => showAllModels(selectedProvider.id)}
>
{t('settings.providers.page.actions.showAll')}
</Button>
</div>
<SettingsSection
title={t('settings.providers.page.models.title')}
titleAccessory={
providerModels.length > 0 ? (
<span className="typography-micro text-muted-foreground font-normal">
({providerModels.length})
</span>
) : null
}
headerAction={(
<div className="flex items-center gap-1">
<Button
variant="outline"
size="xs"
className="!font-normal"
onClick={() => {
const allIds = providerModels
.map((model) => (typeof model?.id === 'string' ? model.id : ''))
.filter((id) => id.length > 0);
hideAllModels(selectedProvider.id, allIds);
}}
>
{t('settings.providers.page.actions.hideAll')}
</Button>
<Button
variant="outline"
size="xs"
className="!font-normal"
onClick={() => showAllModels(selectedProvider.id)}
>
{t('settings.providers.page.actions.showAll')}
</Button>
</div>
<section className="px-2 pb-2 pt-0">
)}
settingsItem="providers.models"
>
<div className="relative mb-2">
<Icon name="search" className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
@@ -1104,9 +1065,7 @@ export const ProvidersPage: React.FC = () => {
})}
</div>
)}
</section>
</div>
</div>
</ScrollableOverlay>
</SettingsSection>
</SettingsPageLayout>
);
};
@@ -10,6 +10,7 @@ import { Icon } from "@/components/icon/Icon";
import { opencodeClient } from '@/lib/opencode/client';
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { SETTINGS_PANEL_TITLE_CLASS } from '@/components/sections/shared/SettingsSection';
const ADD_PROVIDER_ID = '__add_provider__';
@@ -111,7 +112,7 @@ export const ProvidersSidebar: React.FC<ProvidersSidebarProps> = ({ onItemSelect
return (
<div className={cn('flex h-full flex-col', bgClass)}>
<div className="border-b px-3 pt-4 pb-3">
<h2 className="text-base font-semibold text-foreground mb-3">{t('settings.providers.sidebar.title')}</h2>
<h2 className={`${SETTINGS_PANEL_TITLE_CLASS} mb-3`}>{t('settings.providers.sidebar.title')}</h2>
<SettingsProjectSelector className="mb-3" />
<div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground">{t('settings.providers.sidebar.total', { count: providers.length })}</span>
@@ -18,9 +18,16 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
import {
SettingsSection,
SettingsGroupTitle,
SETTINGS_PAGE_TITLE_CLASS,
SETTINGS_FIELD_LABEL_CLASS,
SETTINGS_SELECT_SIZE,
} from '@/components/sections/shared/SettingsSection';
import { SettingsInfoHint } from '@/components/sections/shared/SettingsInfoHint';
import { useDesktopSshStore } from '@/stores/useDesktopSshStore';
import { useUIStore } from '@/stores/useUIStore';
import { toast } from '@/components/ui';
@@ -28,6 +35,7 @@ import { Checkbox } from '@/components/ui/checkbox';
import { Radio } from '@/components/ui/radio';
import { Icon } from "@/components/icon/Icon";
import { cn } from '@/lib/utils';
import { formatDateTimeForPreference } from '@/lib/timeFormat';
import { copyTextToClipboard } from '@/lib/clipboard';
import { openExternalUrl } from '@/lib/url';
import { useI18n, type I18nKey } from '@/lib/i18n';
@@ -184,16 +192,9 @@ const suggestConcreteHost = (pattern: string): string => {
const HintLabel: React.FC<{ label: string; hint: React.ReactNode }> = ({ label, hint }) => {
return (
<span className="inline-flex items-center gap-1 typography-meta text-muted-foreground">
<span className={`inline-flex items-center gap-1 ${SETTINGS_FIELD_LABEL_CLASS}`}>
<span>{label}</span>
<Tooltip>
<TooltipTrigger asChild>
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
<div className="typography-meta text-foreground">{hint}</div>
</TooltipContent>
</Tooltip>
<SettingsInfoHint contentClassName="max-w-xs">{hint}</SettingsInfoHint>
</span>
);
};
@@ -380,6 +381,7 @@ const normalizeForSave = (instance: DesktopSshInstance): DesktopSshInstance => {
export const RemoteInstancesPage: React.FC = () => {
const { t } = useI18n();
const timeFormatPreference = useUIStore((state) => state.timeFormatPreference);
const { clientAuth } = useRuntimeAPIs();
const showInstanceManagement = isDesktopShell();
const instances = useDesktopSshStore((state) => state.instances);
@@ -1384,21 +1386,22 @@ export const RemoteInstancesPage: React.FC = () => {
if (!draft) {
return (
<SettingsPageLayout>
<SettingsPageLayout title={t('settings.page.remoteInstances.title')}>
{clientAuth ? (
<div data-settings-item="remote-instances.client-auth" className="mb-8">
<div className="mb-1 px-1 space-y-0.5">
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.clientAuth.title')}</h3>
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.clientAuth.description')}</p>
</div>
<section className="px-2 pb-2 pt-0 space-y-3">
<SettingsSection
title={t('settings.remoteInstances.clientAuth.title')}
info={t('settings.remoteInstances.clientAuth.description')}
divider={false}
settingsItem="remote-instances.client-auth"
contentClassName="space-y-3"
>
<div>
<Button type="button" size="xs" className="!font-normal" onClick={() => void openAddDevice()}>
<Icon name="add" className="h-3.5 w-3.5" />
{t('settings.remoteInstances.clientAuth.actions.addDevice')}
</Button>
</div>
<div className="space-y-1">
<div className="space-y-2.5">
{revokedClientCount > 0 ? (
<div className="flex justify-end">
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => void purgeRevokedRemoteClients()}>
@@ -1414,7 +1417,7 @@ export const RemoteInstancesPage: React.FC = () => {
<>
{pendingPairings.map((pending) => (
<div key={`pending-${pending.id}`} className="flex items-center justify-between gap-3 py-1.5">
<div className="min-w-0">
<div className="min-w-0 space-y-0.5">
<div className="flex min-w-0 items-center gap-2">
<span className="h-2 w-2 shrink-0 rounded-full bg-[var(--status-warning)] animate-pulse" />
<p className="typography-ui-label text-foreground truncate">{pending.label || t('settings.remoteInstances.clientAuth.field.labelPlaceholder')}</p>
@@ -1444,13 +1447,20 @@ export const RemoteInstancesPage: React.FC = () => {
? (client.lastTransport === 'relay' && !isLocalDesktopClient
? t('settings.remoteInstances.clientAuth.state.connectedRelay')
: t('settings.remoteInstances.clientAuth.state.connectedDirect'))
: client.lastUsedAt
? t('settings.remoteInstances.clientAuth.lastUsed', { date: client.lastUsedAt })
: Number.isFinite(lastUsedMs)
? t('settings.remoteInstances.clientAuth.lastUsed', {
date: formatDateTimeForPreference(lastUsedMs, timeFormatPreference, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
}),
})
: t('settings.remoteInstances.clientAuth.neverUsed');
return (
<div key={client.id} className="flex items-center justify-between gap-3 py-1.5">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0.5">
<span className={cn(
'h-2 w-2 shrink-0 rounded-full',
client.revokedAt ? 'bg-muted-foreground/20' : isOnline ? 'bg-[var(--status-success)]' : 'bg-muted-foreground/30',
@@ -1466,8 +1476,8 @@ export const RemoteInstancesPage: React.FC = () => {
{t('settings.remoteInstances.clientAuth.state.thisDevice')}
</span>
) : null}
<span className={cn('typography-micro truncate', isOnline && !client.revokedAt ? 'text-[var(--status-success)]' : 'text-muted-foreground')}>{statusText}</span>
</div>
<p className={cn('typography-micro truncate', isOnline && !client.revokedAt ? 'text-[var(--status-success)]' : 'text-muted-foreground')}>{statusText}</p>
</div>
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => void revokeRemoteClient(client)} disabled={Boolean(client.revokedAt)}>
{t('settings.remoteInstances.clientAuth.actions.revoke')}
@@ -1479,20 +1489,19 @@ export const RemoteInstancesPage: React.FC = () => {
)}
</div>
{remoteClientError ? <p className="typography-meta text-[var(--status-error)]">{remoteClientError}</p> : null}
</section>
</div>
</SettingsSection>
) : null}
{showInstanceManagement ? <div data-settings-item="remote-instances.direct-hosts" className="mb-8 border-t border-[var(--surface-subtle)] pt-8">
<div className="mb-1 flex items-start justify-between gap-3 px-1">
<div className="min-w-0 space-y-0.5">
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.direct.title')}</h3>
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.direct.description')}</p>
</div>
{/* Importing a pairing link is the flagship path; add-by-address is
the manual fallback. The token-storage note lives in the add
dialog next to the token field it describes. */}
<div className="flex shrink-0 items-center gap-2 pt-0.5">
{showInstanceManagement ? <SettingsSection
title={t('settings.remoteInstances.direct.title')}
info={t('settings.remoteInstances.direct.description')}
settingsItem="remote-instances.direct-hosts"
contentClassName="space-y-4"
headerAction={(
/* Importing a pairing link is the flagship path; add-by-address is
the manual fallback. The token-storage note lives in the add
dialog next to the token field it describes. */
<div className="flex shrink-0 items-center gap-2">
<Button type="button" size="xs" className="!font-normal" onClick={() => setDirectImportDialogOpen(true)} disabled={directSaving}>
{t('settings.remoteInstances.direct.import.action')}
</Button>
@@ -1501,9 +1510,9 @@ export const RemoteInstancesPage: React.FC = () => {
{t('settings.remoteInstances.direct.actions.add')}
</Button>
</div>
</div>
<section className="px-2 pb-2 pt-0 space-y-4">
<div className="space-y-1">
)}
>
<div className="space-y-2.5">
{directLoading ? (
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.direct.state.loading')}</p>
) : directHosts.length === 0 ? (
@@ -1527,7 +1536,7 @@ export const RemoteInstancesPage: React.FC = () => {
return (
<div key={host.id} className="py-1.5">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="min-w-0 space-y-0.5">
<div className="flex min-w-0 items-center gap-2">
<span className={cn(
'h-2 w-2 shrink-0 rounded-full',
@@ -1572,8 +1581,7 @@ export const RemoteInstancesPage: React.FC = () => {
</div>
{directError ? <p className="typography-meta text-[var(--status-error)]">{directError}</p> : null}
</section>
</div> : null}
</SettingsSection> : null}
{showInstanceManagement ? <Dialog open={directAddDialogOpen} onOpenChange={setDirectAddDialogOpen}>
<DialogContent className="sm:max-w-lg">
@@ -1589,9 +1597,9 @@ export const RemoteInstancesPage: React.FC = () => {
<p className="px-1 typography-micro text-muted-foreground">{t('settings.remoteInstances.direct.note')}</p>
</div>
<div className="space-y-2">
<div>
<p className="typography-ui-label text-foreground">{t('settings.remoteInstances.direct.headers.title')}</p>
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.direct.headers.description')}</p>
<div className="flex items-center gap-1.5">
<SettingsGroupTitle>{t('settings.remoteInstances.direct.headers.title')}</SettingsGroupTitle>
<SettingsInfoHint>{t('settings.remoteInstances.direct.headers.description')}</SettingsInfoHint>
</div>
{directHeaders.map((header) => (
<div key={header.id} className="flex w-full gap-2">
@@ -1626,9 +1634,9 @@ export const RemoteInstancesPage: React.FC = () => {
<Input className="h-8" value={directEditUrl} onChange={(event) => setDirectEditUrl(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.urlPlaceholder')} disabled={directSaving} autoFocus />
<Input className="h-8" value={directEditToken} onChange={(event) => setDirectEditToken(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.tokenPlaceholder')} type="password" disabled={directSaving} />
<div className="space-y-2">
<div>
<p className="typography-ui-label text-foreground">{t('settings.remoteInstances.direct.headers.title')}</p>
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.direct.headers.description')}</p>
<div className="flex items-center gap-1.5">
<SettingsGroupTitle>{t('settings.remoteInstances.direct.headers.title')}</SettingsGroupTitle>
<SettingsInfoHint>{t('settings.remoteInstances.direct.headers.description')}</SettingsInfoHint>
</div>
{directEditHeaders.map((header) => (
<div key={header.id} className="flex w-full gap-2">
@@ -1762,20 +1770,17 @@ export const RemoteInstancesPage: React.FC = () => {
</DialogContent>
</Dialog>
{showInstanceManagement ? <div className="mb-8 border-t border-[var(--surface-subtle)] pt-8">
<div className="mb-1 px-1 space-y-0.5">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.sidebar.title')}</h3>
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.sidebar.total', { count: instances.length })}</p>
</div>
<Button type="button" size="xs" className="!font-normal" onClick={() => setSshAddDialogOpen(true)}>
<Icon name="add" className="h-3.5 w-3.5" />
{t('settings.remoteInstances.sidebar.actions.addSshInstance')}
</Button>
</div>
</div>
<section className="px-2 pb-2 pt-0 space-y-1">
{showInstanceManagement ? <SettingsSection
title={t('settings.remoteInstances.sidebar.title')}
description={t('settings.remoteInstances.sidebar.total', { count: instances.length })}
headerAction={(
<Button type="button" size="xs" className="!font-normal" onClick={() => setSshAddDialogOpen(true)}>
<Icon name="add" className="h-3.5 w-3.5" />
{t('settings.remoteInstances.sidebar.actions.addSshInstance')}
</Button>
)}
contentClassName="space-y-2.5"
>
{isLoading ? (
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.import.loading')}</p>
) : instances.length === 0 ? (
@@ -1787,7 +1792,7 @@ export const RemoteInstancesPage: React.FC = () => {
const ready = phase === 'ready';
return (
<div key={instance.id} className="flex items-center justify-between gap-3 py-1.5">
<div className="min-w-0">
<div className="min-w-0 space-y-0.5">
<div className="flex min-w-0 items-center gap-2">
<span className={`h-2 w-2 rounded-full ${phaseDotClass(phase)}`} />
<p className="typography-ui-label text-foreground truncate">{title}</p>
@@ -1824,8 +1829,7 @@ export const RemoteInstancesPage: React.FC = () => {
</div>
);
})}
</section>
</div> : null}
</SettingsSection> : null}
{showInstanceManagement ? <Dialog open={sshAddDialogOpen} onOpenChange={setSshAddDialogOpen}>
<DialogContent className="sm:max-w-lg">
@@ -1844,11 +1848,9 @@ export const RemoteInstancesPage: React.FC = () => {
</DialogContent>
</Dialog> : null}
{showInstanceManagement ? <div className="mb-8 border-t border-[var(--surface-subtle)] pt-8">
<div className="mb-1 px-1 space-y-0.5">
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.import.sectionTitle')}</h3>
</div>
<section className="px-2 pb-2 pt-0">
{showInstanceManagement ? <SettingsSection
title={t('settings.remoteInstances.page.import.sectionTitle')}
>
{isImportsLoading ? (
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.import.loading')}</p>
) : importCandidates.length === 0 ? (
@@ -1877,8 +1879,7 @@ export const RemoteInstancesPage: React.FC = () => {
))}
</div>
)}
</section>
</div> : null}
</SettingsSection> : null}
<Dialog
open={Boolean(patternHost)}
@@ -1930,7 +1931,7 @@ export const RemoteInstancesPage: React.FC = () => {
<Dialog open={Boolean(draft)} onOpenChange={(open) => { if (!open) setSelectedId(null); }}>
<DialogContent className="sm:max-w-4xl max-h-[90vh] overflow-auto">
<div className="mb-6 px-1">
<h2 className="typography-ui-header font-semibold text-foreground truncate">{instanceTitle}</h2>
<h1 className={`${SETTINGS_PAGE_TITLE_CLASS} truncate`}>{instanceTitle}</h1>
<div className="mt-1 flex flex-wrap items-center gap-2 typography-meta text-muted-foreground">
<span className={`h-2.5 w-2.5 rounded-full ${phaseDotClass(statusPhase)}`} />
<span>{t(phaseLabelKey(statusPhase))}</span>
@@ -1939,12 +1940,12 @@ export const RemoteInstancesPage: React.FC = () => {
</div>
</div>
<div className="mb-8">
<div className="mb-1 px-1 space-y-0.5">
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.section.actions')}</h3>
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.section.actionsDescription')}</p>
</div>
<section className="px-2 pb-2 pt-0 space-y-3">
<SettingsSection
title={t('settings.remoteInstances.page.section.actions')}
info={t('settings.remoteInstances.page.section.actionsDescription')}
divider={false}
contentClassName="space-y-3"
>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
@@ -2010,15 +2011,13 @@ export const RemoteInstancesPage: React.FC = () => {
<span className="font-mono text-foreground/90">{status.localUrl}</span>
</div>
) : null}
</section>
</div>
</SettingsSection>
<div className="mb-8">
<div className="mb-1 px-1 space-y-0.5">
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.section.instance')}</h3>
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.section.instanceDescription')}</p>
</div>
<section className="px-2 pb-2 pt-0 space-y-3">
<SettingsSection
title={t('settings.remoteInstances.page.section.instance')}
info={t('settings.remoteInstances.page.section.instanceDescription')}
contentClassName="space-y-3"
>
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
<span className="typography-ui-label text-foreground w-56 shrink-0">{t('settings.remoteInstances.page.field.sshCommand')}</span>
<Input
@@ -2064,15 +2063,13 @@ export const RemoteInstancesPage: React.FC = () => {
}}
/>
</div>
</section>
</div>
</SettingsSection>
<div className="mb-8 border-t border-[var(--surface-subtle)] pt-8">
<div className="mb-1 px-1 space-y-0.5">
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.section.remoteServer')}</h3>
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.section.remoteServerDescription')}</p>
</div>
<section className="px-2 pb-2 pt-0 space-y-3">
<SettingsSection
title={t('settings.remoteInstances.page.section.remoteServer')}
info={t('settings.remoteInstances.page.section.remoteServerDescription')}
contentClassName="space-y-3"
>
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
<div className="w-56 shrink-0">
<HintLabel
@@ -2092,7 +2089,7 @@ export const RemoteInstancesPage: React.FC = () => {
}))
}
>
<SelectTrigger className="h-7 w-fit min-w-[140px]">
<SelectTrigger size={SETTINGS_SELECT_SIZE} className="w-fit min-w-[140px]">
<SelectValue placeholder={t('settings.remoteInstances.page.field.modePlaceholder')} />
</SelectTrigger>
<SelectContent>
@@ -2161,7 +2158,7 @@ export const RemoteInstancesPage: React.FC = () => {
}))
}
>
<SelectTrigger className="h-7 w-fit min-w-[140px]">
<SelectTrigger size={SETTINGS_SELECT_SIZE} className="w-fit min-w-[140px]">
<SelectValue placeholder={t('settings.remoteInstances.page.field.selectInstallMethodPlaceholder')} />
</SelectTrigger>
<SelectContent>
@@ -2198,15 +2195,13 @@ export const RemoteInstancesPage: React.FC = () => {
</div>
</div>
) : null}
</section>
</div>
</SettingsSection>
<div className="mb-8 border-t border-[var(--surface-subtle)] pt-8">
<div className="mb-1 px-1 space-y-0.5">
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.section.mainTunnel')}</h3>
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.section.mainTunnelDescription')}</p>
</div>
<section className="px-2 pb-2 pt-0 space-y-3">
<SettingsSection
title={t('settings.remoteInstances.page.section.mainTunnel')}
info={t('settings.remoteInstances.page.section.mainTunnelDescription')}
contentClassName="space-y-3"
>
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
<div className="w-56 shrink-0">
<HintLabel
@@ -2232,7 +2227,7 @@ export const RemoteInstancesPage: React.FC = () => {
}));
}}
>
<SelectTrigger className="h-7 w-fit min-w-[140px]">
<SelectTrigger size={SETTINGS_SELECT_SIZE} className="w-fit min-w-[140px]">
<SelectValue placeholder={t('settings.remoteInstances.page.field.selectBindHostPlaceholder')} />
</SelectTrigger>
<SelectContent>
@@ -2298,15 +2293,13 @@ export const RemoteInstancesPage: React.FC = () => {
</Button>
</div>
</div>
</section>
</div>
</SettingsSection>
<div className="mb-8 border-t border-[var(--surface-subtle)] pt-8">
<div className="mb-1 px-1 space-y-0.5">
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.section.authentication')}</h3>
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.section.authenticationDescription')}</p>
</div>
<section className="px-2 pb-2 pt-0 space-y-3">
<SettingsSection
title={t('settings.remoteInstances.page.section.authentication')}
info={t('settings.remoteInstances.page.section.authenticationDescription')}
contentClassName="space-y-3"
>
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
<span className="typography-ui-label text-foreground w-56 shrink-0">{t('settings.remoteInstances.page.field.sshPasswordOptional')}</span>
<Input
@@ -2352,15 +2345,13 @@ export const RemoteInstancesPage: React.FC = () => {
placeholder={t('settings.remoteInstances.page.field.uiPasswordPlaceholder')}
/>
</div>
</section>
</div>
</SettingsSection>
<div className="mb-8 border-t border-[var(--surface-subtle)] pt-8">
<div className="mb-1 px-1 space-y-0.5">
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.section.portForwards')}</h3>
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.section.portForwardsDescription')}</p>
</div>
<section className="px-2 pb-2 pt-0 space-y-2">
<SettingsSection
title={t('settings.remoteInstances.page.section.portForwards')}
info={t('settings.remoteInstances.page.section.portForwardsDescription')}
contentClassName="space-y-2"
>
{draft.portForwards.length === 0 ? (
<p className="typography-micro text-muted-foreground/80">{t('settings.remoteInstances.page.empty.noExtraForwards')}</p>
) : null}
@@ -2436,12 +2427,16 @@ export const RemoteInstancesPage: React.FC = () => {
</div>
<CollapsibleContent className="pt-2">
<div className="space-y-0 pb-2">
<p className="typography-meta text-muted-foreground mb-3">{t(forwardTypeDescriptionKey(forward.type))}</p>
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
<div className="w-56 shrink-0">
<HintLabel
label={t('settings.remoteInstances.page.field.forwardType')}
hint={t('settings.remoteInstances.page.field.forwardTypeHint')}
hint={(
<div className="space-y-1">
<p>{t('settings.remoteInstances.page.field.forwardTypeHint')}</p>
<p>{t(forwardTypeDescriptionKey(forward.type))}</p>
</div>
)}
/>
</div>
<Select
@@ -2453,7 +2448,7 @@ export const RemoteInstancesPage: React.FC = () => {
}))
}
>
<SelectTrigger className="h-7 w-fit min-w-[140px]">
<SelectTrigger size={SETTINGS_SELECT_SIZE} className="w-fit min-w-[140px]">
<SelectValue placeholder={t('settings.remoteInstances.page.field.typePlaceholder')} />
</SelectTrigger>
<SelectContent>
@@ -2624,8 +2619,7 @@ export const RemoteInstancesPage: React.FC = () => {
<Icon name="add" className="h-3.5 w-3.5" />
{t('settings.remoteInstances.page.actions.addForward')}
</Button>
</section>
</div>
</SettingsSection>
<div className="mt-8 border-t border-[var(--interactive-border)] pt-3">
<div className="flex items-center gap-2">
@@ -0,0 +1,70 @@
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
interface SettingsInfoHintProps {
children: React.ReactNode;
className?: string;
/** Tooltip panel width cap. @default 'max-w-sm' */
contentClassName?: string;
}
/**
* Info icon revealing helper text on hover or click (click covers touch
* devices, where hover tooltips never open). Clicking outside closes it.
* Settings pages hide secondary descriptions behind this to keep the
* default view quiet.
*/
export const SettingsInfoHint: React.FC<SettingsInfoHintProps> = ({
children,
className,
contentClassName,
}) => {
const { t } = useI18n();
const [open, setOpen] = React.useState(false);
const triggerRef = React.useRef<HTMLButtonElement | null>(null);
React.useEffect(() => {
if (!open) {
return;
}
const handlePointerDown = (event: PointerEvent) => {
const trigger = triggerRef.current;
if (trigger && event.target instanceof Node && trigger.contains(event.target)) {
return;
}
setOpen(false);
};
document.addEventListener('pointerdown', handlePointerDown, true);
return () => document.removeEventListener('pointerdown', handlePointerDown, true);
}, [open]);
return (
<Tooltip open={open} onOpenChange={setOpen}>
<TooltipTrigger asChild>
<button
ref={triggerRef}
type="button"
aria-label={t('settings.common.infoAria')}
aria-expanded={open}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setOpen((current) => !current);
}}
className={cn(
'inline-flex h-5 w-5 shrink-0 cursor-help items-center justify-center rounded text-muted-foreground/60 hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
className,
)}
>
<Icon name="information" className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent sideOffset={8} className={cn('max-w-sm', contentClassName)}>
{children}
</TooltipContent>
</Tooltip>
);
};
@@ -1,10 +1,29 @@
import React from 'react';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Icon } from '@/components/icon/Icon';
import { useI18n } from '@/lib/i18n';
import { getSettingsSaveState, subscribeToSettingsSaveState } from '@/lib/persistence';
import { cn } from '@/lib/utils';
import {
SETTINGS_DESCRIPTION_CLASS,
SETTINGS_PAGE_TITLE_CLASS,
} from '@/components/sections/shared/SettingsSection';
interface SettingsPageLayoutProps {
/** Page content */
children: React.ReactNode;
/** Optional page title shown above settings content. */
title?: React.ReactNode;
/** Optional content rendered before a string/number page title. */
titleLeading?: React.ReactNode;
/** Optional content rendered after a string/number page title. */
titleAccessory?: React.ReactNode;
/** Optional supporting description under the page title. */
description?: React.ReactNode;
/** Optional content rendered at the end of the header row (before save status). */
headerEnd?: React.ReactNode;
/** Show persistence feedback for instant-save settings. */
showSaveStatus?: boolean;
/** Additional className for the content container */
className?: string;
/** Additional className for the outer ScrollableOverlay */
@@ -13,36 +32,120 @@ interface SettingsPageLayoutProps {
/**
* Standard layout wrapper for settings page content.
* Provides scrolling and centered max-width container.
*
* @example
* <SettingsPageLayout>
* <SettingsSection title="General">
* <SomeSettingsForm />
* </SettingsSection>
* <SettingsSection title="Advanced" divider>
* <OtherSettingsForm />
* </SettingsSection>
* </SettingsPageLayout>
* UI Kit: max-width 840px, padding 32px vertical / 48px horizontal.
*/
export const SettingsPageLayout: React.FC<SettingsPageLayoutProps> = ({
children,
className,
outerClassName,
title,
titleLeading,
titleAccessory,
description,
headerEnd,
showSaveStatus = false,
}) => {
const hasHeader = title != null || description != null || headerEnd != null || showSaveStatus;
const isPlainTitle = typeof title === 'string' || typeof title === 'number';
const hasTitleChrome = titleLeading != null || titleAccessory != null;
return (
<ScrollableOverlay
outerClassName={cn('h-full', outerClassName)}
className="w-full"
className="w-full @container"
>
<div
className={cn(
'mx-auto max-w-3xl space-y-6 p-3 sm:p-6 sm:pt-8',
'mx-auto max-w-[840px] space-y-0 px-6 py-6 @3xl:px-12 @3xl:py-8',
// The first visible section never needs the top divider, no matter
// which platform-conditional sections above it rendered null.
'[&>section:first-of-type]:border-t-0 [&>section:first-of-type]:pt-0',
className
)}
>
{hasHeader && (
<div className="mb-2 flex items-start justify-between gap-4 pb-6">
<div className="min-w-0 space-y-1">
{title != null ? (
isPlainTitle ? (
hasTitleChrome ? (
<div className="flex min-w-0 items-center gap-2">
{titleLeading}
<h1 className={cn(SETTINGS_PAGE_TITLE_CLASS, 'min-w-0 truncate')}>{title}</h1>
{titleAccessory}
</div>
) : (
<h1 className={SETTINGS_PAGE_TITLE_CLASS}>{title}</h1>
)
) : (
title
)
) : null}
{description != null ? (
typeof description === 'string' || typeof description === 'number' ? (
<p className={SETTINGS_DESCRIPTION_CLASS}>{description}</p>
) : (
description
)
) : null}
</div>
<div className="flex shrink-0 items-center gap-3">
{headerEnd}
{showSaveStatus && <SettingsSaveStatus />}
</div>
</div>
)}
{children}
</div>
</ScrollableOverlay>
);
};
// Only saves slower than this surface a "Saving…" spinner — local writes
// finish instantly and stay silent; remote/mobile connections get feedback.
const SAVE_SPINNER_DELAY_MS = 500;
const SettingsSaveStatus: React.FC = () => {
const { t } = useI18n();
const status = React.useSyncExternalStore(
subscribeToSettingsSaveState,
getSettingsSaveState,
getSettingsSaveState,
);
const [showSaving, setShowSaving] = React.useState(false);
React.useEffect(() => {
if (status !== 'saving') {
setShowSaving(false);
return;
}
const timer = setTimeout(() => setShowSaving(true), SAVE_SPINNER_DELAY_MS);
return () => clearTimeout(timer);
}, [status]);
if (status === 'error') {
return (
<div
aria-live="assertive"
className="flex shrink-0 items-center gap-1.5 typography-meta text-[var(--status-error)]"
>
<Icon name="error-warning" className="size-3.5" />
<span>{t('settings.common.status.saveFailed')}</span>
</div>
);
}
if (status !== 'saving' || !showSaving) {
return null;
}
return (
<div
aria-live="polite"
className="flex shrink-0 items-center gap-1.5 typography-meta text-muted-foreground"
>
<Icon name="loader-4" className="size-3.5 animate-spin" />
<span>{t('settings.common.actions.saving')}</span>
</div>
);
};
@@ -0,0 +1,558 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Radio } from '@/components/ui/radio';
import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger';
import { cn } from '@/lib/utils';
import { SettingsInfoHint } from './SettingsInfoHint';
/** Settings select trigger: full column width in stacked cells; capped in field rows via parent. */
export const SETTINGS_SELECT_TRIGGER_CLASS = 'w-full min-w-40 max-w-48';
export const SETTINGS_SELECT_SIZE = 'settings' as const;
/** Fixed-width select used inside full-width SettingsFieldRow control columns. */
export const SETTINGS_SELECT_ROW_TRIGGER_CLASS = 'w-full min-w-40 max-w-48';
/** Compact reset / icon action next to a settings control (matches h-8 controls). */
export const SETTINGS_ICON_BUTTON_CLASS =
'h-8 w-8 px-0 text-muted-foreground hover:text-foreground';
/** Custom dropdown triggers (ModelSelector / AgentSelector) in settings field rows. */
// eslint-disable-next-line react-refresh/only-export-components
export const SETTINGS_CUSTOM_TRIGGER_CLASS = cn(
dropdownTriggerVariants(),
'w-full min-w-40 max-w-48',
);
/** Shared width for stacked control clusters (select/input + reset). */
export const SETTINGS_CONTROL_CLUSTER_CLASS = 'w-full max-w-[24rem]';
/** Fill a control cluster the same way as a full-width select. */
export const SETTINGS_CLUSTER_CONTROL_CLASS = 'min-w-0 flex-1';
/**
* Row wrapping a NumberInput + unit label + reset.
* Keep the stepper intrinsic never flex-grow it or +/- buttons stretch unevenly.
*/
export const SETTINGS_NUMBER_STEPPER_ROW_CLASS = 'flex w-full min-w-0 items-center gap-2';
/** Unit suffix next to a settings number stepper (%, px, …). */
export const SETTINGS_NUMBER_UNIT_CLASS =
'typography-meta shrink-0 text-muted-foreground tabular-nums';
/** Vertical stack spacing for fields inside a column. */
export const SETTINGS_FIELDS_STACK_CLASS = 'space-y-4';
/** Compact checkbox / radio list stack. */
export const SETTINGS_OPTION_STACK_CLASS = 'space-y-1.5';
/**
* Settings heading classes by context (size + default color).
* Prefer these over ad-hoc typography-* + color combinations.
*/
/** L1 — page / detail-pane title (larger, quieter than section titles). */
export const SETTINGS_PAGE_TITLE_CLASS =
'typography-settings-page-title text-muted-foreground';
/** L2 — section title inside a settings page. */
export const SETTINGS_SECTION_TITLE_CLASS =
'typography-settings-section-title text-foreground';
/** Split-pane sidebar panel title — same level as section titles. */
export const SETTINGS_PANEL_TITLE_CLASS = SETTINGS_SECTION_TITLE_CLASS;
/** L3 — control-group heading inside a section. */
export const SETTINGS_GROUP_TITLE_CLASS =
'typography-settings-group-title text-foreground';
/** L4 — field / control labels. */
export const SETTINGS_FIELD_LABEL_CLASS =
'typography-settings-field-label text-foreground';
/** Supporting copy under page or section titles. */
export const SETTINGS_DESCRIPTION_CLASS =
'typography-settings-description text-muted-foreground';
/** Supporting copy under group titles and fields. */
export const SETTINGS_HELPER_CLASS = 'typography-meta text-muted-foreground';
/** Callout / alert headline inside a section (not a control-group title). */
export const SETTINGS_CALLOUT_TITLE_CLASS = 'typography-meta font-medium text-foreground';
/** Brand / product name under a logo — quieter than L1 page title. */
export const SETTINGS_BRAND_TITLE_CLASS =
'typography-settings-section-title text-foreground';
interface SettingsSectionProps {
/** Section title. Strings render as the shared h2 style. */
title?: React.ReactNode;
/** Optional supporting text under the title. */
description?: React.ReactNode;
/** Optional icon/badge next to the title. */
titleAccessory?: React.ReactNode;
/** Helper text hidden behind an info icon next to the title. */
info?: React.ReactNode;
/** Optional action aligned to the right of the header. */
headerAction?: React.ReactNode;
children: React.ReactNode;
/**
* Show a top border divider.
* Use `false` for the first section under the page header.
* @default true
*/
divider?: boolean;
className?: string;
contentClassName?: string;
settingsItem?: string;
}
/**
* Shared settings section chrome: single-style header + optional divider.
*/
export const SettingsSection: React.FC<SettingsSectionProps> = ({
title,
description,
titleAccessory,
info,
headerAction,
children,
divider = true,
className,
contentClassName,
settingsItem,
}) => {
const hasHeader = title != null || description != null || headerAction != null || info != null;
return (
<section
data-settings-item={settingsItem}
className={cn(
'space-y-5',
divider ? 'border-t border-border/60 py-8' : 'pb-8',
className,
)}
>
{hasHeader ? (
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
{title != null ? (
<div className="flex items-center gap-2">
{typeof title === 'string' || typeof title === 'number' ? (
<h2 className={SETTINGS_SECTION_TITLE_CLASS}>{title}</h2>
) : (
title
)}
{titleAccessory}
{info != null ? <SettingsInfoHint>{info}</SettingsInfoHint> : null}
</div>
) : null}
{description != null ? (
<div className={SETTINGS_DESCRIPTION_CLASS}>{description}</div>
) : null}
</div>
{headerAction ? <div className="shrink-0">{headerAction}</div> : null}
</div>
) : null}
<div className={cn(contentClassName)}>{children}</div>
</section>
);
};
interface SettingsTwoColumnProps {
children: React.ReactNode;
className?: string;
}
/** Responsive two-column settings grid used when space allows. */
export const SettingsTwoColumn: React.FC<SettingsTwoColumnProps> = ({
children,
className,
}) => {
return (
<div className={cn('grid grid-cols-1 gap-6 @3xl:grid-cols-2 @3xl:gap-10', className)}>
{children}
</div>
);
};
interface SettingsGroupTitleProps {
children: React.ReactNode;
className?: string;
as?: 'h2' | 'h3' | 'div';
}
/** In-section control-group heading (quieter than SettingsSection title). */
export const SettingsGroupTitle: React.FC<SettingsGroupTitleProps> = ({
children,
className,
as: Tag = 'h3',
}) => {
return (
<Tag className={cn(SETTINGS_GROUP_TITLE_CLASS, className)}>
{children}
</Tag>
);
};
interface SettingsControlGroupProps {
title?: React.ReactNode;
description?: React.ReactNode;
/** Helper text hidden behind an info icon next to the group title. */
info?: React.ReactNode;
children: React.ReactNode;
className?: string;
contentClassName?: string;
settingsItem?: string;
}
/** Labeled control cluster inside a section (radios, chips, stacked fields). */
export const SettingsControlGroup: React.FC<SettingsControlGroupProps> = ({
title,
description,
info,
children,
className,
contentClassName,
settingsItem,
}) => {
return (
<div data-settings-item={settingsItem} className={cn('space-y-2', className)}>
{title != null || description != null || info != null ? (
<div className="space-y-0.5">
{title != null ? (
<div className="flex items-center gap-1.5">
<SettingsGroupTitle>{title}</SettingsGroupTitle>
{info != null ? <SettingsInfoHint>{info}</SettingsInfoHint> : null}
</div>
) : null}
{description != null ? (
<p className={SETTINGS_HELPER_CLASS}>{description}</p>
) : null}
</div>
) : null}
<div className={cn(contentClassName)}>{children}</div>
</div>
);
};
interface SettingsStackedFieldProps {
label: React.ReactNode;
description?: React.ReactNode;
/** Helper text hidden behind an info icon next to the label. */
info?: React.ReactNode;
/** Where helper text sits relative to the control. @default 'before' */
descriptionPlacement?: 'before' | 'after';
children: React.ReactNode;
settingsItem?: string;
className?: string;
controlClassName?: string;
}
/**
* Label (+ optional description) above a control for two-column cells.
* Prefer this over SettingsFieldRow inside SettingsTwoColumn (FieldRow overflows half-width columns).
*/
export const SettingsStackedField: React.FC<SettingsStackedFieldProps> = ({
label,
description,
info,
descriptionPlacement = 'before',
children,
settingsItem,
className,
controlClassName,
}) => {
const descriptionNode =
description != null ? (
<p className={SETTINGS_HELPER_CLASS}>{description}</p>
) : null;
return (
<div data-settings-item={settingsItem} className={cn('space-y-2', className)}>
<div className="space-y-0.5">
<div className="flex items-center gap-1.5">
<div className={SETTINGS_FIELD_LABEL_CLASS}>{label}</div>
{info != null ? <SettingsInfoHint>{info}</SettingsInfoHint> : null}
</div>
{descriptionPlacement === 'before' ? descriptionNode : null}
</div>
<div className={cn('flex min-w-0 max-w-[24rem] items-center gap-2', controlClassName)}>{children}</div>
{descriptionPlacement === 'after' ? descriptionNode : null}
</div>
);
};
interface SettingsFieldRowProps {
label: React.ReactNode;
description?: React.ReactNode;
/** Helper text hidden behind an info icon next to the label. */
info?: React.ReactNode;
children: React.ReactNode;
settingsItem?: string;
className?: string;
controlClassName?: string;
/** Align control to the trailing edge on desktop. @default true */
alignEnd?: boolean;
}
/**
* Side-by-side form row: fixed label column + control cluster.
* Use inside full-width sections or single columns.
*/
export const SettingsFieldRow: React.FC<SettingsFieldRowProps> = ({
label,
description,
info,
children,
settingsItem,
className,
controlClassName,
alignEnd = true,
}) => {
return (
<div
data-settings-item={settingsItem}
className={cn(
'flex flex-col gap-2 py-0.5 @xl:flex-row @xl:items-center @xl:gap-8',
className,
)}
>
<div className="min-w-0 @xl:w-56 @xl:shrink-0">
<div className="flex items-center gap-1.5">
<div className={SETTINGS_FIELD_LABEL_CLASS}>{label}</div>
{info != null ? <SettingsInfoHint>{info}</SettingsInfoHint> : null}
</div>
{description != null ? (
<p className={cn(SETTINGS_HELPER_CLASS, 'mt-0.5')}>{description}</p>
) : null}
</div>
<div
className={cn(
'flex min-w-0 flex-1 items-center gap-2 @xl:w-fit @xl:flex-none',
alignEnd && '@xl:justify-end',
controlClassName,
)}
>
{children}
</div>
</div>
);
};
interface SettingsInsetProps {
children: React.ReactNode;
className?: string;
settingsItem?: string;
}
/** Optional inset block under a section (spacing only; section dividers own borders). */
export const SettingsInset: React.FC<SettingsInsetProps> = ({
children,
className,
settingsItem,
}) => {
return (
<div
data-settings-item={settingsItem}
className={cn('pt-4', className)}
>
{children}
</div>
);
};
interface SettingsCheckboxRowProps {
checked: boolean;
onChange: (checked: boolean) => void;
label: React.ReactNode;
description?: React.ReactNode;
disabled?: boolean;
ariaLabel?: string;
settingsItem?: string;
className?: string;
labelAccessory?: React.ReactNode;
/** Helper text hidden behind an info icon next to the label. */
info?: React.ReactNode;
}
/** Shared checkbox setting row with keyboard support. */
export const SettingsCheckboxRow: React.FC<SettingsCheckboxRowProps> = ({
checked,
onChange,
label,
description,
disabled = false,
ariaLabel,
settingsItem,
className,
labelAccessory,
info,
}) => {
const toggle = () => {
if (!disabled) onChange(!checked);
};
const hasDescription = description != null;
return (
<div
data-settings-item={settingsItem}
className={cn(
'group flex cursor-pointer gap-2 py-0.5',
hasDescription ? 'items-start' : 'items-center',
disabled && 'cursor-not-allowed opacity-60',
className,
)}
role="button"
tabIndex={disabled ? -1 : 0}
aria-pressed={checked}
aria-disabled={disabled || undefined}
onClick={toggle}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
toggle();
}
}}
>
<Checkbox
checked={checked}
onChange={onChange}
disabled={disabled}
ariaLabel={ariaLabel}
/>
<div className="flex min-w-0 flex-col">
<div className="flex min-w-0 items-center gap-1.5">
<span className={SETTINGS_FIELD_LABEL_CLASS}>{label}</span>
{labelAccessory}
{info != null ? <SettingsInfoHint>{info}</SettingsInfoHint> : null}
</div>
{hasDescription ? (
<span className={SETTINGS_HELPER_CLASS}>{description}</span>
) : null}
</div>
</div>
);
};
interface SettingsRadioOptionProps {
selected: boolean;
onSelect: () => void;
label: React.ReactNode;
description?: React.ReactNode;
ariaLabel?: string;
disabled?: boolean;
className?: string;
}
/** Single radio option row used inside SettingsRadioGroup. */
export const SettingsRadioOption: React.FC<SettingsRadioOptionProps> = ({
selected,
onSelect,
label,
description,
ariaLabel,
disabled = false,
className,
}) => {
return (
<div
className={cn(
'flex cursor-pointer items-start gap-2 py-0.5',
disabled && 'cursor-not-allowed opacity-60',
className,
)}
role="button"
tabIndex={disabled ? -1 : 0}
aria-pressed={selected}
aria-disabled={disabled || undefined}
onClick={() => {
if (!disabled) onSelect();
}}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
if (!disabled) onSelect();
}
}}
>
<Radio
checked={selected}
onChange={onSelect}
disabled={disabled}
ariaLabel={ariaLabel}
className={description != null ? 'mt-0.5' : undefined}
/>
<div className="flex min-w-0 flex-col">
<span
className={cn(
'typography-settings-field-label font-normal',
selected ? 'text-foreground' : 'text-foreground/50',
)}
>
{label}
</span>
{description != null ? (
<span className={SETTINGS_HELPER_CLASS}>{description}</span>
) : null}
</div>
</div>
);
};
interface SettingsRadioGroupProps {
'aria-label': string;
children: React.ReactNode;
className?: string;
}
/** Accessible radio group wrapper with compact vertical spacing. */
export const SettingsRadioGroup: React.FC<SettingsRadioGroupProps> = ({
'aria-label': ariaLabel,
children,
className,
}) => {
return (
<div role="radiogroup" aria-label={ariaLabel} className={cn(SETTINGS_OPTION_STACK_CLASS, className)}>
{children}
</div>
);
};
interface SettingsChipOption<T extends string> {
value: T;
label: React.ReactNode;
disabled?: boolean;
}
interface SettingsChipGroupProps<T extends string> {
value: T;
options: Array<SettingsChipOption<T>>;
onChange: (value: T) => void;
className?: string;
'aria-label'?: string;
}
/** Compact chip / segmented enum picker. */
export function SettingsChipGroup<T extends string>({
value,
options,
onChange,
className,
'aria-label': ariaLabel,
}: SettingsChipGroupProps<T>) {
return (
<div
role="group"
aria-label={ariaLabel}
className={cn('flex flex-wrap items-center gap-1', className)}
>
{options.map((option) => (
<Button
key={option.value}
type="button"
variant="chip"
size="xs"
disabled={option.disabled}
aria-pressed={value === option.value}
className="!font-normal"
onClick={() => onChange(option.value)}
>
{option.label}
</Button>
))}
</div>
);
}
@@ -8,6 +8,15 @@ import { toast } from '@/components/ui';
import { useSkillsStore, type SkillConfig, type SkillScope, type SupportingFile, type PendingFile } from '@/stores/useSkillsStore';
import { useShallow } from 'zustand/react/shallow';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
import {
SettingsSection,
SettingsFieldRow,
SettingsStackedField,
SETTINGS_FIELD_LABEL_CLASS,
SETTINGS_SELECT_SIZE,
} from '@/components/sections/shared/SettingsSection';
import { SettingsInfoHint } from '@/components/sections/shared/SettingsInfoHint';
import {
Select,
SelectContent,
@@ -504,113 +513,103 @@ const SkillsInstalledPage: React.FC = () => {
}
return (
<ScrollableOverlay outerClassName="h-full" className="w-full">
<div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8">
<>
<SettingsPageLayout
title={isNewSkill ? t('settings.skills.page.title.newSkill') : selectedSkillName}
description={selectedSkill
? t('settings.skills.page.subtitle.skillLocation', {
location: locationLabelText(locationValueFrom(selectedSkill.scope, selectedSkill.source)),
})
: t('settings.skills.page.subtitle.newSkill')}
showSaveStatus={false}
>
{/* Header */}
<div className="mb-4">
<div className="min-w-0">
<h2 className="typography-ui-header font-semibold text-foreground truncate flex items-center gap-2">
{isNewSkill ? t('settings.skills.page.title.newSkill') : selectedSkillName}
</h2>
<p className="typography-meta text-muted-foreground truncate">
{selectedSkill
? t('settings.skills.page.subtitle.skillLocation', {
location: locationLabelText(locationValueFrom(selectedSkill.scope, selectedSkill.source)),
})
: t('settings.skills.page.subtitle.newSkill')}
</p>
</div>
</div>
{/* Basic Information */}
<div data-settings-item="skills.basic-information" className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">
{t('settings.skills.page.section.basicInformation')}
</h3>
</div>
<section className="px-2 pb-2 pt-0 space-y-0">
<SettingsSection
title={t('settings.skills.page.section.basicInformation')}
divider={false}
settingsItem="skills.basic-information"
contentClassName="space-y-0"
>
{isNewSkill && (
<div className="py-1.5">
<span className="typography-ui-label text-foreground">{t('settings.skills.page.field.skillNameLocation')}</span>
<span className="typography-meta text-muted-foreground ml-2">{t('settings.skills.page.field.skillNameHint')}</span>
<div className="flex items-center gap-2 mt-1.5">
<Input
value={draftName}
onChange={(e) => setDraftName(e.target.value.toLowerCase().replace(/\s+/g, '-'))}
placeholder={t('settings.skills.page.field.skillNamePlaceholder')}
className="h-7 w-40 px-2"
/>
<Select
value={locationValueFrom(draftScope, draftSource)}
onValueChange={(v) => {
const next = locationPartsFrom(v as SkillLocationValue);
setDraftScope(next.scope);
setDraftSource(next.source === 'agents' ? 'agents' : 'opencode');
}}
>
<SelectTrigger className="w-fit gap-1.5">
{draftScope === 'user' ? (
<Icon name="user-3" className="h-3.5 w-3.5" />
) : (
<Icon name="folder" className="h-3.5 w-3.5" />
)}
{draftSource === 'agents' ? <Icon name="robot-2" className="h-3.5 w-3.5" /> : null}
<span>{locationLabelText(locationValueFrom(draftScope, draftSource))}</span>
</SelectTrigger>
<SelectContent align="start">
{SKILL_LOCATION_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2">
{option.scope === 'user' ? <Icon name="user-3" className="h-3.5 w-3.5" /> : <Icon name="folder" className="h-3.5 w-3.5" />}
{option.source === 'agents' ? <Icon name="robot-2" className="h-3.5 w-3.5" /> : null}
<span>{locationLabelText(option.value)}</span>
</div>
<span className="typography-micro text-muted-foreground ml-6">{locationDescriptionText(option.value)}</span>
<SettingsFieldRow
label={t('settings.skills.page.field.skillNameLocation')}
info={t('settings.skills.page.field.skillNameHint')}
>
<Input
value={draftName}
onChange={(e) => setDraftName(e.target.value.toLowerCase().replace(/\s+/g, '-'))}
placeholder={t('settings.skills.page.field.skillNamePlaceholder')}
className="h-7 w-40 px-2"
/>
<Select
value={locationValueFrom(draftScope, draftSource)}
onValueChange={(v) => {
const next = locationPartsFrom(v as SkillLocationValue);
setDraftScope(next.scope);
setDraftSource(next.source === 'agents' ? 'agents' : 'opencode');
}}
>
<SelectTrigger size={SETTINGS_SELECT_SIZE} className="w-fit gap-1.5">
{draftScope === 'user' ? (
<Icon name="user-3" className="h-3.5 w-3.5" />
) : (
<Icon name="folder" className="h-3.5 w-3.5" />
)}
{draftSource === 'agents' ? <Icon name="robot-2" className="h-3.5 w-3.5" /> : null}
<span>{locationLabelText(locationValueFrom(draftScope, draftSource))}</span>
</SelectTrigger>
<SelectContent align="start">
{SKILL_LOCATION_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2">
{option.scope === 'user' ? <Icon name="user-3" className="h-3.5 w-3.5" /> : <Icon name="folder" className="h-3.5 w-3.5" />}
{option.source === 'agents' ? <Icon name="robot-2" className="h-3.5 w-3.5" /> : null}
<span>{locationLabelText(option.value)}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<span className="typography-micro text-muted-foreground ml-6">{locationDescriptionText(option.value)}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</SettingsFieldRow>
)}
<div className="py-1.5">
<span className="typography-ui-label text-foreground">{t('settings.common.field.description')} <span className="text-[var(--status-error)]">*</span></span>
<span className="typography-meta text-muted-foreground ml-2">{t('settings.skills.page.field.descriptionHint')}</span>
<div className="mt-1.5">
<Textarea
value={description}
onChange={(e) => handleDescriptionChange(e.target.value)}
placeholder={t('settings.skills.page.field.descriptionPlaceholder')}
rows={2}
className="w-full resize-none min-h-[60px] max-h-32 bg-transparent"
disabled={isReadOnlySkill}
/>
</div>
</div>
<SettingsStackedField
label={(
<>
{t('settings.common.field.description')} <span className="text-[var(--status-error)]">*</span>
</>
)}
info={t('settings.skills.page.field.descriptionHint')}
controlClassName="w-full max-w-none"
>
<Textarea
value={description}
onChange={(e) => handleDescriptionChange(e.target.value)}
placeholder={t('settings.skills.page.field.descriptionPlaceholder')}
rows={2}
className="w-full resize-none min-h-[60px] max-h-32 bg-transparent"
disabled={isReadOnlySkill}
/>
</SettingsStackedField>
</section>
</div>
</SettingsSection>
{/* Instructions */}
<div data-settings-item="skills.instructions" className="mb-8">
<div className="mb-1 px-1 flex items-center justify-between gap-2">
<h3 className="typography-ui-header font-medium text-foreground">
{t('settings.skills.page.section.instructions')}
</h3>
<SettingsSection
title={t('settings.skills.page.section.instructions')}
headerAction={(
<PreviewToggleButton
currentMode={skillEditorMode === 'preview' ? 'preview' : 'edit'}
onToggle={() => setSkillEditorMode((mode) => mode === 'preview' ? 'edit' : 'preview')}
/>
</div>
<section className="px-2 pb-2 pt-0">
)}
settingsItem="skills.instructions"
>
<div
className={cn(
'overflow-hidden rounded-md border border-[var(--surface-subtle)] bg-background',
@@ -639,21 +638,17 @@ const SkillsInstalledPage: React.FC = () => {
/>
)}
</div>
</section>
</div>
</SettingsSection>
{/* Supporting Files */}
<div data-settings-item="skills.supporting-files" className="mb-2">
<div className="mb-1 px-1 flex items-center gap-2">
<h3 className="typography-ui-header font-medium text-foreground">
{t('settings.skills.page.section.supportingFiles')}
</h3>
<SettingsSection
title={t('settings.skills.page.section.supportingFiles')}
headerAction={(
<Button variant="outline" size="xs" className="!font-normal gap-1" onClick={handleAddFile} disabled={isReadOnlySkill}>
<Icon name="add" className="h-3.5 w-3.5" /> {t('settings.skills.page.actions.addFile')}
</Button>
</div>
<section className="px-2 pb-2 pt-0">
)}
settingsItem="skills.supporting-files"
>
{(() => {
const filesToShow = isNewSkill ? pendingFiles : supportingFiles;
@@ -697,11 +692,9 @@ const SkillsInstalledPage: React.FC = () => {
</div>
);
})()}
</section>
</div>
</SettingsSection>
{/* Save action */}
<div className="px-2 py-1">
<SettingsSection>
<Button
onClick={handleSave}
disabled={isReadOnlySkill || isSaving || !hasSkillChanges}
@@ -710,9 +703,9 @@ const SkillsInstalledPage: React.FC = () => {
>
{isSaving ? t('settings.common.actions.saving') : isNewSkill ? t('settings.skills.page.actions.createSkill') : t('settings.common.actions.saveChanges')}
</Button>
</div>
</SettingsSection>
</SettingsPageLayout>
</div>
{/* Add/Edit File Dialog */}
<Dialog
@@ -764,9 +757,14 @@ const SkillsInstalledPage: React.FC = () => {
) : (
<div className="space-y-4 flex-1 min-h-0 flex flex-col pt-2">
<div className="space-y-2 flex-shrink-0">
<label className="typography-ui-label font-medium text-foreground">
{t('settings.skills.page.fileDialog.field.filePath')}
</label>
<div className="flex items-center gap-1">
<label className={SETTINGS_FIELD_LABEL_CLASS}>
{t('settings.skills.page.fileDialog.field.filePath')}
</label>
{!editingFilePath && (
<SettingsInfoHint>{t('settings.skills.page.fileDialog.field.filePathHint')}</SettingsInfoHint>
)}
</div>
<Input
value={newFileName}
onChange={(e) => setNewFileName(e.target.value)}
@@ -774,14 +772,9 @@ const SkillsInstalledPage: React.FC = () => {
className="text-foreground placeholder:text-muted-foreground focus-visible:ring-[var(--primary-base)]"
disabled={editingFilePath !== null}
/>
{!editingFilePath && (
<p className="typography-micro text-muted-foreground">
{t('settings.skills.page.fileDialog.field.filePathHint')}
</p>
)}
</div>
<div className="space-y-2 flex-1 min-h-0 flex flex-col">
<label className="typography-ui-label font-medium text-foreground flex-shrink-0">
<label className={`${SETTINGS_FIELD_LABEL_CLASS} flex-shrink-0`}>
{t('settings.skills.page.fileDialog.field.content')}
</label>
<div className="h-[45vh] min-h-[250px] max-h-[55vh] overflow-hidden rounded-md border border-[var(--surface-subtle)] bg-background">
@@ -813,7 +806,8 @@ const SkillsInstalledPage: React.FC = () => {
</DialogFooter>
</DialogContent>
</Dialog>
</ScrollableOverlay>
</>
);
};
@@ -26,6 +26,7 @@ import { SettingsProjectSelector } from '@/components/sections/shared/SettingsPr
import { SidebarGroup } from '@/components/sections/shared/SidebarGroup';
import { Icon } from "@/components/icon/Icon";
import { useI18n } from '@/lib/i18n';
import { SETTINGS_PANEL_TITLE_CLASS } from '@/components/sections/shared/SettingsSection';
interface SkillsSidebarProps {
onItemSelect?: () => void;
@@ -232,7 +233,7 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
return (
<div className={cn('flex h-full flex-col', bgClass)}>
<div className="border-b px-3 pt-4 pb-3">
<h2 className="text-base font-semibold text-foreground mb-3">{t('settings.skills.sidebar.title')}</h2>
<h2 className={`${SETTINGS_PANEL_TITLE_CLASS} mb-3`}>{t('settings.skills.sidebar.title')}</h2>
<SettingsProjectSelector className="mb-3" />
<div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground">{t('settings.skills.sidebar.total', { count: skills.length })}</span>
@@ -19,6 +19,7 @@ import {
SelectTrigger,
} from '@/components/ui/select';
import { Icon } from "@/components/icon/Icon";
import { SettingsInfoHint } from '@/components/sections/shared/SettingsInfoHint';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { isVSCodeRuntime } from '@/lib/desktop';
@@ -278,7 +279,10 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
</div>
<div className="space-y-2">
<label className="typography-ui-label text-foreground">{t('settings.skills.catalog.add.field.repository')}</label>
<div className="flex items-center gap-1">
<label className="typography-ui-label text-foreground">{t('settings.skills.catalog.add.field.repository')}</label>
<SettingsInfoHint>{t('settings.skills.catalog.add.field.repositoryHint')}</SettingsInfoHint>
</div>
<Input
value={source}
onChange={(e) => {
@@ -287,9 +291,6 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
}}
placeholder={t('settings.skills.catalog.shared.field.repositoryPlaceholder')}
/>
<p className="typography-micro text-muted-foreground">
{t('settings.skills.catalog.add.field.repositoryHint')}
</p>
</div>
<div className="space-y-2">
@@ -306,9 +307,10 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
{identityOptions.length > 0 && !isVSCodeRuntime() ? (
<div className="space-y-2">
<div>
<div className="flex items-center">
<span className="typography-ui-label text-[var(--status-warning)]">{t('settings.skills.catalog.shared.auth.title')}</span>
<span className="typography-meta text-muted-foreground ml-2">{t('settings.skills.catalog.shared.auth.description')}</span>
<SettingsInfoHint className="ml-1">{t('settings.skills.catalog.shared.auth.footerHint')}</SettingsInfoHint>
</div>
<Select
value={gitIdentityId || ''}
@@ -328,9 +330,6 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
))}
</SelectContent>
</Select>
<p className="typography-micro text-muted-foreground">
{t('settings.skills.catalog.shared.auth.footerHint')}
</p>
</div>
) : null}
@@ -3,7 +3,12 @@ import { runtimeFetch } from '@/lib/runtime-fetch';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
import {
SettingsSection,
SETTINGS_SELECT_SIZE,
SETTINGS_SELECT_TRIGGER_CLASS,
} from '@/components/sections/shared/SettingsSection';
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
import {
Dialog,
@@ -161,12 +166,12 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
};
return (
<ScrollableOverlay outerClassName="h-full" className="w-full">
<div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8">
{/* Header */}
<div className="mb-4">
{showModeTabs && (
<>
<SettingsPageLayout
title={t('settings.skills.catalog.page.title')}
showSaveStatus={false}
>
{showModeTabs && (
<div className="mb-4">
<div className="h-10">
<SortableTabsStrip
@@ -184,22 +189,22 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
</div>
</div>
)}
<h2 className="typography-ui-header font-semibold text-foreground px-1">{t('settings.skills.catalog.page.title')}</h2>
</div>
{/* Source & Search */}
<div data-settings-item="skills.catalog.source" className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.skills.catalog.page.section.sourceRepository')}</h3>
</div>
<section className="px-2 pb-2 pt-0 space-y-0">
<SettingsSection
title={t('settings.skills.catalog.page.section.sourceRepository')}
divider={false}
settingsItem="skills.catalog.source"
contentClassName="space-y-0"
>
<div className="flex flex-wrap items-center gap-2 py-1.5">
<Select
value={selectedSourceId || ''}
onValueChange={(v) => setSelectedSource(v)}
>
<SelectTrigger className="w-fit">
<SelectTrigger size={SETTINGS_SELECT_SIZE} className={cn(SETTINGS_SELECT_TRIGGER_CLASS, 'w-fit')}>
<SelectValue placeholder={t('settings.skills.catalog.page.field.selectSourcePlaceholder')}>
{selectedSource?.label}
</SelectValue>
@@ -269,20 +274,18 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
: t('settings.skills.catalog.page.foundCount', { count: filtered.length })}
</span>
</div>
</section>
</div>
</SettingsSection>
{/* Error State */}
{lastCatalogError && (
<div className="mb-8 rounded-lg border border-[var(--status-error-border)] bg-[var(--status-error-background)] px-4 py-3">
<div className="typography-ui-label font-medium text-[var(--status-error)]">{t('settings.skills.catalog.page.error.catalogTitle')}</div>
<div className="typography-meta text-[var(--status-error)]/80 mt-1">{lastCatalogError.message}</div>
</div>
<SettingsSection>
<div className="rounded-lg border border-[var(--status-error-border)] bg-[var(--status-error-background)] px-4 py-3">
<div className="typography-ui-label font-medium text-[var(--status-error)]">{t('settings.skills.catalog.page.error.catalogTitle')}</div>
<div className="typography-meta text-[var(--status-error)]/80 mt-1">{lastCatalogError.message}</div>
</div>
</SettingsSection>
)}
{/* Skills List */}
<div className="mb-8">
<section className="px-2 pb-2 pt-0">
<SettingsSection>
{filtered.length === 0 && !isLoadingSource ? (
<div className="py-8 text-center text-muted-foreground">
<p className="typography-body">{t('settings.skills.catalog.page.empty.noSkillsTitle')}</p>
@@ -367,10 +370,8 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
})}
</div>
)}
</section>
{isClawdHubSource && hasMoreClawdHub && !isLoadingSource && filtered.length > 0 && (
<div className="flex justify-center mt-2 px-2">
<div className="flex justify-center mt-2">
<Button
variant="outline"
size="xs"
@@ -382,7 +383,8 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
</Button>
</div>
)}
</div>
</SettingsSection>
</SettingsPageLayout>
{/* Dialogs */}
<AddCatalogDialog open={addCatalogOpen} onOpenChange={setAddCatalogOpen} />
@@ -417,7 +419,6 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
</DialogContent>
</Dialog>
</div>
</ScrollableOverlay>
</>
);
};
@@ -5,10 +5,17 @@ import { Textarea } from '@/components/ui/textarea';
import { toast } from '@/components/ui';
import { useSnippetsStore, type SnippetScope } from '@/stores/useSnippetsStore';
import { useShallow } from 'zustand/react/shallow';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Icon } from '@/components/icon/Icon';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useI18n } from '@/lib/i18n';
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
import {
SettingsSection,
SettingsFieldRow,
SettingsStackedField,
SETTINGS_SELECT_SIZE,
} from '@/components/sections/shared/SettingsSection';
import { SettingsInfoHint } from '@/components/sections/shared/SettingsInfoHint';
export const SnippetsPage: React.FC = () => {
const { t } = useI18n();
@@ -117,53 +124,61 @@ export const SnippetsPage: React.FC = () => {
}
return (
<ScrollableOverlay outerClassName="h-full" className="w-full">
<div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8">
<div className="mb-4 min-w-0">
<h2 className="typography-ui-header font-semibold text-foreground truncate">
{isNew ? t('settings.snippets.page.title.new') : `#${selectedSnippetName}`}
</h2>
{selectedSnippet ? <p className="typography-meta text-muted-foreground truncate">{selectedSnippet.filePath}</p> : null}
</div>
<SettingsPageLayout
title={isNew ? t('settings.snippets.page.title.new') : `#${selectedSnippetName}`}
description={selectedSnippet ? selectedSnippet.filePath : undefined}
showSaveStatus={false}
>
<SettingsSection divider={false} contentClassName="space-y-0">
{isNew ? (
<SettingsFieldRow label="#">
<Input
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
placeholder={t('settings.snippets.page.field.namePlaceholder')}
className="h-7 w-44 px-2"
/>
<Select value={draftScope} onValueChange={(value) => setDraftScope(value as SnippetScope)}>
<SelectTrigger size={SETTINGS_SELECT_SIZE} className="w-fit min-w-[100px]">
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="global">{t('settings.common.scope.global')}</SelectItem>
<SelectItem value="project">{t('settings.common.scope.project')}</SelectItem>
</SelectContent>
</Select>
</SettingsFieldRow>
) : null}
<SettingsStackedField label={t('settings.common.field.description')}>
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t('settings.snippets.page.field.descriptionPlaceholder')}
className="h-7 w-full max-w-sm px-2"
/>
</SettingsStackedField>
<SettingsStackedField label={t('settings.snippets.page.field.aliases')}>
<Input
value={aliases}
onChange={(e) => setAliases(e.target.value)}
placeholder={t('settings.snippets.page.field.aliasesPlaceholder')}
className="h-7 w-full max-w-sm px-2"
/>
</SettingsStackedField>
</SettingsSection>
<div className="mb-8 space-y-3 px-2">
<div>
{isNew ? (
<div className="mb-3 flex items-center gap-2">
<span className="typography-ui-label text-foreground">#</span>
<Input value={draftName} onChange={(e) => setDraftName(e.target.value)} placeholder={t('settings.snippets.page.field.namePlaceholder')} className="h-7 w-44 px-2" />
<Select value={draftScope} onValueChange={(value) => setDraftScope(value as SnippetScope)}>
<SelectTrigger className="w-fit min-w-[100px]">
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="global">{t('settings.common.scope.global')}</SelectItem>
<SelectItem value="project">{t('settings.common.scope.project')}</SelectItem>
</SelectContent>
</Select>
</div>
) : null}
<span className="typography-ui-label text-foreground">{t('settings.common.field.description')}</span>
<Input value={description} onChange={(e) => setDescription(e.target.value)} placeholder={t('settings.snippets.page.field.descriptionPlaceholder')} className="mt-1.5 h-7 w-full max-w-sm px-2" />
</div>
<div>
<span className="typography-ui-label text-foreground">{t('settings.snippets.page.field.aliases')}</span>
<Input value={aliases} onChange={(e) => setAliases(e.target.value)} placeholder={t('settings.snippets.page.field.aliasesPlaceholder')} className="mt-1.5 h-7 w-full max-w-sm px-2" />
</div>
</div>
<div data-settings-item="snippets.content" className="mb-2 px-2">
<SettingsSection settingsItem="snippets.content">
<div className="flex items-center gap-1">
<span className="typography-ui-label text-foreground">{t('settings.snippets.page.field.content')}</span>
<Textarea value={content} onChange={(e) => setContent(e.target.value)} placeholder={t('settings.snippets.page.field.contentPlaceholder')} rows={12} className="mt-1.5 w-full font-mono typography-meta min-h-[160px] max-h-[60vh] bg-transparent" />
<p className="mt-2 typography-meta text-muted-foreground">{t('settings.snippets.page.hint')}</p>
<SettingsInfoHint>{t('settings.snippets.page.hint')}</SettingsInfoHint>
</div>
<div className="px-2 py-1">
<Textarea value={content} onChange={(e) => setContent(e.target.value)} placeholder={t('settings.snippets.page.field.contentPlaceholder')} rows={12} className="mt-1.5 w-full font-mono typography-meta min-h-[160px] max-h-[60vh] bg-transparent" />
<div className="pt-3">
<Button onClick={handleSave} disabled={isSaving || !isDirty} size="xs" className="!font-normal">
{isSaving ? t('settings.common.actions.saving') : t('settings.common.actions.saveChanges')}
</Button>
</div>
</div>
</ScrollableOverlay>
</SettingsSection>
</SettingsPageLayout>
);
};
@@ -11,6 +11,7 @@ import { cn } from '@/lib/utils';
import type { Snippet } from '@/types/snippet';
import { Icon } from '@/components/icon/Icon';
import { useI18n } from '@/lib/i18n';
import { SETTINGS_PANEL_TITLE_CLASS } from '@/components/sections/shared/SettingsSection';
interface SnippetsSidebarProps {
onItemSelect?: () => void;
@@ -62,7 +63,7 @@ export const SnippetsSidebar: React.FC<SnippetsSidebarProps> = ({ onItemSelect }
return (
<div className={cn('flex h-full flex-col', 'bg-background')}>
<div className="border-b px-3 pt-4 pb-3">
<h2 className="text-base font-semibold text-foreground mb-3">{t('settings.snippets.sidebar.title')}</h2>
<h2 className={`${SETTINGS_PANEL_TITLE_CLASS} mb-3`}>{t('settings.snippets.sidebar.title')}</h2>
<div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground">{t('settings.snippets.sidebar.total', { count: snippets.length })}</span>
<Button size="sm" data-settings-item="snippets.create" variant="ghost" className="h-7 w-7 px-0 -my-1 text-muted-foreground" onClick={handleCreateNew} aria-label={t('settings.snippets.sidebar.actions.create')}>
@@ -32,7 +32,7 @@ export const QuotaCredentials: React.FC<{ providerId: ProviderId; providerName:
finally { setBusy(false); }
};
const field = (name: string, label: string, placeholder: string) => <label className="block typography-ui-label text-foreground">{label}<Input className="mt-1 h-7 font-mono text-xs" type={name === 'workspaceId' ? 'text' : 'password'} autoComplete="off" value={values[name] ?? ''} onChange={(event) => setValues((current) => ({ ...current, [name]: event.target.value }))} placeholder={status?.secretMasked ?? placeholder} /></label>;
return <div data-settings-item={`providers.${providerId}-credentials`} className="mb-8">
return <div data-settings-item={`usage.${providerId}-credentials`} className="mb-8">
<div className="mb-1 px-1"><h3 className="typography-ui-header font-medium text-foreground">{providerName}</h3></div>
<section className="space-y-3 px-2 pb-2 pt-0">
{providerId === 'opencode-go' && field('workspaceId', t('settings.providers.page.openCodeGo.workspaceId'), 'wrk_...')}
@@ -1,7 +1,6 @@
import React from 'react';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Checkbox } from '@/components/ui/checkbox';
import { UsageCard } from './UsageCard';
import { QuotaCredentials } from './QuotaCredentials';
import { QUOTA_PROVIDERS } from '@/lib/quota';
import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
import { updateDesktopSettings } from '@/lib/persistence';
@@ -13,11 +12,15 @@ import {
} from '@/components/ui/collapsible';
import type { UsageWindows, QuotaProviderId } from '@/types';
import { getAllModelFamilies, getDisplayModelName, sortModelFamilies, groupModelsByFamilyWithGetter } from '@/lib/quota/model-families';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Icon } from "@/components/icon/Icon";
import { useI18n } from '@/lib/i18n';
import { formatTimeForPreference } from '@/lib/timeFormat';
import { useUIStore, type TimeFormatPreference } from '@/stores/useUIStore';
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
import {
SettingsSection,
SettingsCheckboxRow,
} from '@/components/sections/shared/SettingsSection';
const formatTime = (timestamp: number | null, timeFormatPreference: TimeFormatPreference) => {
if (!timestamp) return '-';
@@ -74,6 +77,7 @@ export const UsagePage: React.FC = () => {
const providerName = providerMeta?.name ?? selectedProviderId ?? t('settings.usage.sidebar.title');
const usage = selectedResult?.usage;
const showInDropdown = selectedProviderId ? dropdownProviderIds.includes(selectedProviderId) : false;
const hasCredentialsForm = selectedProviderId === 'opencode-go' || selectedProviderId === 'ollama-cloud' || selectedProviderId === 'cursor';
const handleDropdownToggle = React.useCallback((enabled: boolean) => {
if (!selectedProviderId) {
return;
@@ -149,222 +153,181 @@ export const UsagePage: React.FC = () => {
}
return (
<ScrollableOverlay outerClassName="h-full" className="w-full">
<div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8">
<SettingsPageLayout
title={t('settings.usage.page.header.providerUsage', { provider: providerName })}
titleLeading={<ProviderLogo providerId={selectedProviderId} className="h-5 w-5 shrink-0" />}
description={
isLoading ? (
<span className="animate-pulse typography-settings-description text-muted-foreground">{t('settings.usage.page.header.refreshing')}</span>
) : (
t('settings.usage.page.header.lastUpdated', { time: formatTime(lastUpdated, timeFormatPreference) })
)
}
showSaveStatus
>
<SettingsSection divider={false} settingsItem="usage.header-menu">
<SettingsCheckboxRow
checked={showInDropdown}
onChange={handleDropdownToggle}
label={t('settings.usage.page.options.showInHeader')}
ariaLabel={t('settings.usage.page.options.showInHeaderAria')}
info={t('settings.usage.page.options.showInHeaderTooltip')}
/>
</SettingsSection>
{/* Header */}
<div className="mb-4 flex items-center gap-3">
<ProviderLogo providerId={selectedProviderId} className="h-5 w-5 shrink-0" />
<div className="min-w-0">
<h2 className="typography-ui-header font-semibold text-foreground truncate">
{t('settings.usage.page.header.providerUsage', { provider: providerName })}
</h2>
<p className="typography-meta text-muted-foreground truncate">
{isLoading ? (
<span className="animate-pulse">{t('settings.usage.page.header.refreshing')}</span>
) : (
t('settings.usage.page.header.lastUpdated', { time: formatTime(lastUpdated, timeFormatPreference) })
)}
</p>
</div>
{!selectedResult && (
<p className="typography-ui-label text-foreground pb-8">{t('settings.usage.page.state.noData')}</p>
)}
{error && (
<div className="mb-8 rounded-lg border border-[var(--status-error-border)] bg-[var(--status-error-background)] px-4 py-3">
<p className="typography-ui-label font-medium text-[var(--status-error)]">{t('settings.usage.page.state.refreshFailedTitle')}</p>
<p className="typography-meta text-[var(--status-error)]/80 mt-1">{error}</p>
</div>
)}
{/* Options */}
<div data-settings-item="usage.header-menu" className="mb-8 px-2">
<div
className="group flex cursor-pointer items-center gap-2 py-1.5"
role="button"
tabIndex={0}
aria-pressed={showInDropdown}
onClick={() => handleDropdownToggle(!showInDropdown)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
handleDropdownToggle(!showInDropdown);
}
}}
>
<Checkbox
checked={showInDropdown}
onChange={handleDropdownToggle}
ariaLabel={t('settings.usage.page.options.showInHeaderAria')}
/>
<div className="flex min-w-0 items-center gap-1.5">
<span className="typography-ui-label text-foreground">{t('settings.usage.page.options.showInHeader')}</span>
<Tooltip>
<TooltipTrigger asChild>
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
{t('settings.usage.page.options.showInHeaderTooltip')}
</TooltipContent>
</Tooltip>
</div>
</div>
{/* Providers with an inline credentials form don't need the "go to Providers" banner — the form IS the fix. */}
{selectedResult && !selectedResult.configured && !hasCredentialsForm && (
<div className="mb-8 rounded-lg border border-[var(--status-warning-border)] bg-[var(--status-warning-background)] px-4 py-3">
<p className="typography-ui-label font-medium text-[var(--status-warning)]">{t('settings.usage.page.state.providerNotConfiguredTitle')}</p>
<p className="typography-meta text-[var(--status-warning)]/80 mt-1">
{t('settings.usage.page.state.providerNotConfiguredDescription')}
</p>
</div>
)}
{/* State Messages */}
{!selectedResult && (
<div className="mb-8 px-2">
<p className="typography-ui-label text-foreground">{t('settings.usage.page.state.noData')}</p>
{(selectedProviderId === 'opencode-go' || selectedProviderId === 'ollama-cloud' || selectedProviderId === 'cursor') && (
<QuotaCredentials providerId={selectedProviderId} providerName={providerName} />
)}
{usage?.windows && Object.keys(usage.windows).length > 0 && (
<SettingsSection settingsItem="usage.model-quotas">
<div className="divide-y divide-[var(--surface-subtle)]">
{Object.entries(usage.windows).map(([label, window]) => (
<UsageCard key={label} title={label} window={window} />
))}
</div>
)}
</SettingsSection>
)}
{error && (
<div className="mb-8 rounded-lg border border-[var(--status-error-border)] bg-[var(--status-error-background)] px-4 py-3">
<p className="typography-ui-label font-medium text-[var(--status-error)]">{t('settings.usage.page.state.refreshFailedTitle')}</p>
<p className="typography-meta text-[var(--status-error)]/80 mt-1">{error}</p>
</div>
)}
{providerModels.length > 0 && (
<SettingsSection
title={t('settings.usage.page.section.modelQuotas')}
contentClassName="space-y-3"
>
{sortedFamilies.map((family) => {
const familyModels = modelsByFamily.get(family.id) ?? [];
if (familyModels.length === 0) return null;
{selectedResult && !selectedResult.configured && (
<div className="mb-8 rounded-lg border border-[var(--status-warning-border)] bg-[var(--status-warning-background)] px-4 py-3">
<p className="typography-ui-label font-medium text-[var(--status-warning)]">{t('settings.usage.page.state.providerNotConfiguredTitle')}</p>
<p className="typography-meta text-[var(--status-warning)]/80 mt-1">
{t('settings.usage.page.state.providerNotConfiguredDescription')}
</p>
</div>
)}
const isCollapsed = collapsedFamilies[family.id] ?? false;
{/* Overall Usage Windows */}
{usage?.windows && Object.keys(usage.windows).length > 0 && (
<div data-settings-item="usage.model-quotas" className="mb-8">
<section className="px-2 pb-2 pt-0">
<div className="divide-y divide-[var(--surface-subtle)]">
{Object.entries(usage.windows).map(([label, window]) => (
<UsageCard key={label} title={label} window={window} />
))}
</div>
</section>
</div>
)}
return (
<section key={family.id} className="p-2">
<Collapsible
open={!isCollapsed}
onOpenChange={() => toggleFamilyCollapsed(family.id)}
>
<CollapsibleTrigger className="flex w-full items-center justify-between py-0.5 group">
<div className="flex items-center gap-1.5 text-left">
<span className="typography-ui-label font-normal text-foreground">{family.label}</span>
<span className="typography-micro text-muted-foreground">
({familyModels.length})
</span>
</div>
{isCollapsed ? (
<Icon name="arrow-right-s" className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors" />
) : (
<Icon name="arrow-down-s" className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors" />
)}
</CollapsibleTrigger>
<CollapsibleContent>
<div className="divide-y divide-[var(--surface-subtle)] mt-1">
{familyModels.map((model) => {
const entries = Object.entries(model.windows.windows);
if (entries.length === 0) return null;
const [label, window] = entries[0];
const isSelected = providerSelectedModels.includes(model.name);
{/* Models Section */}
{providerModels.length > 0 && (
<div className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.usage.page.section.modelQuotas')}</h3>
</div>
return (
<UsageCard
key={model.name}
title={label}
subtitle={getDisplayModelName(model.name)}
window={window}
showToggle
toggleEnabled={isSelected}
onToggle={() => handleModelToggle(model.name)}
/>
);
})}
</div>
</CollapsibleContent>
</Collapsible>
</section>
);
})}
<div className="space-y-3">
{/* Predefined families */}
{sortedFamilies.map((family) => {
const familyModels = modelsByFamily.get(family.id) ?? [];
if (familyModels.length === 0) return null;
{(() => {
const otherModels = modelsByFamily.get(null) ?? [];
if (otherModels.length === 0) return null;
const isCollapsed = collapsedFamilies[family.id] ?? false;
const isCollapsed = collapsedFamilies['other'] ?? false;
return (
<section key={family.id} className="p-2">
<Collapsible
open={!isCollapsed}
onOpenChange={() => toggleFamilyCollapsed(family.id)}
>
<CollapsibleTrigger className="flex w-full items-center justify-between py-0.5 group">
<div className="flex items-center gap-1.5 text-left">
<span className="typography-ui-label font-normal text-foreground">{family.label}</span>
<span className="typography-micro text-muted-foreground">
({familyModels.length})
</span>
</div>
{isCollapsed ? (
<Icon name="arrow-right-s" className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors" />
) : (
<Icon name="arrow-down-s" className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors" />
)}
</CollapsibleTrigger>
<CollapsibleContent>
<div className="divide-y divide-[var(--surface-subtle)] mt-1">
{familyModels.map((model) => {
const entries = Object.entries(model.windows.windows);
if (entries.length === 0) return null;
const [label, window] = entries[0];
const isSelected = providerSelectedModels.includes(model.name);
return (
<section className="p-2">
<Collapsible
open={!isCollapsed}
onOpenChange={() => toggleFamilyCollapsed('other')}
>
<CollapsibleTrigger className="flex w-full items-center justify-between py-0.5 group">
<div className="flex items-center gap-1.5 text-left">
<span className="typography-ui-label font-normal text-foreground">{t('settings.usage.page.section.otherModels')}</span>
<span className="typography-micro text-muted-foreground">
({otherModels.length})
</span>
</div>
{isCollapsed ? (
<Icon name="arrow-right-s" className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors" />
) : (
<Icon name="arrow-down-s" className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors" />
)}
</CollapsibleTrigger>
<CollapsibleContent>
<div className="divide-y divide-[var(--surface-subtle)] mt-1">
{otherModels.map((model) => {
const entries = Object.entries(model.windows.windows);
if (entries.length === 0) return null;
const [label, window] = entries[0];
const isSelected = providerSelectedModels.includes(model.name);
return (
<UsageCard
key={model.name}
title={label}
subtitle={getDisplayModelName(model.name)}
window={window}
showToggle
toggleEnabled={isSelected}
onToggle={() => handleModelToggle(model.name)}
/>
);
})}
</div>
</CollapsibleContent>
</Collapsible>
</section>
);
})}
return (
<UsageCard
key={model.name}
title={label}
subtitle={getDisplayModelName(model.name)}
window={window}
showToggle
toggleEnabled={isSelected}
onToggle={() => handleModelToggle(model.name)}
/>
);
})}
</div>
</CollapsibleContent>
</Collapsible>
</section>
);
})()}
</SettingsSection>
)}
{/* Other family */}
{(() => {
const otherModels = modelsByFamily.get(null) ?? [];
if (otherModels.length === 0) return null;
const isCollapsed = collapsedFamilies['other'] ?? false;
return (
<section className="p-2">
<Collapsible
open={!isCollapsed}
onOpenChange={() => toggleFamilyCollapsed('other')}
>
<CollapsibleTrigger className="flex w-full items-center justify-between py-0.5 group">
<div className="flex items-center gap-1.5 text-left">
<span className="typography-ui-label font-normal text-foreground">{t('settings.usage.page.section.otherModels')}</span>
<span className="typography-micro text-muted-foreground">
({otherModels.length})
</span>
</div>
{isCollapsed ? (
<Icon name="arrow-right-s" className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors" />
) : (
<Icon name="arrow-down-s" className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors" />
)}
</CollapsibleTrigger>
<CollapsibleContent>
<div className="divide-y divide-[var(--surface-subtle)] mt-1">
{otherModels.map((model) => {
const entries = Object.entries(model.windows.windows);
if (entries.length === 0) return null;
const [label, window] = entries[0];
const isSelected = providerSelectedModels.includes(model.name);
return (
<UsageCard
key={model.name}
title={label}
subtitle={getDisplayModelName(model.name)}
window={window}
showToggle
toggleEnabled={isSelected}
onToggle={() => handleModelToggle(model.name)}
/>
);
})}
</div>
</CollapsibleContent>
</Collapsible>
</section>
);
})()}
</div>
</div>
)}
{selectedResult?.configured && usage && Object.keys(usage.windows ?? {}).length === 0 &&
providerModels.length === 0 && (
<div className="mb-8 px-2">
<p className="typography-ui-label text-foreground">{t('settings.usage.page.state.noQuotaWindowsTitle')}</p>
<p className="typography-meta text-muted-foreground mt-1">{t('settings.usage.page.state.noQuotaWindowsDescription')}</p>
</div>
)}
</div>
</ScrollableOverlay>
{selectedResult?.configured && usage && Object.keys(usage.windows ?? {}).length === 0 &&
providerModels.length === 0 && (
<div className="pb-8">
<p className="typography-ui-label text-foreground">{t('settings.usage.page.state.noQuotaWindowsTitle')}</p>
<p className="typography-meta text-muted-foreground mt-1">{t('settings.usage.page.state.noQuotaWindowsDescription')}</p>
</div>
)}
</SettingsPageLayout>
);
};
@@ -11,6 +11,7 @@ import { QUOTA_PROVIDERS, resolveUsageTone } from '@/lib/quota';
import { useQuotaStore } from '@/stores/useQuotaStore';
import { updateDesktopSettings } from '@/lib/persistence';
import { useI18n } from '@/lib/i18n';
import { SETTINGS_PANEL_TITLE_CLASS } from '@/components/sections/shared/SettingsSection';
interface UsageSidebarProps {
onItemSelect?: () => void;
@@ -88,7 +89,7 @@ export const UsageSidebar: React.FC<UsageSidebarProps> = ({ onItemSelect }) => {
return (
<div className={cn('flex h-full flex-col', bgClass)}>
<div className="border-b px-3 pt-4 pb-3">
<h2 className="text-base font-semibold text-foreground mb-3">{t('settings.usage.sidebar.title')}</h2>
<h2 className={`${SETTINGS_PANEL_TITLE_CLASS} mb-3`}>{t('settings.usage.sidebar.title')}</h2>
<div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground">{t('settings.usage.sidebar.total', { count: QUOTA_PROVIDERS.length })}</span>
<div className="flex items-center gap-2">
@@ -24,6 +24,7 @@ import {
CommandSeparator,
} from '@/components/ui/command';
import { cn } from '@/lib/utils';
import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import { useUIStore } from '@/stores/useUIStore';
@@ -1584,12 +1585,15 @@ export function NewWorktreeDialog({
<div className="flex items-center gap-2">
<DropdownMenu open={existingBranchDropdownOpen} onOpenChange={setExistingBranchDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-9 min-w-[220px] max-w-full justify-between gap-2">
<button
type="button"
className={cn(dropdownTriggerVariants({ size: 'default' }), 'min-w-[220px] max-w-full')}
>
<span className={cn('truncate', existingBranchState.selectedBranch ? 'text-foreground' : 'text-muted-foreground')}>
{existingBranchState.selectedBranch || t('session.newWorktree.chooseBranch')}
</span>
<Icon name="arrow-down-s" className="h-4 w-4 shrink-0 text-muted-foreground" />
</Button>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" sideOffset={6} portalToBody className="w-[min(42rem,calc(100vw-2rem))] p-0 max-h-[min(var(--available-height),24rem)] flex flex-col overflow-hidden" ref={existingBranchDropdownContentRef}>
<Command shouldFilter={false}>
@@ -1825,12 +1829,15 @@ export function NewWorktreeDialog({
</label>
<DropdownMenu open={sourceBranchDropdownOpen} onOpenChange={setSourceBranchDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-9 min-w-[220px] max-w-full justify-between gap-2">
<button
type="button"
className={cn(dropdownTriggerVariants({ size: 'default' }), 'min-w-[220px] max-w-full')}
>
<span className={cn('truncate', newBranchState.sourceBranch ? 'text-foreground' : 'text-muted-foreground')}>
{newBranchState.sourceBranch || t('session.newWorktree.selectSourceBranchPlaceholder')}
</span>
<Icon name="arrow-down-s" className="h-4 w-4 shrink-0 text-muted-foreground" />
</Button>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" portalToBody className="w-[min(42rem,calc(100vw-2rem))] p-0 max-h-[min(var(--available-height),24rem)] flex flex-col overflow-hidden" ref={sourceBranchDropdownContentRef}>
<Command shouldFilter={false}>
@@ -1,6 +1,7 @@
import * as React from 'react';
import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { cn } from '@/lib/utils';
import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger';
import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
@@ -701,7 +702,7 @@ const CronScheduleSection: React.FC<{
}));
}}
>
<SelectTrigger className="w-fit max-w-full"><SelectValue /></SelectTrigger>
<SelectTrigger size="lg" className="w-fit max-w-full"><SelectValue /></SelectTrigger>
<SelectContent>
{TIMEZONE_OPTIONS.map((timezone) => (
<SelectItem key={timezone} value={timezone}>{timezone}</SelectItem>
@@ -1226,7 +1227,7 @@ export function ScheduledTaskEditorDialog(props: {
}));
}}
>
<SelectTrigger className="w-fit max-w-full">
<SelectTrigger size="lg" className="w-fit max-w-full">
<SelectValue>
{(value) => value === 'daily'
? t('sessions.scheduledTasks.editor.scheduleType.daily')
@@ -1257,7 +1258,7 @@ export function ScheduledTaskEditorDialog(props: {
<div className="relative">
<button
type="button"
className="inline-flex h-9 w-fit max-w-full items-center justify-between gap-2 rounded-md border border-border bg-background px-3 text-left hover:bg-interactive-hover"
className={cn(dropdownTriggerVariants({ size: 'default' }), 'w-fit max-w-full')}
onClick={() => setIsDatePickerOpen((prev) => !prev)}
>
<span className="inline-flex items-center gap-2">
@@ -1386,7 +1387,7 @@ export function ScheduledTaskEditorDialog(props: {
}));
}}
>
<SelectTrigger className="w-fit max-w-full"><SelectValue /></SelectTrigger>
<SelectTrigger size="lg" className="w-fit max-w-full"><SelectValue /></SelectTrigger>
<SelectContent>
{TIMEZONE_OPTIONS.map((timezone) => (
<SelectItem key={timezone} value={timezone}>{timezone}</SelectItem>
@@ -1474,7 +1475,7 @@ export function ScheduledTaskEditorDialog(props: {
}));
}}
>
<SelectTrigger className="w-fit max-w-full"><SelectValue /></SelectTrigger>
<SelectTrigger size="lg" className="w-fit max-w-full"><SelectValue /></SelectTrigger>
<SelectContent>
{TIMEZONE_OPTIONS.map((timezone) => (
<SelectItem key={timezone} value={timezone}>{timezone}</SelectItem>
@@ -1520,7 +1521,7 @@ export function ScheduledTaskEditorDialog(props: {
}));
}}
>
<SelectTrigger className="w-fit max-w-full">
<SelectTrigger size="lg" className="w-fit max-w-full">
<SelectValue>
{(value) => value === '__default'
? t('sessions.scheduledTasks.editor.thinkingLevel.default')
@@ -387,7 +387,7 @@ export function ScheduledTasksDialog() {
}
}}
>
<SelectTrigger className={isMobile ? 'w-full' : undefined}>
<SelectTrigger size="lg" className={isMobile ? 'w-full' : undefined}>
{selectedProject ? (
<SelectValue>{renderProjectLabel(selectedProject)}</SelectValue>
) : (
@@ -414,7 +414,7 @@ export function ScheduledTasksDialog() {
const tasksContent = (
<div className="space-y-4">
{!isMobile ? (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
{projectSelector}
<Button onClick={openNewTaskEditor} disabled={!selectedProjectID}>
<Icon name="add" className="mr-1 h-4 w-4" /> {t('sessions.scheduledTasks.dialog.actions.newTask')}
@@ -0,0 +1,39 @@
import { cva, type VariantProps } from 'class-variance-authority';
/**
* Single source of truth for every dropdown-style trigger surface in the app:
* native Select triggers, DropdownMenu trigger buttons, and custom pickers
* (model/agent/project selectors, git branch pickers, ).
*
* Change shape, border, radius, or interaction colors HERE and every trigger
* in every surface follows. Do not re-declare these classes at call sites
* call sites may only add layout (width/min-width/max-width, truncation).
*
* Sizes:
* - `sm` dense surfaces: chat composer, toolbars, list rows (h-6).
* - `default` forms, dialogs, and settings pages (h-8).
*/
export const dropdownTriggerVariants = cva(
[
'border-input flex items-center justify-between gap-2 rounded-md border bg-transparent',
'typography-ui-label whitespace-nowrap shadow-none outline-none text-left',
'hover:bg-interactive-hover data-[popup-open]:bg-interactive-active',
'focus-visible:outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'disabled:cursor-not-allowed disabled:opacity-50',
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='text-'])]:text-muted-foreground",
].join(' '),
{
variants: {
size: {
sm: "h-6 min-h-6 px-2 [&_svg:not([class*='size-'])]:size-3.5",
default: "h-8 min-h-8 px-3 [&_svg:not([class*='size-'])]:size-4",
},
},
defaultVariants: {
size: 'default',
},
},
);
export type DropdownTriggerVariantProps = VariantProps<typeof dropdownTriggerVariants>;
export type DropdownTriggerSize = NonNullable<DropdownTriggerVariantProps['size']>;
+18 -15
View File
@@ -59,7 +59,7 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
ref
) => {
const { t } = useI18n()
const [draft, setDraft] = React.useState(() => (value === undefined ? '' : String(value)))
const [draft, setDraft] = React.useState(() => (value == null ? '' : String(value)))
const { isMobile } = useDeviceInfo()
const ignoreNextClickRef = React.useRef(false)
const swallowNextClickCleanupRef = React.useRef<(() => void) | null>(null)
@@ -100,7 +100,7 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
}, [])
React.useEffect(() => {
setDraft(value === undefined ? '' : String(value))
setDraft(value == null ? '' : String(value))
}, [value])
const baseValue = React.useMemo(() => {
@@ -158,7 +158,7 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
(event: React.FocusEvent<HTMLInputElement>) => {
if (draft.trim() === '') {
if (!onClear) {
setDraft(value === undefined ? '' : String(value))
setDraft(value == null ? '' : String(value))
}
onBlur?.(event)
return
@@ -166,7 +166,7 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
const parsed = Number(draft)
if (!Number.isFinite(parsed)) {
setDraft(value === undefined ? '' : String(value))
setDraft(value == null ? '' : String(value))
} else {
const clamped = clamp(parsed, min, max)
const normalized = normalizeToStep(clamped, step)
@@ -228,7 +228,7 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
<div
className={cn(
// NOTE: mobile.css enforces min-height:36px on buttons; match it to avoid clipping.
"flex h-9 shrink-0 items-stretch overflow-x-hidden overflow-y-hidden rounded-lg border border-border bg-transparent select-none overscroll-contain",
"flex h-8 shrink-0 items-stretch overflow-x-hidden overflow-y-hidden rounded-lg border border-border bg-transparent select-none overscroll-contain",
"[-webkit-user-select:none] [-webkit-touch-callout:none]",
"disabled:pointer-events-none disabled:opacity-50",
containerClassName
@@ -241,7 +241,7 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
onTouchStart={handleMobileTouchActivate(handleMobileDecrement)}
onClick={handleMobileClickActivate(handleMobileDecrement)}
className={cn(
"grid h-full min-h-0 w-9 place-items-center overflow-x-hidden overflow-y-hidden border-r border-border p-0 leading-none touch-none",
"grid h-full min-h-0 w-9 shrink-0 place-items-center overflow-x-hidden overflow-y-hidden border-r border-border p-0 leading-none touch-none",
"text-muted-foreground",
"disabled:pointer-events-none disabled:opacity-50",
!decrementDisabled && "active:bg-interactive-hover"
@@ -252,13 +252,13 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
<div
className={cn(
"flex h-full min-w-0 w-14 items-center justify-center bg-transparent px-1.5",
"flex h-full min-w-0 flex-1 items-center justify-center bg-transparent px-1.5",
"text-center text-[16px] leading-none text-foreground [font-variant-numeric:tabular-nums]",
className
)}
aria-live="polite"
>
{value === undefined ? emptyLabel : draft}
{value == null ? emptyLabel : draft}
</div>
<button
@@ -268,7 +268,7 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
onTouchStart={handleMobileTouchActivate(handleMobileIncrement)}
onClick={handleMobileClickActivate(handleMobileIncrement)}
className={cn(
"grid h-full min-h-0 w-9 place-items-center overflow-x-hidden overflow-y-hidden border-l border-border p-0 leading-none touch-none",
"grid h-full min-h-0 w-9 shrink-0 place-items-center overflow-x-hidden overflow-y-hidden border-l border-border p-0 leading-none touch-none",
"text-muted-foreground",
"disabled:pointer-events-none disabled:opacity-50",
!incrementDisabled && "active:bg-interactive-hover"
@@ -283,8 +283,9 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
return (
<div
className={cn(
"flex h-7 shrink-0 items-stretch overflow-x-hidden overflow-y-hidden rounded-lg border border-border bg-transparent",
"flex h-8 shrink-0 items-stretch overflow-x-hidden overflow-y-hidden rounded-md border border-border bg-transparent",
"disabled:pointer-events-none disabled:opacity-50",
"transition-[background-color,border-color,box-shadow] duration-150 ease-in-out",
containerClassName
)}
>
@@ -294,9 +295,10 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
disabled={decrementDisabled}
onClick={() => commitValue(committedValueRef.current - step)}
className={cn(
"flex h-full w-7 items-center justify-center overflow-x-hidden overflow-y-hidden border-r border-border p-0 leading-none touch-manipulation",
"flex h-full w-7 shrink-0 items-center justify-center overflow-x-hidden overflow-y-hidden border-r border-border p-0 leading-none touch-manipulation",
"text-muted-foreground hover:bg-interactive-hover hover:text-foreground",
"disabled:pointer-events-none disabled:opacity-50"
"disabled:pointer-events-none disabled:opacity-50",
"transition-colors duration-150 ease-in-out"
)}
>
<Icon name="subtract" className="block h-3.5 w-3.5" />
@@ -315,7 +317,7 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
autoCorrect="off"
autoCapitalize="off"
className={cn(
"h-full min-w-0 w-14 bg-transparent px-1.5 text-center typography-ui-label leading-none text-foreground [font-variant-numeric:tabular-nums]",
"h-full min-w-0 w-10 flex-1 bg-transparent px-1.5 text-center typography-ui-label leading-none text-foreground [font-variant-numeric:tabular-nums]",
"placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground",
"appearance-none outline-none [appearance:textfield] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
"disabled:pointer-events-none disabled:cursor-not-allowed",
@@ -328,9 +330,10 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
disabled={incrementDisabled}
onClick={() => commitValue(committedValueRef.current + step)}
className={cn(
"flex h-full w-7 items-center justify-center overflow-x-hidden overflow-y-hidden border-l border-border p-0 leading-none touch-manipulation",
"flex h-full w-7 shrink-0 items-center justify-center overflow-x-hidden overflow-y-hidden border-l border-border p-0 leading-none touch-manipulation",
"text-muted-foreground hover:bg-interactive-hover hover:text-foreground",
"disabled:pointer-events-none disabled:opacity-50"
"disabled:pointer-events-none disabled:opacity-50",
"transition-colors duration-150 ease-in-out"
)}
>
<Icon name="add" className="block h-3.5 w-3.5" />
+6 -2
View File
@@ -5,6 +5,7 @@ import { Select as BaseSelect } from "@base-ui/react/select"
import type { SelectRootChangeEventDetails } from "@base-ui/react/select";
import { cn } from "@/lib/utils"
import { dropdownTriggerVariants } from "@/components/ui/dropdown-trigger"
import { ScrollableOverlay } from "@/components/ui/ScrollableOverlay";
import { Icon } from "@/components/icon/Icon";
@@ -108,7 +109,7 @@ function SelectTrigger({
onFocusCapture,
...props
}: React.ComponentProps<typeof BaseSelect.Trigger> & AsChildProps & {
size?: "sm" | "default" | "lg" | "chip"
size?: "sm" | "default" | "lg" | "chip" | "settings"
}) {
const portalContext = React.useContext(SelectPortalContext);
@@ -128,7 +129,10 @@ function SelectTrigger({
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex w-fit items-center justify-between gap-2 rounded-lg border bg-transparent px-2 py-2 typography-ui-label whitespace-nowrap shadow-none outline-none focus-visible:outline-none hover:bg-interactive-hover data-[popup-open]:bg-interactive-active focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-6 data-[size=sm]:h-6 data-[size=lg]:h-8 data-[size=lg]:py-1.5 data-[size=chip]:h-7 data-[size=chip]:py-1 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
// Shared trigger chrome: one source of truth for every dropdown trigger.
// Legacy sizes map onto the two canonical ones: sm (dense) / default (forms).
dropdownTriggerVariants({ size: size === 'settings' || size === 'lg' ? 'default' : 'sm' }),
"w-fit data-[placeholder]:text-muted-foreground aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2",
className
)}
onPointerDownCapture={(event) => {
+122 -229
View File
@@ -37,6 +37,10 @@ import { GitPage } from '@/components/sections/git-identities/GitPage';
import type { OpenChamberSection } from '@/components/sections/openchamber/types';
import { OpenChamberPage } from '@/components/sections/openchamber/OpenChamberPage';
import { AboutSettings } from '@/components/sections/openchamber/AboutSettings';
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
import {
SETTINGS_SECTION_TITLE_CLASS,
} from '@/components/sections/shared/SettingsSection';
import { useDeviceInfo } from '@/lib/device';
import { isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime, isWebRuntime } from '@/lib/desktop';
import { useI18n } from '@/lib/i18n';
@@ -54,16 +58,11 @@ import {
} from '@/lib/settings/metadata';
import { buildSettingsSearchResults, type SettingsSearchResult } from '@/lib/settings/search';
// Same constraints as main sidebar
const SETTINGS_NAV_MIN_WIDTH = 176;
const SETTINGS_NAV_MAX_WIDTH = 280;
const SETTINGS_NAV_RESIZE_STEP = 8;
// UI Kit: fixed settings navigation width
const SETTINGS_NAV_WIDTH = 256;
const SETTINGS_SPLIT_SIDEBAR_WIDTH = 280;
const SETTINGS_DETAIL_HISTORY_KEY = '__openchamberSettingsDetail';
function clampSettingsNavWidth(width: number): number {
return Math.min(SETTINGS_NAV_MAX_WIDTH, Math.max(SETTINGS_NAV_MIN_WIDTH, width));
}
type MobileStage = 'nav' | 'page-sidebar' | 'page-content';
type SettingsDetailHistoryEntry = {
page: SettingsPageSlug;
@@ -82,30 +81,37 @@ interface SettingsViewProps {
}
const pageOrder: SettingsPageSlug[] = [
// 'general' group — OpenChamber
'general',
'appearance',
'chat',
'notifications',
'sessions',
'shortcuts',
'git',
'magic-prompts',
'snippets',
'voice',
'usage',
'about',
// 'projects' group — Workspace
'projects',
'remote-instances',
'tunnel',
'git',
// 'opencode' group — OpenCode
'providers',
'agents',
'behavior',
'commands',
'mcp',
'plugins',
'providers',
'usage',
// 'content' group — Library
'magic-prompts',
'snippets',
'skills.installed',
'skills.catalog',
'voice',
'tunnel',
'about',
];
const NAV_GROUP_ORDER = ['general', 'projects', 'opencode', 'content'] as const;
const SNIPPETS_SETTINGS_ICON = { icon: 'chat-thread' } as const;
const ADD_PROVIDER_SETTINGS_ID = '__add_provider__';
@@ -167,10 +173,12 @@ function getCurrentHistoryState(): Record<string, unknown> {
// eslint-disable-next-line react-refresh/only-export-components
export function getSettingsNavIcon(slug: SettingsPageSlug): IconName | null {
switch (slug) {
case 'general':
return 'settings-3';
case 'projects':
return 'folders';
case 'remote-instances':
return 'server';
return 'computer';
case 'appearance':
return 'palette';
case 'chat':
@@ -197,7 +205,7 @@ export function getSettingsNavIcon(slug: SettingsPageSlug): IconName | null {
case 'mcp':
return null;
case 'plugins':
return 'code-box';
return 'plug-2';
case 'skills.installed':
return 'book-open';
@@ -212,7 +220,7 @@ export function getSettingsNavIcon(slug: SettingsPageSlug): IconName | null {
case 'voice':
return 'mic';
case 'tunnel':
return 'global';
return 'home-office';
case 'about':
return 'information';
case 'home':
@@ -222,82 +230,6 @@ export function getSettingsNavIcon(slug: SettingsPageSlug): IconName | null {
}
}
const SettingsHome: React.FC<{ onOpen: (slug: SettingsPageSlug) => void }> = ({ onOpen }) => {
const { t } = useI18n();
return (
<div className="h-full overflow-auto">
<div className="mx-auto w-full max-w-3xl px-6 py-6 space-y-6">
<div className="space-y-1">
<h1 className="typography-ui-header font-semibold text-foreground">{t('settings.view.home.title')}</h1>
<p className="typography-ui text-muted-foreground">{t('settings.view.home.description')}</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<button
type="button"
onClick={() => onOpen('providers')}
className={cn(
'rounded-lg border border-border bg-[var(--surface-elevated)] p-4 text-left',
'hover:bg-[var(--interactive-hover)] transition-colors'
)}
>
<div className="typography-ui-label text-foreground">{t('settings.view.home.cards.providers.title')}</div>
<div className="typography-micro text-muted-foreground/70">{t('settings.view.home.cards.providers.description')}</div>
</button>
<button
type="button"
onClick={() => onOpen('agents')}
className={cn(
'rounded-lg border border-border bg-[var(--surface-elevated)] p-4 text-left',
'hover:bg-[var(--interactive-hover)] transition-colors'
)}
>
<div className="typography-ui-label text-foreground">{t('settings.view.home.cards.agents.title')}</div>
<div className="typography-micro text-muted-foreground/70">{t('settings.view.home.cards.agents.description')}</div>
</button>
<button
type="button"
onClick={() => onOpen('skills.catalog')}
className={cn(
'rounded-lg border border-border bg-[var(--surface-elevated)] p-4 text-left',
'hover:bg-[var(--interactive-hover)] transition-colors'
)}
>
<div className="typography-ui-label text-foreground">{t('settings.view.home.cards.skillsCatalog.title')}</div>
<div className="typography-micro text-muted-foreground/70">{t('settings.view.home.cards.skillsCatalog.description')}</div>
</button>
<button
type="button"
onClick={() => onOpen('mcp')}
className={cn(
'rounded-lg border border-border bg-[var(--surface-elevated)] p-4 text-left',
'hover:bg-[var(--interactive-hover)] transition-colors'
)}
>
<div className="typography-ui-label text-foreground">{t('settings.view.home.cards.mcp.title')}</div>
<div className="typography-micro text-muted-foreground/70">{t('settings.view.home.cards.mcp.description')}</div>
</button>
<button
type="button"
onClick={() => onOpen('usage')}
className={cn(
'rounded-lg border border-border bg-[var(--surface-elevated)] p-4 text-left',
'hover:bg-[var(--interactive-hover)] transition-colors'
)}
>
<div className="typography-ui-label text-foreground">{t('settings.view.home.cards.usage.title')}</div>
<div className="typography-micro text-muted-foreground/70">{t('settings.view.home.cards.usage.description')}</div>
</button>
</div>
</div>
</div>
);
};
export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile, isWindowed, visiblePageSlugs, initialMobileStage = 'nav' }) => {
const { t } = useI18n();
const deviceInfo = useDeviceInfo();
@@ -311,14 +243,18 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
const [mobileStage, setMobileStage] = React.useState<MobileStage>(initialMobileStage);
const autoNavSlugRef = React.useRef<string | null>(null);
const [navWidth, setNavWidth] = React.useState(216);
// No starter page on desktop: 'home' (fresh state) resolves to General.
// settingsPage persists in the UI store, so subsequent opens restore the
// last visited page. Mobile keeps 'home' — its entry stage is the nav list.
React.useEffect(() => {
if (!isMobile && settingsSlug === 'home') {
setSettingsPage('general');
}
}, [isMobile, setSettingsPage, settingsSlug]);
const [settingsSearchQuery, setSettingsSearchQuery] = React.useState('');
const [pendingSearchItemId, setPendingSearchItemId] = React.useState<string | null>(null);
const [activeSearchResultIndex, setActiveSearchResultIndex] = React.useState(0);
const [hasManuallyResized, setHasManuallyResized] = React.useState(false);
const [isResizing, setIsResizing] = React.useState(false);
const startXRef = React.useRef(0);
const startWidthRef = React.useRef(navWidth);
const containerRef = React.useRef<HTMLDivElement>(null);
const searchResultRefs = React.useRef<(HTMLButtonElement | null)[]>([]);
const activeSearchResultIndexRef = React.useRef(0);
@@ -362,68 +298,6 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
React.useEffect(() => {
if (typeof window === 'undefined') return;
const handleResize = () => {
if (!hasManuallyResized) {
const proportionalWidth = clampSettingsNavWidth(Math.floor(window.innerWidth * 0.12));
setNavWidth(proportionalWidth);
}
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [hasManuallyResized]);
React.useEffect(() => {
if (!isResizing) return;
const handlePointerMove = (event: PointerEvent) => {
const delta = event.clientX - startXRef.current;
const nextWidth = clampSettingsNavWidth(startWidthRef.current + delta);
setNavWidth(nextWidth);
setHasManuallyResized(true);
};
const handlePointerUp = () => setIsResizing(false);
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp, { once: true });
return () => {
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
};
}, [isResizing]);
const handlePointerDown = (event: React.PointerEvent) => {
setIsResizing(true);
startXRef.current = event.clientX;
startWidthRef.current = navWidth;
event.preventDefault();
};
const handleResizeKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
const step = event.shiftKey ? SETTINGS_NAV_RESIZE_STEP * 4 : SETTINGS_NAV_RESIZE_STEP;
let nextWidth: number;
switch (event.key) {
case 'ArrowLeft':
nextWidth = navWidth - step;
break;
case 'ArrowRight':
nextWidth = navWidth + step;
break;
case 'Home':
nextWidth = SETTINGS_NAV_MIN_WIDTH;
break;
case 'End':
nextWidth = SETTINGS_NAV_MAX_WIDTH;
break;
default:
return;
}
event.preventDefault();
setNavWidth(clampSettingsNavWidth(nextWidth));
setHasManuallyResized(true);
};
// Load stores when project changes or when a page becomes active.
React.useEffect(() => {
if (!isSettingsDialogOpen && !runtimeCtx.isVSCode && !isWindowed) {
@@ -476,6 +350,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
// Nav is always open (collapsed state removed)
const openChamberSectionBySlug: Partial<Record<SettingsPageSlug, OpenChamberSection>> = React.useMemo(() => ({
general: 'general',
appearance: 'visual',
chat: 'chat',
shortcuts: 'shortcuts',
@@ -487,6 +362,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
const getPageTitle = React.useCallback((slug: SettingsPageSlug): string => {
switch (slug) {
case 'general':
return t('settings.page.general.title');
case 'projects':
return t('settings.page.projects.title');
case 'remote-instances':
@@ -739,7 +616,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
return (
<div className="flex h-full items-center justify-center px-6">
<div className="max-w-md text-center">
<div className="typography-ui-header font-semibold text-foreground">{t('settings.view.unavailable.title')}</div>
<div className={SETTINGS_SECTION_TITLE_CLASS}>{t('settings.view.unavailable.title')}</div>
<p className="typography-ui text-muted-foreground mt-1">{t('settings.view.unavailable.description')}</p>
</div>
</div>
@@ -780,8 +657,6 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
}
switch (slug) {
case 'home':
return <SettingsHome onOpen={openPage} />;
case 'projects':
return <ProjectsPage />;
case 'remote-instances':
@@ -805,13 +680,18 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
case 'usage':
return <UsagePage />;
case 'about':
return <div className="h-full overflow-auto px-5 py-6"><AboutSettings /></div>;
return (
<SettingsPageLayout title={t('settings.page.about.title')} showSaveStatus={false}>
<AboutSettings />
</SettingsPageLayout>
);
case 'magic-prompts':
return <MagicPromptsPage />;
case 'snippets':
return <SnippetsPage />;
case 'git':
return <GitPage />;
case 'general':
case 'appearance':
case 'chat':
case 'shortcuts':
@@ -822,10 +702,11 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
const section = openChamberSectionBySlug[slug] ?? 'visual';
return <OpenChamberPage section={section} />;
}
case 'home':
default:
return <SettingsHome onOpen={openPage} />;
return null;
}
}, [openChamberSectionBySlug, openPage, renderUnavailable, runtimeCtx]);
}, [openChamberSectionBySlug, renderUnavailable, runtimeCtx, t]);
// Mobile: if opened via deep-link / palette to a non-home page, jump into it once.
React.useEffect(() => {
@@ -938,7 +819,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="px-2 pt-3">
<div className="px-4 pt-3">
<div className="flex h-10 items-center gap-1.5 rounded-md border border-border bg-background/70 px-2 text-muted-foreground focus-within:ring-2 focus-within:ring-primary/40 sm:h-8">
<Icon name="search" className="h-4 w-4 shrink-0" />
<input
@@ -964,7 +845,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
{/* Scrollable nav items */}
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden">
<div className="flex flex-col gap-0.5 pt-4 pb-2 px-2">
<div className="flex flex-col gap-0.5 px-4 pt-4 pb-2">
{hasSearchQuery ? (
settingsSearchResults.length > 0 ? (() => {
let resultIndex = 0;
@@ -1010,54 +891,83 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
{t('settings.view.search.noResults')}
</div>
)
) : sortedFilteredPages.map((page) => {
const selected = settingsSlug === page.slug;
const iconName = getSettingsNavIcon(page.slug);
if (!iconName && page.slug !== 'mcp') return null;
) : (() => {
const pagesByGroup = new Map<string, typeof sortedFilteredPages>();
for (const page of sortedFilteredPages) {
const group = page.group;
const existing = pagesByGroup.get(group);
if (existing) {
existing.push(page);
} else {
pagesByGroup.set(group, [page]);
}
}
return (
<Tooltip key={page.slug}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => openPage(page.slug)}
aria-current={selected ? 'page' : undefined}
className={cn(
'flex h-8 items-center gap-2 rounded-md px-2 overflow-hidden',
selected
? 'bg-interactive-selection text-foreground'
: 'text-foreground hover:bg-interactive-hover'
)}
>
{page.slug === 'mcp'
? <McpIcon className="h-4 w-4 shrink-0" />
: <Icon name={iconName!} className="h-4 w-4 shrink-0" />}
<span className="flex items-center gap-1.5 whitespace-nowrap overflow-hidden transition-opacity duration-150 opacity-100">
<span className="typography-ui-label font-normal truncate">{getPageTitle(page.slug)}</span>
{page.slug === 'tunnel' && (
<span className="shrink-0 typography-micro px-1 rounded leading-none pb-px text-[var(--status-warning)] bg-[var(--status-warning)]/10">
{t('settings.view.badge.beta')}
</span>
)}
</span>
</button>
</TooltipTrigger>
</Tooltip>
);
})}
const visibleGroups = NAV_GROUP_ORDER
.map((group) => ({ group, pages: pagesByGroup.get(group) ?? [] }))
.filter((entry) => entry.pages.length > 0);
return visibleGroups.map(({ group, pages }, groupIndex) => (
<div key={group} className="space-y-0.5">
<div
className={cn(
'px-3 pb-1 typography-micro font-semibold uppercase tracking-wide text-muted-foreground sm:px-2 sm:pb-0.5',
groupIndex === 0 ? 'pt-1' : 'pt-4 sm:pt-3',
)}
>
{t(`settings.view.nav.group.${group}`)}
</div>
{pages.map((page) => {
const selected = settingsSlug === page.slug;
const iconName = getSettingsNavIcon(page.slug);
if (!iconName && page.slug !== 'mcp') return null;
return (
<Tooltip key={page.slug}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => openPage(page.slug)}
aria-current={selected ? 'page' : undefined}
className={cn(
'flex h-11 w-full items-center gap-2.5 rounded-md px-3 overflow-hidden sm:h-8 sm:gap-2 sm:px-2',
selected
? 'bg-interactive-selection text-foreground'
: 'text-foreground hover:bg-interactive-hover'
)}
>
{page.slug === 'mcp'
? <McpIcon className="h-[18px] w-[18px] shrink-0 sm:h-4 sm:w-4" />
: <Icon name={iconName!} className="h-[18px] w-[18px] shrink-0 sm:h-4 sm:w-4" />}
<span className="flex items-center gap-1.5 whitespace-nowrap overflow-hidden transition-opacity duration-150 opacity-100">
<span className="typography-ui-label font-normal truncate">{getPageTitle(page.slug)}</span>
{page.slug === 'tunnel' && (
<span className="shrink-0 typography-micro px-1 rounded leading-none pb-px text-[var(--status-warning)] bg-[var(--status-warning)]/10">
{t('settings.view.badge.beta')}
</span>
)}
</span>
</button>
</TooltipTrigger>
</Tooltip>
);
})}
</div>
));
})()}
</div>
</div>
{/* Footer */}
<div className="overflow-hidden transition-opacity duration-150 opacity-100">
<div className="border-t border-border bg-sidebar px-2 py-1 space-y-0.5">
<div className="border-t border-border bg-background px-4 py-1 space-y-0.5 sm:bg-sidebar">
{!runtimeCtx.isVSCode && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
'flex h-7 w-full items-center gap-2 rounded-md px-2 overflow-hidden whitespace-nowrap',
'flex h-11 w-full items-center gap-2 rounded-md px-3 overflow-hidden whitespace-nowrap sm:h-7 sm:px-2',
'text-sm font-semibold text-sidebar-foreground/90',
'hover:text-sidebar-foreground hover:bg-interactive-hover',
)}
@@ -1082,7 +992,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
const renderMobileStage = () => {
if (mobileStage === 'nav') {
return (
<div className={cn('flex-1 min-h-0 overflow-hidden', runtimeCtx.isVSCode ? 'bg-background' : 'bg-sidebar')}>
<div className="flex-1 min-h-0 overflow-hidden bg-background">
<div className="flex h-full min-h-0 flex-col">
<ErrorBoundary>{renderSettingsNav()}</ErrorBoundary>
</div>
@@ -1105,7 +1015,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
);
}
return (
<div className={cn('flex-1 min-h-0 overflow-hidden', runtimeCtx.isVSCode ? 'bg-background' : 'bg-sidebar')}>
<div className="flex-1 min-h-0 overflow-hidden bg-background">
<ErrorBoundary>
{renderPageSidebar(settingsSlug, { onItemSelect: handleMobilePageSidebarItemSelect })}
</ErrorBoundary>
@@ -1125,13 +1035,13 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
const renderDesktopContent = () => {
if (!activePageMeta || settingsSlug === 'home') {
return <SettingsHome onOpen={openPage} />;
return null;
}
if (activePageMeta.kind === 'split') {
return (
<div className="flex h-full min-h-0 overflow-hidden">
<div className={cn('w-[264px] min-w-[264px] border-r', runtimeCtx.isVSCode ? 'bg-background' : 'bg-sidebar')} style={{ borderColor: 'var(--interactive-border)' }}>
<div className={cn('border-r', runtimeCtx.isVSCode ? 'bg-background' : 'bg-sidebar')} style={{ width: SETTINGS_SPLIT_SIDEBAR_WIDTH, minWidth: SETTINGS_SPLIT_SIDEBAR_WIDTH, borderColor: 'var(--interactive-border)' }}>
<ErrorBoundary>{renderPageSidebar(settingsSlug, {})}</ErrorBoundary>
</div>
<div className="flex-1 min-h-0 overflow-hidden bg-background">
@@ -1242,30 +1152,13 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
: runtimeCtx.isVSCode
? 'bg-background'
: 'bg-sidebar',
isResizing ? '' : 'transition-[width,min-width] duration-200 ease-[cubic-bezier(0.25,0.1,0.25,1)]'
)}
style={{
width: `${navWidth}px`,
minWidth: `${navWidth}px`,
width: `${SETTINGS_NAV_WIDTH}px`,
minWidth: `${SETTINGS_NAV_WIDTH}px`,
borderColor: 'var(--interactive-border)',
}}
>
<div
className={cn(
'absolute right-0 top-0 z-20 h-full w-[6px] -mr-[3px] cursor-col-resize',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]',
isResizing ? 'bg-primary/30' : 'bg-transparent hover:bg-primary/20'
)}
tabIndex={0}
onPointerDown={handlePointerDown}
onKeyDown={handleResizeKeyDown}
role="separator"
aria-orientation="vertical"
aria-valuemin={SETTINGS_NAV_MIN_WIDTH}
aria-valuemax={SETTINGS_NAV_MAX_WIDTH}
aria-valuenow={navWidth}
aria-label={t('settings.view.actions.resizeNavigation')}
/>
<ErrorBoundary>
{renderSettingsNav()}
</ErrorBoundary>
@@ -3,6 +3,7 @@ import { toast } from '@/components/ui';
import { Button } from '@/components/ui/button';
import { copyTextToClipboard } from '@/lib/clipboard';
import { cn } from '@/lib/utils';
import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { useAgentGroupsStore, type AgentGroup, type AgentGroupSession } from '@/stores/useAgentGroupsStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
@@ -179,9 +180,9 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
<div className="flex-1 min-w-0">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="w-full justify-between h-10 px-3"
<button
type="button"
className={cn(dropdownTriggerVariants({ size: 'default' }), 'w-full')}
>
<div className="flex items-center gap-2 min-w-0">
{selectedSession && (
@@ -203,7 +204,7 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
)}
</div>
<Icon name="arrow-down-s" className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
</Button>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[var(--anchor-width)]">
{group.sessions.map((session) => (
@@ -25,6 +25,7 @@ import {
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Icon } from "@/components/icon/Icon";
import { cn } from '@/lib/utils';
import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger';
import { useI18n } from '@/lib/i18n';
type OperationType = 'merge' | 'rebase';
@@ -300,12 +301,15 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
</p>
<DropdownMenu open={branchDropdownOpen} onOpenChange={setBranchDropdownOpen} modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="lg" className="w-full justify-between">
<button
type="button"
className={cn(dropdownTriggerVariants({ size: 'default' }), 'w-full')}
>
<span className={cn('truncate', !selectedBranch && 'text-muted-foreground')}>
{selectedBranch || t('gitView.branch.selectBranch')}
</span>
<Icon name="arrow-down-s" className="size-4 opacity-60 shrink-0" />
</Button>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
@@ -228,7 +228,7 @@ export const HistorySection: React.FC<HistorySectionProps> = ({
>
<SelectTrigger
size="sm"
className="data-[size=sm]:h-auto h-7 min-h-7 w-auto justify-between px-2 py-0"
className="w-auto"
disabled={isLogLoading}
>
<SelectValue placeholder={t('gitView.history.commitsPlaceholder')} />
@@ -5,6 +5,7 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger';
import {
Command,
CommandEmpty,
@@ -361,11 +362,14 @@ export const IntegrateCommitsSection: React.FC<{
<DropdownMenu open={branchDropdownOpen} onOpenChange={setBranchDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-1.5">
<button
type="button"
className={dropdownTriggerVariants({ size: 'default' })}
>
{t('gitView.integrate.target')}
<span className="max-w-[160px] truncate font-mono text-xs text-muted-foreground">{targetBranch}</span>
<Icon name="arrow-down-s" className="size-4 opacity-60" />
</Button>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
@@ -1697,7 +1697,7 @@ export const PullRequestSection: React.FC<{
onValueChange={(value) => setMergeMethod(value as MergeMethod)}
disabled={isMerging || pr.state !== 'open'}
>
<SelectTrigger size="lg" className="h-7 w-auto min-w-0">
<SelectTrigger size="sm" className="w-auto min-w-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -1761,7 +1761,7 @@ export const PullRequestSection: React.FC<{
<div className="typography-micro text-muted-foreground">{t('gitView.pr.field.baseBranch')}</div>
{availableBaseBranches.length > 0 ? (
<Select value={targetBaseBranch} onValueChange={setTargetBaseBranch}>
<SelectTrigger className="h-9">
<SelectTrigger size="lg">
<SelectValue placeholder={t('gitView.pr.placeholder.selectBaseBranch')} />
</SelectTrigger>
<SelectContent>