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
+1
View File
@@ -73,6 +73,7 @@ import { useEdgeSwipeSessionSwitch } from './useEdgeSwipeSessionSwitch';
import { useNativePushRegistration } from './useNativePushRegistration';
const MOBILE_SETTINGS_PAGES = [
'general',
'appearance',
'chat',
'notifications',
@@ -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>
+16 -10
View File
@@ -662,16 +662,22 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
);
const setThemeModeHandler = useCallback((mode: ThemeMode) => {
setPreferences((prev) => {
if (prev.themeMode === mode) {
return prev;
}
return {
...prev,
themeMode: mode,
};
});
}, []);
if (preferences.themeMode === mode) {
return;
}
setPreferences((prev) => ({
...prev,
themeMode: mode,
}));
if (!receivesParentThemeSync) {
void updateDesktopSettings({
themeVariant: mode === 'system' ? currentTheme.metadata.variant : mode,
useSystemTheme: mode === 'system',
});
}
}, [currentTheme.metadata.variant, preferences.themeMode, receivesParentThemeSync]);
const setSystemPreferenceHandler = useCallback(
(use: boolean) => {
@@ -47,6 +47,10 @@ export const settingsDict = {
'settings.view.search.aria': 'Search settings',
'settings.view.search.clear': 'Clear settings search',
'settings.view.search.noResults': 'No matching settings',
'settings.view.nav.group.general': 'OpenChamber',
'settings.view.nav.group.projects': 'Workspace',
'settings.view.nav.group.opencode': 'OpenCode',
'settings.view.nav.group.content': 'Library',
'settings.page.projects.title': 'Projects',
'settings.page.remoteInstances.title': 'Remote Instances',
'settings.page.providers.title': 'Providers',
@@ -59,13 +63,22 @@ export const settingsDict = {
'settings.page.skillsCatalog.title': 'Skills Catalog',
'settings.page.git.title': 'Git',
'settings.page.appearance.title': 'Appearance',
'settings.page.appearance.description': 'Customize how OpenChamber looks and feels.',
'settings.page.chat.title': 'Chat',
'settings.page.chat.description': 'Configure how messages and tools are displayed.',
'settings.page.shortcuts.title': 'Shortcuts',
'settings.page.shortcuts.description': 'Customize keyboard shortcuts.',
'settings.page.general.title': 'General',
'settings.page.general.description': 'App startup, security, connection, and privacy.',
'settings.page.sessions.title': 'Sessions',
'settings.page.sessions.description': 'Set defaults and retention for sessions.',
'settings.page.magicPrompts.title': 'Magic Prompts',
'settings.page.notifications.title': 'Notifications',
'settings.page.notifications.description': 'Choose when and how you get notified.',
'settings.page.voice.title': 'Voice',
'settings.page.tunnel.title': 'Remote Tunnel',
'settings.page.voice.description': 'Configure speech and dictation.',
'settings.page.tunnel.title': 'External Tunnel',
'settings.page.tunnel.description': 'Expose this instance over a remote tunnel.',
'settings.page.about.title': 'About',
'settings.page.snippets.title': 'Snippets',
'settings.snippets.sidebar.title': 'Snippets',
@@ -123,7 +136,7 @@ export const settingsDict = {
'settings.promptTemplates.page.toast.created': 'Template created',
'settings.promptTemplates.page.toast.createFailed': 'Failed to create template',
'settings.promptTemplates.page.toast.saveUnexpectedError': 'An unexpected error occurred while saving',
'settings.openchamber.tunnel.title': 'Remote Tunnel',
'settings.openchamber.tunnel.title': 'External Tunnel',
'settings.openchamber.tunnel.description': 'Configure secure remote access with quick links or your own managed remote Cloudflare tunnel.',
'settings.openchamber.tunnel.note.serverSideEnforced': 'Secure tunnel access is enforced server-side.',
'settings.openchamber.tunnel.note.connectLinksOneTime': 'Connect links are one-time and are revoked when tunnel stops or connect-link TTL expires.',
@@ -399,6 +412,7 @@ export const settingsDict = {
'settings.remoteInstances.page.logsDialog.selectedInstanceFallback': 'Selected instance',
'settings.remoteInstances.page.patternDialog.descriptionWithHost': '{host} requires a concrete destination.',
'settings.remoteInstances.page.patternDialog.description': 'Enter destination.',
'settings.common.infoAria': 'More information',
'settings.common.actions.cancel': 'Cancel',
'settings.common.actions.create': 'Create',
'settings.common.actions.delete': 'Delete',
@@ -409,6 +423,8 @@ export const settingsDict = {
'settings.common.actions.copyAll': 'Copy all',
'settings.common.actions.clear': 'Clear',
'settings.common.actions.saving': 'Saving...',
'settings.common.status.saved': 'Saved',
'settings.common.status.saveFailed': 'Save failed',
'settings.common.actions.saveChanges': 'Save Changes',
'settings.common.scope.global': 'Global',
'settings.common.scope.project': 'Project',
@@ -493,6 +509,33 @@ export const settingsDict = {
'settings.agents.page.section.identityRole': 'Identity & Role',
'settings.agents.page.section.modelParameters': 'Model & Parameters',
'settings.agents.page.section.systemPrompt': 'System Prompt',
'settings.agents.page.permissionsEditor.sectionInfo': 'Edits this agent\'s own permission rules exactly as stored in its config. "Inherit" keeps a key unset so global config and defaults apply.',
'settings.agents.page.permissionsEditor.defaultLabel': 'Default for all tools',
'settings.agents.page.permissionsEditor.defaultAria': 'Default permission for all tools',
'settings.agents.page.permissionsEditor.defaultInfo': 'The agent-level fallback (the * key). Applies to any tool without its own rule.',
'settings.agents.page.permissionsEditor.effectiveHint': '→ {action}',
'settings.agents.page.permissionsEditor.ruleCount': '{count} rules',
'settings.agents.page.permissionsEditor.keyAria': 'Permission for {key}',
'settings.agents.page.permissionsEditor.patternPlaceholder': 'Pattern, e.g. rm -rf * or /path/**',
'settings.agents.page.permissionsEditor.patternActionAria': 'Action for {key} pattern',
'settings.agents.page.permissionsEditor.customKeyPlaceholder': 'Custom permission key…',
'settings.agents.page.permissionsEditor.sessionRulesTitle': 'Session-granted (not saved)',
'settings.agents.page.permissionsEditor.sessionRulesInfo': 'Rules granted during live sessions (e.g. "always allow" answers). They apply right now but are not part of the saved config and disappear on restart.',
'settings.agents.page.permissionsEditor.action.default': 'Default',
'settings.agents.page.permissionsEditor.action.inherit': 'Inherit',
'settings.agents.page.permissionsEditor.action.allow': 'Allow',
'settings.agents.page.permissionsEditor.action.ask': 'Ask',
'settings.agents.page.permissionsEditor.action.deny': 'Deny',
'settings.agents.page.permissionsEditor.actions.save': 'Save permissions',
'settings.agents.page.permissionsEditor.actions.discard': 'Discard',
'settings.agents.page.permissionsEditor.actions.addRule': 'Add rule',
'settings.agents.page.permissionsEditor.actions.addKey': 'Add key',
'settings.agents.page.permissionsEditor.actions.removeRuleAria': 'Remove rule',
'settings.agents.page.permissionsEditor.actions.retry': 'Retry',
'settings.agents.page.permissionsEditor.state.loadFailed': 'Could not load the agent permission config.',
'settings.agents.page.permissionsEditor.toast.saved': 'Permissions saved',
'settings.agents.page.permissionsEditor.toast.savedRestartRequired': 'Permissions saved — restart the OpenCode server to apply',
'settings.agents.page.permissionsEditor.toast.saveFailed': 'Failed to save permissions',
'settings.agents.page.section.toolPermissions': 'Tool Permissions',
'settings.agents.page.field.agentName': 'Agent Name',
'settings.agents.page.field.agentNamePlaceholder': 'agent-name',
@@ -528,6 +571,7 @@ export const settingsDict = {
'settings.agents.page.permissions.permissionPlaceholder': 'Permission...',
'settings.agents.page.permissions.patternPlaceholder': 'Pattern (e.g. *)',
'settings.page.behavior.title': 'Behavior',
'settings.page.behavior.description': 'Guide how the agent responds.',
'settings.behavior.page.title': 'Behavior',
'settings.behavior.page.warning.title': 'Global rules are combined with project rules',
'settings.behavior.page.warning.description': 'Changes made here update {path}. OpenCode also includes any project-level AGENTS.md rules when they exist.',
@@ -913,6 +957,8 @@ export const settingsDict = {
'settings.openchamber.desktopNetwork.field.allowLanAccessDescription': 'Restarts the app so phones, tablets, and other computers on your Wi-Fi can open it.',
'settings.openchamber.desktopNetwork.field.warning': 'Warning: while enabled, the app is reachable by anyone on the same local network.',
'settings.openchamber.desktopNetwork.field.passwordRequiredWarning': 'LAN access requires a Desktop UI Password. Until one is set, the desktop app starts local-only.',
'settings.openchamber.desktopPassword.actions.showPassword': 'Show password',
'settings.openchamber.desktopPassword.actions.hidePassword': 'Hide password',
'settings.openchamber.desktopPassword.field.password': 'Desktop UI Password',
'settings.openchamber.desktopPassword.field.passwordPlaceholder': 'No password required',
'settings.openchamber.desktopPassword.field.passwordDescription': 'OpenChamber asks after restart, then when the login session expires: after 12 hours, or 7 days with Trust this device. Leave empty to disable login.',
@@ -1618,6 +1664,7 @@ export const settingsDict = {
'settings.voice.page.section.playbackAndSummary': 'Playback',
'settings.voice.page.field.provider': 'Provider',
'settings.voice.page.provider.browser': 'Browser',
'settings.voice.page.provider.openai': 'OpenAI',
'settings.voice.page.provider.custom': 'Custom',
'settings.voice.page.provider.say': 'Say',
'settings.voice.page.provider.server': 'Server',
@@ -1697,18 +1744,24 @@ export const settingsDict = {
'settings.voice.page.field.ttsInputModeRaw': 'Raw Markdown',
'settings.voice.page.field.ttsInputModeSummarized': 'summarized',
'settings.openchamber.visual.section.colorMode': 'Color Mode',
'settings.openchamber.visual.section.colorModeAndTheme': 'Color mode & Theme',
'settings.openchamber.visual.section.mobileLayout': 'Mobile Layout',
'settings.openchamber.visual.option.mobileLayout.default': 'Old',
'settings.openchamber.visual.option.mobileLayout.new': 'New',
'settings.openchamber.visual.section.localization': 'Localization',
'settings.openchamber.visual.section.spacingAndLayout': 'Spacing & Layout',
'settings.openchamber.visual.section.densityAndType': 'Density & type',
'settings.openchamber.visual.section.appInstall': 'App install',
'settings.openchamber.visual.section.navigation': 'Navigation',
'settings.openchamber.visual.section.chatRenderMode': 'Chat Render Mode',
'settings.openchamber.visual.section.chatRenderModeAria': 'Chat render mode',
'settings.openchamber.visual.section.chatDisplay': 'Display',
'settings.openchamber.visual.section.chatMessageOptions': 'Message options',
'settings.openchamber.visual.section.chatFeatures': 'Features',
'settings.openchamber.visual.section.messageStreamTransport': 'Message Stream Transport',
'settings.openchamber.visual.section.activityDefault': 'Activity Default',
'settings.openchamber.visual.section.activityDefaultAria': 'Activity default mode',
'settings.openchamber.visual.section.showToolsOpenedByDefault': 'Show tools opened by default:',
'settings.openchamber.visual.section.showToolsOpenedByDefault': 'Show tools opened by default',
'settings.openchamber.visual.section.sessionAssistance': 'Session Assistance',
'settings.openchamber.visual.section.reasoning': 'Reasoning',
'settings.openchamber.visual.section.messageAppearance': 'Message Appearance',
@@ -1839,8 +1892,11 @@ export const settingsDict = {
'settings.openchamber.visual.field.sendAnonymousUsageReports': 'Send anonymous usage reports',
'settings.openchamber.visual.field.sendAnonymousUsageReportsHint': 'Helps us understand which app versions are actively used so we can prioritize improvements. Only app version, platform, and runtime are collected - no personal data or code.',
'settings.openchamber.visual.option.themeMode.system': 'System',
'settings.openchamber.visual.option.themeMode.system.description': 'Follow system setting',
'settings.openchamber.visual.option.themeMode.light': 'Light',
'settings.openchamber.visual.option.themeMode.light.description': 'Always use light appearance',
'settings.openchamber.visual.option.themeMode.dark': 'Dark',
'settings.openchamber.visual.option.themeMode.dark.description': 'Always use dark appearance',
'settings.openchamber.visual.option.diffLayout.dynamic.label': 'Dynamic',
'settings.openchamber.visual.option.diffLayout.dynamic.description': 'New inline, modified side-by-side.',
'settings.openchamber.visual.option.diffLayout.inline.label': 'Always inline',
@@ -47,6 +47,10 @@ export const settingsDict = {
"settings.view.search.aria": "Buscar configuración",
"settings.view.search.clear": "Borrar búsqueda de configuración",
"settings.view.search.noResults": "No hay configuraciones coincidentes",
"settings.view.nav.group.general": "OpenChamber",
"settings.view.nav.group.projects": "Espacio de trabajo",
"settings.view.nav.group.opencode": "OpenCode",
"settings.view.nav.group.content": "Biblioteca",
"settings.page.projects.title": "Proyectos",
"settings.page.remoteInstances.title": "Instancias remotas",
"settings.page.providers.title": "Proveedores",
@@ -59,13 +63,22 @@ export const settingsDict = {
"settings.page.skillsCatalog.title": "Catálogo de habilidades",
"settings.page.git.title": "Git",
"settings.page.appearance.title": "Apariencia",
"settings.page.appearance.description": "Personaliza el aspecto y la sensación de OpenChamber.",
"settings.page.chat.title": "Chat",
"settings.page.chat.description": "Configura cómo se muestran los mensajes y las herramientas.",
"settings.page.shortcuts.title": "Atajos",
"settings.page.shortcuts.description": "Personaliza los atajos de teclado.",
"settings.page.general.title": "General",
"settings.page.general.description": "Inicio de la aplicación, seguridad, conexión y privacidad.",
"settings.page.sessions.title": "Sesiones",
"settings.page.sessions.description": "Define valores predeterminados y retención de sesiones.",
"settings.page.magicPrompts.title": "Prompts mágicos",
"settings.page.notifications.title": "Notificaciones",
"settings.page.notifications.description": "Elige cuándo y cómo recibir notificaciones.",
"settings.page.voice.title": "Voz",
"settings.page.tunnel.title": "Túnel remoto",
"settings.page.voice.description": "Configura la voz y el dictado.",
"settings.page.tunnel.title": "Túnel externo",
"settings.page.tunnel.description": "Expón esta instancia a través de un túnel remoto.",
"settings.page.about.title": "Acerca de",
"settings.page.snippets.title": "Snippets",
"settings.snippets.sidebar.title": "Snippets",
@@ -90,7 +103,7 @@ export const settingsDict = {
"settings.snippets.page.toast.contentRequired": "El contenido del snippet es obligatorio",
"settings.snippets.page.toast.saveFailed": "No se pudo guardar el snippet",
"settings.snippets.page.toast.saved": "Snippet guardado",
"settings.openchamber.tunnel.title": "Túnel remoto",
"settings.openchamber.tunnel.title": "Túnel externo",
"settings.openchamber.tunnel.description": "Configura el acceso remoto seguro con enlaces rápidos o tu propio túnel gestionado de Cloudflare.",
"settings.openchamber.tunnel.note.serverSideEnforced": "El acceso seguro al túnel se aplica en el servidor.",
"settings.openchamber.tunnel.note.connectLinksOneTime": "Los enlaces de conexión son de un solo uso: se revocan cuando el túnel se detiene o vence el TTL del enlace.",
@@ -366,6 +379,7 @@ export const settingsDict = {
"settings.remoteInstances.page.logsDialog.selectedInstanceFallback": "Instancia seleccionada",
"settings.remoteInstances.page.patternDialog.descriptionWithHost": "{host} requiere un destino concreto.",
"settings.remoteInstances.page.patternDialog.description": "Introduce destino.",
"settings.common.infoAria": "Más información",
"settings.common.actions.cancel": "Cancelar",
"settings.common.actions.create": "Crear",
"settings.common.actions.delete": "Eliminar",
@@ -376,6 +390,8 @@ export const settingsDict = {
"settings.common.actions.copyAll": "Copiar todo",
"settings.common.actions.clear": "Limpiar",
"settings.common.actions.saving": "Guardando...",
'settings.common.status.saved': 'Guardado',
'settings.common.status.saveFailed': 'Error al guardar',
"settings.common.actions.saveChanges": "Guardar cambios",
"settings.common.scope.global": "Global",
"settings.common.scope.project": "Proyecto",
@@ -460,6 +476,33 @@ export const settingsDict = {
"settings.agents.page.section.identityRole": "Identidad y rol",
"settings.agents.page.section.modelParameters": "Modelo y parámetros",
"settings.agents.page.section.systemPrompt": "Prompt del sistema",
"settings.agents.page.permissionsEditor.sectionInfo": "Edita las reglas de permisos propias de este agente tal como están guardadas en su config. «Heredar» deja la clave sin definir y aplican la config global y los valores por defecto.",
"settings.agents.page.permissionsEditor.defaultLabel": "Predeterminado para todas las herramientas",
"settings.agents.page.permissionsEditor.defaultAria": "Permiso predeterminado para todas las herramientas",
"settings.agents.page.permissionsEditor.defaultInfo": "El valor de reserva del agente (la clave *). Aplica a cualquier herramienta sin regla propia.",
"settings.agents.page.permissionsEditor.effectiveHint": "→ {action}",
"settings.agents.page.permissionsEditor.ruleCount": "{count} reglas",
"settings.agents.page.permissionsEditor.keyAria": "Permiso para {key}",
"settings.agents.page.permissionsEditor.patternPlaceholder": "Patrón, p. ej. rm -rf * o /path/**",
"settings.agents.page.permissionsEditor.patternActionAria": "Acción para el patrón de {key}",
"settings.agents.page.permissionsEditor.customKeyPlaceholder": "Clave de permiso personalizada…",
"settings.agents.page.permissionsEditor.sessionRulesTitle": "Concedidos en sesión (no guardados)",
"settings.agents.page.permissionsEditor.sessionRulesInfo": "Reglas concedidas durante sesiones en vivo (respuestas de «permitir siempre»). Aplican ahora, pero no forman parte de la config guardada y desaparecen al reiniciar.",
"settings.agents.page.permissionsEditor.action.default": "Predeterminado",
"settings.agents.page.permissionsEditor.action.inherit": "Heredar",
"settings.agents.page.permissionsEditor.action.allow": "Permitir",
"settings.agents.page.permissionsEditor.action.ask": "Preguntar",
"settings.agents.page.permissionsEditor.action.deny": "Denegar",
"settings.agents.page.permissionsEditor.actions.save": "Guardar permisos",
"settings.agents.page.permissionsEditor.actions.discard": "Descartar",
"settings.agents.page.permissionsEditor.actions.addRule": "Añadir regla",
"settings.agents.page.permissionsEditor.actions.addKey": "Añadir clave",
"settings.agents.page.permissionsEditor.actions.removeRuleAria": "Eliminar regla",
"settings.agents.page.permissionsEditor.actions.retry": "Reintentar",
"settings.agents.page.permissionsEditor.state.loadFailed": "No se pudo cargar la config de permisos del agente.",
"settings.agents.page.permissionsEditor.toast.saved": "Permisos guardados",
"settings.agents.page.permissionsEditor.toast.savedRestartRequired": "Permisos guardados: reinicia el servidor de OpenCode para aplicarlos",
"settings.agents.page.permissionsEditor.toast.saveFailed": "No se pudieron guardar los permisos",
"settings.agents.page.section.toolPermissions": "Permisos de herramientas",
"settings.agents.page.field.agentName": "Nombre del agente",
"settings.agents.page.field.agentNamePlaceholder": "nombre-agente",
@@ -495,6 +538,7 @@ export const settingsDict = {
"settings.agents.page.permissions.permissionPlaceholder": "Permiso...",
"settings.agents.page.permissions.patternPlaceholder": "Patrón (ej. *)",
"settings.page.behavior.title": "Comportamiento",
'settings.page.behavior.description': 'Guía cómo responde el agente.',
"settings.behavior.page.title": "Comportamiento",
"settings.behavior.page.warning.title": "Las reglas globales se combinan con las reglas del proyecto",
"settings.behavior.page.warning.description": "Los cambios realizados aquí actualizan {path}. OpenCode también incluye cualquier AGENTS.md a nivel de proyecto cuando existe.",
@@ -504,7 +548,7 @@ export const settingsDict = {
"settings.behavior.page.responseStyle.tooltip": "Cuando está habilitado, estas instrucciones guían cómo responde el asistente en cada conversación nueva. Se envían con tu primer mensaje y no cambian tus reglas globales de AGENTS.md.",
"settings.behavior.page.responseStyle.enableAria": "Habilitar instrucciones de estilo de respuesta",
"settings.behavior.page.responseStyle.enable": "Agregar instrucciones de estilo de respuesta a conversaciones nuevas",
"settings.behavior.page.responseStyle.preset": "Preset",
"settings.behavior.page.responseStyle.preset": "Preajuste",
"settings.behavior.page.responseStyle.option.concise": "Conciso",
"settings.behavior.page.responseStyle.option.detailed": "Detallado",
"settings.behavior.page.responseStyle.option.mentor": "Mentor",
@@ -880,6 +924,8 @@ export const settingsDict = {
"settings.openchamber.desktopNetwork.field.allowLanAccessDescription": "Reinicia la aplicación para que los teléfonos, tablets y otros ordenadores en tu Wi-Fi puedan abrirla.",
"settings.openchamber.desktopNetwork.field.warning": "Advertencia: mientras esté habilitado, la aplicación es accesible por cualquiera en la misma red local.",
"settings.openchamber.desktopNetwork.field.passwordRequiredWarning": "El acceso LAN requiere una contraseña de UI de escritorio. Hasta que se configure, la app de escritorio se inicia solo localmente.",
"settings.openchamber.desktopPassword.actions.showPassword": "Mostrar contraseña",
"settings.openchamber.desktopPassword.actions.hidePassword": "Ocultar contraseña",
"settings.openchamber.desktopPassword.field.password": "Contraseña de UI de escritorio",
"settings.openchamber.desktopPassword.field.passwordPlaceholder": "No se requiere contraseña",
"settings.openchamber.desktopPassword.field.passwordDescription": "OpenChamber la pide después del reinicio y luego cuando vence la sesión: tras 12 horas, o 7 días con Confiar en este dispositivo. Déjalo vacío para desactivar el inicio de sesión.",
@@ -1585,6 +1631,7 @@ export const settingsDict = {
"settings.voice.page.section.playbackAndSummary": "Reproducción",
"settings.voice.page.field.provider": "Proveedor",
"settings.voice.page.provider.browser": "Navegador",
"settings.voice.page.provider.openai": "OpenAI",
"settings.voice.page.provider.custom": "Personalizado",
"settings.voice.page.provider.say": "Decir",
"settings.voice.page.provider.server": "Servidor",
@@ -1664,18 +1711,24 @@ export const settingsDict = {
"settings.voice.page.field.ttsInputModeRaw": "Markdown sin procesar",
"settings.voice.page.field.ttsInputModeSummarized": "resumido",
"settings.openchamber.visual.section.colorMode": "Modo de color",
"settings.openchamber.visual.section.colorModeAndTheme": "Modo de color y tema",
"settings.openchamber.visual.section.mobileLayout": "Diseño móvil",
"settings.openchamber.visual.option.mobileLayout.default": "Anterior",
"settings.openchamber.visual.option.mobileLayout.new": "Nuevo",
"settings.openchamber.visual.section.localization": "Localización",
"settings.openchamber.visual.section.spacingAndLayout": "Espaciado y diseño",
"settings.openchamber.visual.section.densityAndType": "Densidad y tipografía",
"settings.openchamber.visual.section.appInstall": "Instalación de la app",
"settings.openchamber.visual.section.navigation": "Navegación",
"settings.openchamber.visual.section.chatRenderMode": "Modo de renderizado de chat",
"settings.openchamber.visual.section.chatRenderModeAria": "Modo de renderizado de chat",
"settings.openchamber.visual.section.chatDisplay": "Visualización",
"settings.openchamber.visual.section.chatMessageOptions": "Opciones de mensajes",
"settings.openchamber.visual.section.chatFeatures": "Funciones",
"settings.openchamber.visual.section.messageStreamTransport": "Transporte del flujo de mensajes",
"settings.openchamber.visual.section.activityDefault": "Modo predeterminado de actividad",
"settings.openchamber.visual.section.activityDefaultAria": "Modo predeterminado de actividad",
"settings.openchamber.visual.section.showToolsOpenedByDefault": "Mostrar herramientas abiertas por defecto:",
"settings.openchamber.visual.section.showToolsOpenedByDefault": "Mostrar herramientas abiertas por defecto",
"settings.openchamber.visual.section.sessionAssistance": "Asistencia de sesión",
"settings.openchamber.visual.section.reasoning": "Razonamiento",
"settings.openchamber.visual.section.messageAppearance": "Apariencia de los mensajes",
@@ -1806,8 +1859,11 @@ export const settingsDict = {
"settings.openchamber.visual.field.sendAnonymousUsageReports": "Enviar informes anónimos de uso",
"settings.openchamber.visual.field.sendAnonymousUsageReportsHint": "Nos ayuda a entender qué versiones de la aplicación se usan activamente para priorizar mejoras. Solo se recopilan la versión de la aplicación, la plataforma y el entorno de ejecución ; no se recopilan datos personales ni código.",
"settings.openchamber.visual.option.themeMode.system": "Sistema",
"settings.openchamber.visual.option.themeMode.system.description": "Seguir la configuración del sistema",
"settings.openchamber.visual.option.themeMode.light": "Claro",
"settings.openchamber.visual.option.themeMode.light.description": "Usar siempre la apariencia clara",
"settings.openchamber.visual.option.themeMode.dark": "Oscuro",
"settings.openchamber.visual.option.themeMode.dark.description": "Usar siempre la apariencia oscura",
"settings.openchamber.visual.option.diffLayout.dynamic.label": "Dinámico",
"settings.openchamber.visual.option.diffLayout.dynamic.description": "Nuevo en línea, modificado lado a lado.",
"settings.openchamber.visual.option.diffLayout.inline.label": "Siempre en línea",
@@ -55,13 +55,22 @@ export const settingsDict = {
'settings.page.skillsCatalog.title': 'Catalogue de skills',
'settings.page.git.title': 'Git',
'settings.page.appearance.title': 'Apparence',
'settings.page.appearance.description': 'Personnalisez lapparence et le ressenti dOpenChamber.',
'settings.page.chat.title': 'Chat',
'settings.page.chat.description': 'Configurez laffichage des messages et des outils.',
'settings.page.shortcuts.title': 'Raccourcis',
'settings.page.shortcuts.description': 'Personnalisez les raccourcis clavier.',
'settings.page.general.title': 'Général',
'settings.page.general.description': 'Démarrage de l\'application, sécurité, connexion et confidentialité.',
'settings.page.sessions.title': 'Sessions',
'settings.page.sessions.description': 'Définissez les valeurs par défaut et la conservation des sessions.',
'settings.page.magicPrompts.title': 'Prompts magiques',
'settings.page.notifications.title': 'Notifications',
'settings.page.notifications.description': 'Choisissez quand et comment être notifié.',
'settings.page.voice.title': 'Voix',
'settings.page.tunnel.title': 'Tunnel à distance',
'settings.page.voice.description': 'Configurez la synthèse vocale et la dictée.',
'settings.page.tunnel.title': 'Tunnel externe',
'settings.page.tunnel.description': 'Exposez cette instance via un tunnel distant.',
'settings.page.snippets.title': 'Extraits',
'settings.snippets.sidebar.title': 'Extraits',
'settings.snippets.sidebar.total': 'Total : {count}',
@@ -118,7 +127,7 @@ export const settingsDict = {
'settings.promptTemplates.page.toast.created': 'Modèle créé',
'settings.promptTemplates.page.toast.createFailed': 'Échec de la création du modèle',
'settings.promptTemplates.page.toast.saveUnexpectedError': 'Une erreur inattendue s\'est produite lors de l\'enregistrement',
'settings.openchamber.tunnel.title': 'Tunnel à distance',
'settings.openchamber.tunnel.title': 'Tunnel externe',
'settings.openchamber.tunnel.description': 'Configurez un accès à distance sécurisé avec des liens rapides ou votre propre tunnel Cloudflare distant géré.',
'settings.openchamber.tunnel.note.serverSideEnforced': 'L\'accès au tunnel sécurisé est appliqué côté serveur.',
'settings.openchamber.tunnel.note.connectLinksOneTime': 'Les liens de connexion sont uniques et sont révoqués lorsque le tunnel s\'arrête ou lorsque la durée de vie du lien de connexion expire.',
@@ -293,6 +302,7 @@ export const settingsDict = {
'settings.remoteInstances.page.logsDialog.selectedInstanceFallback': 'Instance sélectionnée',
'settings.remoteInstances.page.patternDialog.descriptionWithHost': '{host} nécessite une destination concrète.',
'settings.remoteInstances.page.patternDialog.description': 'Entrez la destination.',
'settings.common.infoAria': 'Plus dinformations',
'settings.common.actions.cancel': 'Annuler',
'settings.common.actions.create': 'Créer',
'settings.common.actions.delete': 'Supprimer',
@@ -303,6 +313,8 @@ export const settingsDict = {
'settings.common.actions.copyAll': 'Copier tout',
'settings.common.actions.clear': 'Effacer',
'settings.common.actions.saving': 'Enregistrement...',
'settings.common.status.saved': 'Enregistré',
'settings.common.status.saveFailed': 'Échec de lenregistrement',
'settings.common.actions.saveChanges': 'Enregistrer les modifications',
'settings.common.scope.global': 'Mondial',
'settings.common.scope.project': 'Projet',
@@ -385,6 +397,33 @@ export const settingsDict = {
'settings.agents.page.section.identityRole': 'Identité et rôle',
'settings.agents.page.section.modelParameters': 'Modèle et paramètres',
'settings.agents.page.section.systemPrompt': 'Prompt système',
'settings.agents.page.permissionsEditor.sectionInfo': 'Modifie les règles de permissions propres à cet agent, telles qu\'enregistrées dans sa config. « Hériter » laisse la clé non définie : la config globale et les valeurs par défaut s\'appliquent.',
'settings.agents.page.permissionsEditor.defaultLabel': 'Défaut pour tous les outils',
'settings.agents.page.permissionsEditor.defaultAria': 'Permission par défaut pour tous les outils',
'settings.agents.page.permissionsEditor.defaultInfo': 'Le repli au niveau de l\'agent (la clé *). S\'applique à tout outil sans règle propre.',
'settings.agents.page.permissionsEditor.effectiveHint': '→ {action}',
'settings.agents.page.permissionsEditor.ruleCount': '{count} règles',
'settings.agents.page.permissionsEditor.keyAria': 'Permission pour {key}',
'settings.agents.page.permissionsEditor.patternPlaceholder': 'Motif, p. ex. rm -rf * ou /path/**',
'settings.agents.page.permissionsEditor.patternActionAria': 'Action pour le motif de {key}',
'settings.agents.page.permissionsEditor.customKeyPlaceholder': 'Clé de permission personnalisée…',
'settings.agents.page.permissionsEditor.sessionRulesTitle': 'Accordées en session (non enregistrées)',
'settings.agents.page.permissionsEditor.sessionRulesInfo': 'Règles accordées pendant des sessions (réponses « toujours autoriser »). Elles s\'appliquent maintenant mais ne font pas partie de la config enregistrée et disparaissent au redémarrage.',
'settings.agents.page.permissionsEditor.action.default': 'Par défaut',
'settings.agents.page.permissionsEditor.action.inherit': 'Hériter',
'settings.agents.page.permissionsEditor.action.allow': 'Autoriser',
'settings.agents.page.permissionsEditor.action.ask': 'Demander',
'settings.agents.page.permissionsEditor.action.deny': 'Refuser',
'settings.agents.page.permissionsEditor.actions.save': 'Enregistrer les permissions',
'settings.agents.page.permissionsEditor.actions.discard': 'Annuler',
'settings.agents.page.permissionsEditor.actions.addRule': 'Ajouter une règle',
'settings.agents.page.permissionsEditor.actions.addKey': 'Ajouter une clé',
'settings.agents.page.permissionsEditor.actions.removeRuleAria': 'Supprimer la règle',
'settings.agents.page.permissionsEditor.actions.retry': 'Réessayer',
'settings.agents.page.permissionsEditor.state.loadFailed': 'Impossible de charger la config de permissions de l\'agent.',
'settings.agents.page.permissionsEditor.toast.saved': 'Permissions enregistrées',
'settings.agents.page.permissionsEditor.toast.savedRestartRequired': 'Permissions enregistrées — redémarrez le serveur OpenCode pour les appliquer',
'settings.agents.page.permissionsEditor.toast.saveFailed': 'Échec de l\'enregistrement des permissions',
'settings.agents.page.section.toolPermissions': 'Autorisations des outils',
'settings.agents.page.field.agentName': 'Nom de l\'agent',
'settings.agents.page.field.agentNamePlaceholder': 'nom-agent',
@@ -420,6 +459,7 @@ export const settingsDict = {
'settings.agents.page.permissions.permissionPlaceholder': 'Autorisation...',
'settings.agents.page.permissions.patternPlaceholder': 'Modèle (e.g. *)',
'settings.page.behavior.title': 'Comportement',
'settings.page.behavior.description': 'Guidez la façon dont l\'agent répond.',
'settings.behavior.page.title': 'Comportement',
'settings.behavior.page.warning.title': 'Les règles globales sont combinées avec les règles du projet',
'settings.behavior.page.warning.description': 'Les modifications apportées ici mettent à jour {path}. OpenCode inclut également toutes les règles AGENTS.md au niveau du projet lorsqu\'elles existent.',
@@ -805,6 +845,8 @@ export const settingsDict = {
'settings.openchamber.desktopNetwork.field.allowLanAccessDescription': 'Redémarre l\'application afin que les téléphones, tablettes et autres ordinateurs connectés à votre réseau Wi-Fi puissent l\'ouvrir.',
'settings.openchamber.desktopNetwork.field.warning': 'Attention : lorsqu\'elle est activée, l\'application est accessible à toute personne se trouvant sur le même réseau local.',
'settings.openchamber.desktopNetwork.field.passwordRequiredWarning': 'L\'accès LAN nécessite un mot de passe de l\'interface utilisateur du bureau. Tant qu\'il n\'est pas défini, l\'application de bureau démarre en accès local uniquement.',
'settings.openchamber.desktopPassword.actions.showPassword': 'Afficher le mot de passe',
'settings.openchamber.desktopPassword.actions.hidePassword': 'Masquer le mot de passe',
'settings.openchamber.desktopPassword.field.password': 'Mot de passe de l\'interface utilisateur du bureau',
'settings.openchamber.desktopPassword.field.passwordPlaceholder': 'Aucun mot de passe requis',
'settings.openchamber.desktopPassword.field.passwordDescription': 'OpenChamber demande après le redémarrage, puis quand la session de connexion expire : après 12 heures, ou 7 jours avec Trust this device. Laissez vide pour désactiver la connexion.',
@@ -1510,6 +1552,7 @@ export const settingsDict = {
'settings.voice.page.section.playbackAndSummary': 'Lecture',
'settings.voice.page.field.provider': 'Fournisseur',
'settings.voice.page.provider.browser': 'Navigateur',
'settings.voice.page.provider.openai': 'OpenAI',
'settings.voice.page.provider.custom': 'Personnalisé',
'settings.voice.page.provider.say': 'Dire',
'settings.voice.page.provider.server': 'Serveur',
@@ -1585,15 +1628,21 @@ export const settingsDict = {
'settings.voice.page.preview.voiceLine': 'Bonjour! Je m\'appelle {voiceName}. C\'est ainsi que je parle.',
'settings.voice.page.preview.customServerLine': 'Bonjour! Ceci est un aperçu du serveur TTS personnalisé.',
'settings.openchamber.visual.section.colorMode': 'Mode couleur',
'settings.openchamber.visual.section.colorModeAndTheme': 'Mode couleur et thème',
'settings.openchamber.visual.section.localization': 'Localisation',
'settings.openchamber.visual.section.spacingAndLayout': 'Espacement et disposition',
'settings.openchamber.visual.section.densityAndType': 'Densité et typographie',
'settings.openchamber.visual.section.appInstall': 'Installation de lapp',
'settings.openchamber.visual.section.navigation': 'Navigation',
'settings.openchamber.visual.section.chatRenderMode': 'Mode de rendu du chat',
'settings.openchamber.visual.section.chatRenderModeAria': 'Mode de rendu du chat',
'settings.openchamber.visual.section.chatDisplay': 'Affichage',
'settings.openchamber.visual.section.chatMessageOptions': 'Options des messages',
'settings.openchamber.visual.section.chatFeatures': 'Fonctionnalités',
'settings.openchamber.visual.section.messageStreamTransport': 'Transport de flux de messages',
'settings.openchamber.visual.section.activityDefault': 'Activité par défaut',
'settings.openchamber.visual.section.activityDefaultAria': 'Mode d\'activité par défaut',
'settings.openchamber.visual.section.showToolsOpenedByDefault': 'Afficher les outils ouverts par défaut :',
'settings.openchamber.visual.section.showToolsOpenedByDefault': 'Afficher les outils ouverts par défaut',
'settings.openchamber.visual.section.sessionAssistance': 'Assistance de session',
'settings.openchamber.visual.section.reasoning': 'Raisonnement',
'settings.openchamber.visual.section.messageAppearance': 'Apparence des messages',
@@ -1713,8 +1762,11 @@ export const settingsDict = {
'settings.openchamber.visual.field.sendAnonymousUsageReports': 'Envoyer des rapports d\'utilisation anonymes',
'settings.openchamber.visual.field.sendAnonymousUsageReportsHint': 'Nous aide à comprendre quelles versions de l\'application sont activement utilisées afin que nous puissions prioriser les améliorations. Seules la version de lapplication, la plate-forme et le runtime sont collectés aucune donnée personnelle ni code.',
'settings.openchamber.visual.option.themeMode.system': 'Système',
'settings.openchamber.visual.option.themeMode.system.description': 'Suivre le réglage système',
'settings.openchamber.visual.option.themeMode.light': 'Clair',
'settings.openchamber.visual.option.themeMode.light.description': 'Toujours utiliser lapparence claire',
'settings.openchamber.visual.option.themeMode.dark': 'Sombre',
'settings.openchamber.visual.option.themeMode.dark.description': 'Toujours utiliser lapparence sombre',
'settings.openchamber.visual.option.diffLayout.dynamic.label': 'Dynamique',
'settings.openchamber.visual.option.diffLayout.dynamic.description': 'Choisit automatiquement entre la vue côte à côte et la vue intégrée selon le contenu modifié.',
'settings.openchamber.visual.option.diffLayout.inline.label': 'Toujours intégré',
@@ -1800,6 +1852,10 @@ export const settingsDict = {
'settings.view.search.aria': 'Rechercher dans les paramètres',
'settings.view.search.clear': 'Effacer la recherche des paramètres',
'settings.view.search.noResults': 'Aucun paramètre correspondant',
'settings.view.nav.group.general': 'OpenChamber',
'settings.view.nav.group.projects': 'Espace de travail',
'settings.view.nav.group.opencode': 'OpenCode',
'settings.view.nav.group.content': 'Bibliothèque',
'settings.page.about.title': 'À propos',
'settings.openchamber.tunnel.notAvailable.dependencyNotFound': '{dependency} est introuvable.',
'settings.magicPrompts.sidebar.item.sessionExplore': 'Tour du codebase',
@@ -47,6 +47,10 @@ export const settingsDict = {
'settings.view.search.aria': '設定を検索',
'settings.view.search.clear': '設定検索をクリア',
'settings.view.search.noResults': '該当する設定がありません',
'settings.view.nav.group.general': 'OpenChamber',
'settings.view.nav.group.projects': 'ワークスペース',
'settings.view.nav.group.opencode': 'OpenCode',
'settings.view.nav.group.content': 'ライブラリ',
'settings.page.projects.title': 'プロジェクト',
'settings.page.remoteInstances.title': 'リモートインスタンス',
'settings.page.providers.title': 'プロバイダー',
@@ -59,13 +63,22 @@ export const settingsDict = {
'settings.page.skillsCatalog.title': 'スキルカタログ',
'settings.page.git.title': 'Git',
'settings.page.appearance.title': '外観',
'settings.page.appearance.description': 'OpenChamber の見た目と操作感をカスタマイズします。',
'settings.page.chat.title': 'チャット',
'settings.page.chat.description': 'メッセージとツールの表示方法を設定します。',
'settings.page.shortcuts.title': 'ショートカット',
'settings.page.shortcuts.description': 'キーボードショートカットをカスタマイズします。',
'settings.page.general.title': '一般',
'settings.page.general.description': 'アプリの起動、セキュリティ、接続、プライバシー。',
'settings.page.sessions.title': 'セッション',
'settings.page.sessions.description': 'セッションのデフォルトと保持期間を設定します。',
'settings.page.magicPrompts.title': 'マジックプロンプト',
'settings.page.notifications.title': '通知',
'settings.page.notifications.description': '通知のタイミングと方法を選択します。',
'settings.page.voice.title': '音声',
'settings.page.tunnel.title': 'リモートトンネル',
'settings.page.voice.description': '音声読み上げとディクテーションを設定します。',
'settings.page.tunnel.title': '外部トンネル',
'settings.page.tunnel.description': 'このインスタンスをリモートトンネルで公開します。',
'settings.page.about.title': '概要',
'settings.page.snippets.title': 'スニペット',
'settings.snippets.sidebar.title': 'スニペット',
@@ -123,7 +136,7 @@ export const settingsDict = {
'settings.promptTemplates.page.toast.created': 'Template を作成しました',
'settings.promptTemplates.page.toast.createFailed': 'Template の作成に失敗しました',
'settings.promptTemplates.page.toast.saveUnexpectedError': '保存中に予期しないエラーが発生しました',
'settings.openchamber.tunnel.title': 'リモートトンネル',
'settings.openchamber.tunnel.title': '外部トンネル',
'settings.openchamber.tunnel.description': 'クイックリンクまたは自身で管理するリモート Cloudflare Tunnel を使用して安全なリモートアクセスを設定します。',
'settings.openchamber.tunnel.note.serverSideEnforced': '安全な Tunnel アクセスはサーバー側で強制されます。',
'settings.openchamber.tunnel.note.connectLinksOneTime': '接続リンクは一度きりで、Tunnel が停止するか接続リンクの TTL が期限切れになると無効になります。',
@@ -399,6 +412,7 @@ export const settingsDict = {
'settings.remoteInstances.page.logsDialog.selectedInstanceFallback': '選択したインスタンス',
'settings.remoteInstances.page.patternDialog.descriptionWithHost': '{host} には具体的な接続先が必要です。',
'settings.remoteInstances.page.patternDialog.description': '接続先を入力してください。',
'settings.common.infoAria': '詳細情報',
'settings.common.actions.cancel': 'キャンセル',
'settings.common.actions.create': '作成',
'settings.common.actions.delete': '削除',
@@ -409,6 +423,8 @@ export const settingsDict = {
'settings.common.actions.copyAll': 'すべてコピー',
'settings.common.actions.clear': 'クリア',
'settings.common.actions.saving': '保存中...',
'settings.common.status.saved': '保存済み',
'settings.common.status.saveFailed': '保存に失敗しました',
'settings.common.actions.saveChanges': '変更を保存',
'settings.common.scope.global': 'グローバル',
'settings.common.scope.project': 'プロジェクト',
@@ -493,6 +509,33 @@ export const settingsDict = {
'settings.agents.page.section.identityRole': '識別情報と役割',
'settings.agents.page.section.modelParameters': 'モデルとパラメーター',
'settings.agents.page.section.systemPrompt': 'システムプロンプト',
'settings.agents.page.permissionsEditor.sectionInfo': 'このエージェント自身の権限ルールを、設定に保存されているとおりに編集します。「継承」はキーを未設定のままにし、グローバル設定とデフォルトが適用されます。',
'settings.agents.page.permissionsEditor.defaultLabel': '全ツールのデフォルト',
'settings.agents.page.permissionsEditor.defaultAria': '全ツールのデフォルト権限',
'settings.agents.page.permissionsEditor.defaultInfo': 'エージェントレベルのフォールバック(* キー)。個別ルールのないツールに適用されます。',
'settings.agents.page.permissionsEditor.effectiveHint': '→ {action}',
'settings.agents.page.permissionsEditor.ruleCount': '{count} 件のルール',
'settings.agents.page.permissionsEditor.keyAria': '{key} の権限',
'settings.agents.page.permissionsEditor.patternPlaceholder': 'パターン(例: rm -rf * や /path/**',
'settings.agents.page.permissionsEditor.patternActionAria': '{key} パターンのアクション',
'settings.agents.page.permissionsEditor.customKeyPlaceholder': 'カスタム権限キー…',
'settings.agents.page.permissionsEditor.sessionRulesTitle': 'セッションで許可(未保存)',
'settings.agents.page.permissionsEditor.sessionRulesInfo': 'ライブセッション中に許可されたルール(「常に許可」の回答など)。現在は有効ですが、保存された設定には含まれず、再起動で消えます。',
'settings.agents.page.permissionsEditor.action.default': 'デフォルト',
'settings.agents.page.permissionsEditor.action.inherit': '継承',
'settings.agents.page.permissionsEditor.action.allow': '許可',
'settings.agents.page.permissionsEditor.action.ask': '確認',
'settings.agents.page.permissionsEditor.action.deny': '拒否',
'settings.agents.page.permissionsEditor.actions.save': '権限を保存',
'settings.agents.page.permissionsEditor.actions.discard': '破棄',
'settings.agents.page.permissionsEditor.actions.addRule': 'ルールを追加',
'settings.agents.page.permissionsEditor.actions.addKey': 'キーを追加',
'settings.agents.page.permissionsEditor.actions.removeRuleAria': 'ルールを削除',
'settings.agents.page.permissionsEditor.actions.retry': '再試行',
'settings.agents.page.permissionsEditor.state.loadFailed': 'エージェントの権限設定を読み込めませんでした。',
'settings.agents.page.permissionsEditor.toast.saved': '権限を保存しました',
'settings.agents.page.permissionsEditor.toast.savedRestartRequired': '権限を保存しました — 適用するには OpenCode サーバーを再起動してください',
'settings.agents.page.permissionsEditor.toast.saveFailed': '権限の保存に失敗しました',
'settings.agents.page.section.toolPermissions': 'ツール権限',
'settings.agents.page.field.agentName': 'Agent 名',
'settings.agents.page.field.agentNamePlaceholder': 'agent-name',
@@ -528,6 +571,7 @@ export const settingsDict = {
'settings.agents.page.permissions.permissionPlaceholder': '権限...',
'settings.agents.page.permissions.patternPlaceholder': 'パターン(例: *',
'settings.page.behavior.title': '動作',
'settings.page.behavior.description': 'エージェントの応答の仕方をガイドします。',
'settings.behavior.page.title': '動作',
'settings.behavior.page.warning.title': 'グローバルルールはプロジェクトルールと組み合わされます',
'settings.behavior.page.warning.description': 'ここでの変更は {path} を更新します。OpenCode はプロジェクトレベルの AGENTS.md ルールも存在する場合は含めます。',
@@ -913,6 +957,8 @@ export const settingsDict = {
'settings.openchamber.desktopNetwork.field.allowLanAccessDescription': 'アプリを再起動して、Wi-Fi 上の他のデバイスから開けるようにします。',
'settings.openchamber.desktopNetwork.field.warning': '警告: 有効にすると、アプリは同じローカルネットワーク上の誰からもアクセス可能になります。',
'settings.openchamber.desktopNetwork.field.passwordRequiredWarning': 'LAN アクセスには Desktop UI パスワードが必要です。設定されるまで、Desktop アプリはローカルのみで起動します。',
'settings.openchamber.desktopPassword.actions.showPassword': 'パスワードを表示',
'settings.openchamber.desktopPassword.actions.hidePassword': 'パスワードを非表示',
'settings.openchamber.desktopPassword.field.password': 'Desktop UI パスワード',
'settings.openchamber.desktopPassword.field.passwordPlaceholder': 'パスワード不要',
'settings.openchamber.desktopPassword.field.passwordDescription': 'OpenChamber は再起動後、およびログイン Session の有効期限後(12時間、または「このデバイスを信頼」の場合は7日)に確認を求めます。空のままにするとログインが無効になります。',
@@ -1618,6 +1664,7 @@ export const settingsDict = {
'settings.voice.page.section.playbackAndSummary': '再生',
'settings.voice.page.field.provider': 'プロバイダー',
'settings.voice.page.provider.browser': 'ブラウザ',
'settings.voice.page.provider.openai': 'OpenAI',
'settings.voice.page.provider.custom': 'カスタム',
'settings.voice.page.provider.say': 'Say',
'settings.voice.page.provider.server': 'サーバー',
@@ -1697,18 +1744,24 @@ export const settingsDict = {
'settings.voice.page.field.ttsInputModeRaw': '生 Markdown',
'settings.voice.page.field.ttsInputModeSummarized': '要約',
'settings.openchamber.visual.section.colorMode': 'カラーモード',
'settings.openchamber.visual.section.colorModeAndTheme': 'カラーモードとテーマ',
'settings.openchamber.visual.section.mobileLayout': 'モバイルレイアウト',
'settings.openchamber.visual.option.mobileLayout.default': '旧',
'settings.openchamber.visual.option.mobileLayout.new': '新',
'settings.openchamber.visual.section.localization': 'ローカライゼーション',
'settings.openchamber.visual.section.spacingAndLayout': '間隔とレイアウト',
'settings.openchamber.visual.section.densityAndType': '密度と書体',
'settings.openchamber.visual.section.appInstall': 'アプリのインストール',
'settings.openchamber.visual.section.navigation': 'ナビゲーション',
'settings.openchamber.visual.section.chatRenderMode': 'Chat 表示モード',
'settings.openchamber.visual.section.chatRenderModeAria': 'Chat 表示モード',
'settings.openchamber.visual.section.chatDisplay': '表示',
'settings.openchamber.visual.section.chatMessageOptions': 'メッセージオプション',
'settings.openchamber.visual.section.chatFeatures': '機能',
'settings.openchamber.visual.section.messageStreamTransport': 'メッセージストリーム転送',
'settings.openchamber.visual.section.activityDefault': 'アクティビティデフォルト',
'settings.openchamber.visual.section.activityDefaultAria': 'アクティビティデフォルトモード',
'settings.openchamber.visual.section.showToolsOpenedByDefault': 'デフォルトで開くツールを表示:',
'settings.openchamber.visual.section.showToolsOpenedByDefault': 'デフォルトで開くツールを表示',
'settings.openchamber.visual.section.sessionAssistance': 'セッション支援',
'settings.openchamber.visual.section.reasoning': '推論',
'settings.openchamber.visual.section.messageAppearance': 'メッセージの外観',
@@ -1839,8 +1892,11 @@ export const settingsDict = {
'settings.openchamber.visual.field.sendAnonymousUsageReports': '匿名使用状況レポートを送信',
'settings.openchamber.visual.field.sendAnonymousUsageReportsHint': 'どのアプリバージョンがアクティブに使用されているかを把握し、改善の優先順位を決めるのに役立ちます。収集されるのはアプリバージョン、プラットフォーム、ランタイムのみで、個人データやコードは収集されません。',
'settings.openchamber.visual.option.themeMode.system': 'システム',
'settings.openchamber.visual.option.themeMode.system.description': 'システム設定に合わせる',
'settings.openchamber.visual.option.themeMode.light': 'ライト',
'settings.openchamber.visual.option.themeMode.light.description': '常にライト表示を使用',
'settings.openchamber.visual.option.themeMode.dark': 'ダーク',
'settings.openchamber.visual.option.themeMode.dark.description': '常にダーク表示を使用',
'settings.openchamber.visual.option.diffLayout.dynamic.label': '動的',
'settings.openchamber.visual.option.diffLayout.dynamic.description': '新規はインライン、変更は並列表示。',
'settings.openchamber.visual.option.diffLayout.inline.label': '常にインライン',
@@ -47,6 +47,10 @@ export const settingsDict = {
'settings.view.search.aria': '설정 검색',
'settings.view.search.clear': '설정 검색 지우기',
'settings.view.search.noResults': '일치하는 설정이 없습니다',
'settings.view.nav.group.general': 'OpenChamber',
'settings.view.nav.group.projects': '워크스페이스',
'settings.view.nav.group.opencode': 'OpenCode',
'settings.view.nav.group.content': '라이브러리',
'settings.page.projects.title': '프로젝트',
'settings.page.remoteInstances.title': '원격 인스턴스',
'settings.page.providers.title': '프로바이더',
@@ -59,13 +63,22 @@ export const settingsDict = {
'settings.page.skillsCatalog.title': '스킬 카탈로그',
'settings.page.git.title': 'Git',
'settings.page.appearance.title': '외관',
'settings.page.appearance.description': 'OpenChamber의 모양과 느낌을 맞춤 설정하세요.',
'settings.page.chat.title': '채팅',
'settings.page.chat.description': '메시지와 도구가 표시되는 방식을 구성합니다.',
'settings.page.shortcuts.title': '단축키',
'settings.page.shortcuts.description': '키보드 단축키를 맞춤 설정합니다.',
'settings.page.general.title': '일반',
'settings.page.general.description': '앱 시작, 보안, 연결 및 개인정보.',
'settings.page.sessions.title': '세션',
'settings.page.sessions.description': '세션 기본값과 보관 기간을 설정합니다.',
'settings.page.magicPrompts.title': '매직 프롬프트',
'settings.page.notifications.title': '알림',
'settings.page.notifications.description': '알림을 받을 시점과 방식을 선택합니다.',
'settings.page.voice.title': '음성',
'settings.page.tunnel.title': '원격 터널',
'settings.page.voice.description': '음성 및 받아쓰기를 구성합니다.',
'settings.page.tunnel.title': '외부 터널',
'settings.page.tunnel.description': '원격 터널로 이 인스턴스를 노출합니다.',
'settings.page.about.title': '정보',
'settings.page.snippets.title': '스니펫',
'settings.snippets.sidebar.title': '스니펫',
@@ -90,7 +103,7 @@ export const settingsDict = {
'settings.snippets.page.toast.contentRequired': '스니펫 내용은 필수입니다',
'settings.snippets.page.toast.saveFailed': '스니펫 저장 실패',
'settings.snippets.page.toast.saved': '스니펫이 저장되었습니다',
'settings.openchamber.tunnel.title': '원격 터널',
'settings.openchamber.tunnel.title': '외부 터널',
'settings.openchamber.tunnel.description': '퀵 링크나 직접 관리하는 Cloudflare 터널로 안전한 원격 접속을 설정하세요.',
'settings.openchamber.tunnel.note.serverSideEnforced': '보안 터널 접근은 서버 측에서 강제됩니다.',
'settings.openchamber.tunnel.note.connectLinksOneTime': '접속 링크는 일회용이며, 터널이 중지되거나 링크 TTL이 만료되면 폐기됩니다.',
@@ -366,6 +379,7 @@ export const settingsDict = {
'settings.remoteInstances.page.logsDialog.selectedInstanceFallback': '선택한 인스턴스',
'settings.remoteInstances.page.patternDialog.descriptionWithHost': '{host}에는 구체적인 대상이 필요합니다.',
'settings.remoteInstances.page.patternDialog.description': '대상을 입력하세요.',
'settings.common.infoAria': '자세한 정보',
'settings.common.actions.cancel': '취소',
'settings.common.actions.create': '생성',
'settings.common.actions.delete': '삭제',
@@ -376,6 +390,8 @@ export const settingsDict = {
'settings.common.actions.copyAll': '모두 복사',
'settings.common.actions.clear': '지우기',
'settings.common.actions.saving': '저장 중...',
'settings.common.status.saved': '저장됨',
'settings.common.status.saveFailed': '저장 실패',
'settings.common.actions.saveChanges': '변경 사항 저장',
'settings.common.scope.global': '전역',
'settings.common.scope.project': '프로젝트',
@@ -460,6 +476,33 @@ export const settingsDict = {
'settings.agents.page.section.identityRole': '정체성 및 역할',
'settings.agents.page.section.modelParameters': '모델 및 파라미터',
'settings.agents.page.section.systemPrompt': '시스템 프롬프트',
'settings.agents.page.permissionsEditor.sectionInfo': '이 에이전트 자체의 권한 규칙을 설정 파일에 저장된 그대로 편집합니다. "상속"은 키를 설정하지 않은 상태로 두어 전역 설정과 기본값이 적용됩니다.',
'settings.agents.page.permissionsEditor.defaultLabel': '모든 도구 기본값',
'settings.agents.page.permissionsEditor.defaultAria': '모든 도구의 기본 권한',
'settings.agents.page.permissionsEditor.defaultInfo': '에이전트 수준 폴백(* 키)입니다. 자체 규칙이 없는 도구에 적용됩니다.',
'settings.agents.page.permissionsEditor.effectiveHint': '→ {action}',
'settings.agents.page.permissionsEditor.ruleCount': '규칙 {count}개',
'settings.agents.page.permissionsEditor.keyAria': '{key} 권한',
'settings.agents.page.permissionsEditor.patternPlaceholder': '패턴, 예: rm -rf * 또는 /path/**',
'settings.agents.page.permissionsEditor.patternActionAria': '{key} 패턴의 동작',
'settings.agents.page.permissionsEditor.customKeyPlaceholder': '사용자 지정 권한 키…',
'settings.agents.page.permissionsEditor.sessionRulesTitle': '세션에서 허용됨(저장 안 됨)',
'settings.agents.page.permissionsEditor.sessionRulesInfo': '라이브 세션 중 허용된 규칙("항상 허용" 응답 등)입니다. 지금은 적용되지만 저장된 설정에는 포함되지 않으며 재시작 시 사라집니다.',
'settings.agents.page.permissionsEditor.action.default': '기본값',
'settings.agents.page.permissionsEditor.action.inherit': '상속',
'settings.agents.page.permissionsEditor.action.allow': '허용',
'settings.agents.page.permissionsEditor.action.ask': '확인',
'settings.agents.page.permissionsEditor.action.deny': '거부',
'settings.agents.page.permissionsEditor.actions.save': '권한 저장',
'settings.agents.page.permissionsEditor.actions.discard': '취소',
'settings.agents.page.permissionsEditor.actions.addRule': '규칙 추가',
'settings.agents.page.permissionsEditor.actions.addKey': '키 추가',
'settings.agents.page.permissionsEditor.actions.removeRuleAria': '규칙 삭제',
'settings.agents.page.permissionsEditor.actions.retry': '다시 시도',
'settings.agents.page.permissionsEditor.state.loadFailed': '에이전트 권한 설정을 불러오지 못했습니다.',
'settings.agents.page.permissionsEditor.toast.saved': '권한이 저장되었습니다',
'settings.agents.page.permissionsEditor.toast.savedRestartRequired': '권한이 저장되었습니다 — 적용하려면 OpenCode 서버를 재시작하세요',
'settings.agents.page.permissionsEditor.toast.saveFailed': '권한 저장에 실패했습니다',
'settings.agents.page.section.toolPermissions': '도구 권한',
'settings.agents.page.field.agentName': '에이전트 이름',
'settings.agents.page.field.agentNamePlaceholder': 'agent-name (예: code-reviewer)',
@@ -495,6 +538,7 @@ export const settingsDict = {
'settings.agents.page.permissions.permissionPlaceholder': '권한...',
'settings.agents.page.permissions.patternPlaceholder': '패턴(예: *)',
'settings.page.behavior.title': '동작',
'settings.page.behavior.description': '에이전트의 응답 방식을 안내합니다.',
'settings.behavior.page.title': '동작',
'settings.behavior.page.warning.title': '전역 규칙은 프로젝트 규칙과 함께 사용됩니다',
'settings.behavior.page.warning.description': '여기에서 변경한 내용은 {path}를 업데이트합니다. 프로젝트 수준의 AGENTS.md가 있으면 OpenCode도 함께 포함합니다.',
@@ -880,6 +924,8 @@ export const settingsDict = {
'settings.openchamber.desktopNetwork.field.allowLanAccessDescription': '휴대폰, 태블릿, Wi-Fi의 다른 컴퓨터에서 열 수 있도록 앱을 다시 시작합니다.',
'settings.openchamber.desktopNetwork.field.warning': '경고: 활성화된 동안 같은 로컬 네트워크의 누구나 앱에 접속할 수 있습니다.',
'settings.openchamber.desktopNetwork.field.passwordRequiredWarning': 'LAN 접속에는 Desktop UI 비밀번호가 필요합니다. 설정하기 전까지 desktop 앱은 로컬 전용으로 시작됩니다.',
'settings.openchamber.desktopPassword.actions.showPassword': '비밀번호 표시',
'settings.openchamber.desktopPassword.actions.hidePassword': '비밀번호 숨기기',
'settings.openchamber.desktopPassword.field.password': 'Desktop UI 비밀번호',
'settings.openchamber.desktopPassword.field.passwordPlaceholder': '비밀번호 필요 없음',
'settings.openchamber.desktopPassword.field.passwordDescription': 'OpenChamber는 다시 시작 후 비밀번호를 요청하고, 이후 로그인 세션이 만료되면 다시 요청합니다. 기본 12시간, 이 디바이스 신뢰 선택 시 7일입니다. 로그인을 끄려면 비워 두세요.',
@@ -1585,6 +1631,7 @@ export const settingsDict = {
'settings.voice.page.section.playbackAndSummary': '재생',
'settings.voice.page.field.provider': '프로바이더',
'settings.voice.page.provider.browser': '브라우저',
'settings.voice.page.provider.openai': 'OpenAI',
'settings.voice.page.provider.custom': '사용자 정의',
'settings.voice.page.provider.say': 'Say',
'settings.voice.page.provider.server': '서버',
@@ -1664,18 +1711,24 @@ export const settingsDict = {
'settings.voice.page.field.ttsInputModeRaw': '원본 Markdown',
'settings.voice.page.field.ttsInputModeSummarized': '요약',
'settings.openchamber.visual.section.colorMode': '색상 모드',
'settings.openchamber.visual.section.colorModeAndTheme': '색상 모드 및 테마',
'settings.openchamber.visual.section.mobileLayout': '모바일 레이아웃',
'settings.openchamber.visual.option.mobileLayout.default': '이전',
'settings.openchamber.visual.option.mobileLayout.new': '새로움',
'settings.openchamber.visual.section.localization': '지역화',
'settings.openchamber.visual.section.spacingAndLayout': '간격 및 레이아웃',
'settings.openchamber.visual.section.densityAndType': '밀도 및 서체',
'settings.openchamber.visual.section.appInstall': '앱 설치',
'settings.openchamber.visual.section.navigation': '내비게이션',
'settings.openchamber.visual.section.chatRenderMode': '채팅 렌더링 모드',
'settings.openchamber.visual.section.chatRenderModeAria': '채팅 렌더링 모드',
'settings.openchamber.visual.section.chatDisplay': '표시',
'settings.openchamber.visual.section.chatMessageOptions': '메시지 옵션',
'settings.openchamber.visual.section.chatFeatures': '기능',
'settings.openchamber.visual.section.messageStreamTransport': '메시지 스트림 전송',
'settings.openchamber.visual.section.activityDefault': 'Activity 기본값',
'settings.openchamber.visual.section.activityDefaultAria': 'Activity 기본 모드',
'settings.openchamber.visual.section.showToolsOpenedByDefault': '도구를 기본으로 펼쳐 표시:',
'settings.openchamber.visual.section.showToolsOpenedByDefault': '도구를 기본으로 펼쳐 표시',
'settings.openchamber.visual.section.sessionAssistance': '세션 지원',
'settings.openchamber.visual.section.reasoning': '추론',
'settings.openchamber.visual.section.messageAppearance': '메시지 모양',
@@ -1806,8 +1859,11 @@ export const settingsDict = {
'settings.openchamber.visual.field.sendAnonymousUsageReports': '익명 사용량 보고서 보내기',
'settings.openchamber.visual.field.sendAnonymousUsageReportsHint': '활성 사용 앱 버전을 파악해 개선 우선순위를 정하는 데 도움이 됩니다. 앱 버전, 플랫폼, 런타임만 수집되며 개인 데이터나 코드는 수집되지 않습니다.',
'settings.openchamber.visual.option.themeMode.system': 'System',
'settings.openchamber.visual.option.themeMode.system.description': '시스템 설정 따르기',
'settings.openchamber.visual.option.themeMode.light': 'Light',
'settings.openchamber.visual.option.themeMode.light.description': '항상 라이트 모양 사용',
'settings.openchamber.visual.option.themeMode.dark': 'Dark',
'settings.openchamber.visual.option.themeMode.dark.description': '항상 다크 모양 사용',
'settings.openchamber.visual.option.diffLayout.dynamic.label': '동적',
'settings.openchamber.visual.option.diffLayout.dynamic.description': '새 항목은 inline, 수정 항목은 side-by-side로 표시합니다.',
'settings.openchamber.visual.option.diffLayout.inline.label': '항상 inline',
@@ -69,6 +69,33 @@ export const settingsDict = {
'settings.agents.page.section.identityRole': 'Tożsamość i Rola',
'settings.agents.page.section.modelParameters': 'Model i Parametry',
'settings.agents.page.section.systemPrompt': 'System Prompt',
'settings.agents.page.permissionsEditor.sectionInfo': 'Edytuje własne reguły uprawnień tego agenta dokładnie tak, jak są zapisane w jego konfiguracji. „Dziedzicz” pozostawia klucz nieustawiony — obowiązują konfiguracja globalna i domyślne.',
'settings.agents.page.permissionsEditor.defaultLabel': 'Domyślne dla wszystkich narzędzi',
'settings.agents.page.permissionsEditor.defaultAria': 'Domyślne uprawnienie dla wszystkich narzędzi',
'settings.agents.page.permissionsEditor.defaultInfo': 'Rezerwa na poziomie agenta (klucz *). Dotyczy narzędzi bez własnej reguły.',
'settings.agents.page.permissionsEditor.effectiveHint': '→ {action}',
'settings.agents.page.permissionsEditor.ruleCount': '{count} reguł',
'settings.agents.page.permissionsEditor.keyAria': 'Uprawnienie dla {key}',
'settings.agents.page.permissionsEditor.patternPlaceholder': 'Wzorzec, np. rm -rf * lub /path/**',
'settings.agents.page.permissionsEditor.patternActionAria': 'Akcja dla wzorca {key}',
'settings.agents.page.permissionsEditor.customKeyPlaceholder': 'Własny klucz uprawnienia…',
'settings.agents.page.permissionsEditor.sessionRulesTitle': 'Przyznane w sesji (niezapisane)',
'settings.agents.page.permissionsEditor.sessionRulesInfo': 'Reguły przyznane w trakcie sesji (odpowiedzi „zawsze zezwalaj”). Działają teraz, ale nie są częścią zapisanej konfiguracji i znikną po restarcie.',
'settings.agents.page.permissionsEditor.action.default': 'Domyślnie',
'settings.agents.page.permissionsEditor.action.inherit': 'Dziedzicz',
'settings.agents.page.permissionsEditor.action.allow': 'Zezwól',
'settings.agents.page.permissionsEditor.action.ask': 'Pytaj',
'settings.agents.page.permissionsEditor.action.deny': 'Odmów',
'settings.agents.page.permissionsEditor.actions.save': 'Zapisz uprawnienia',
'settings.agents.page.permissionsEditor.actions.discard': 'Odrzuć',
'settings.agents.page.permissionsEditor.actions.addRule': 'Dodaj regułę',
'settings.agents.page.permissionsEditor.actions.addKey': 'Dodaj klucz',
'settings.agents.page.permissionsEditor.actions.removeRuleAria': 'Usuń regułę',
'settings.agents.page.permissionsEditor.actions.retry': 'Ponów',
'settings.agents.page.permissionsEditor.state.loadFailed': 'Nie udało się wczytać konfiguracji uprawnień agenta.',
'settings.agents.page.permissionsEditor.toast.saved': 'Uprawnienia zapisane',
'settings.agents.page.permissionsEditor.toast.savedRestartRequired': 'Uprawnienia zapisane — uruchom ponownie serwer OpenCode, aby zastosować',
'settings.agents.page.permissionsEditor.toast.saveFailed': 'Nie udało się zapisać uprawnień',
'settings.agents.page.section.toolPermissions': 'Uprawnienia do narzędzi',
'settings.agents.page.subtitle.edit': 'Edytuj ustawienia agenta',
'settings.agents.page.subtitle.new': 'Skonfiguruj nową personę asystenta',
@@ -117,7 +144,7 @@ export const settingsDict = {
'settings.behavior.page.responseStyle.option.noFiller': 'Bez zbędnych treści',
'settings.behavior.page.responseStyle.option.pushback': 'Krytyczne podejście',
'settings.behavior.page.responseStyle.option.warmPeer': 'Przyjazny kolega',
'settings.behavior.page.responseStyle.preset': 'Preset',
'settings.behavior.page.responseStyle.preset': 'Predefiniowany styl',
'settings.behavior.page.responseStyle.tooltip': 'Jeśli jest włączone, instrukcje te określają sposób odpowiadania asystenta w każdej nowej rozmowie. Są one przesyłane z Twoją pierwszą wiadomością i nie zmieniają Twoich globalnych reguł AGENTS.md.',
'settings.behavior.page.section.responseStyle': 'Styl odpowiedzi',
'settings.behavior.page.section.systemPrompt': 'Globalny AGENTS.md',
@@ -173,6 +200,7 @@ export const settingsDict = {
'settings.commands.sidebar.toast.renameFailed': 'Nie udało się zmienić nazwy polecenia',
'settings.commands.sidebar.toast.resetFailed': 'Nie udało się zresetować polecenia',
'settings.commands.sidebar.total': 'Suma: {count}',
'settings.common.infoAria': 'Więcej informacji',
'settings.common.actions.cancel': 'Anuluj',
'settings.common.actions.clear': 'Wyczyść',
'settings.common.actions.copyAll': 'Kopiuj wszystko',
@@ -184,6 +212,8 @@ export const settingsDict = {
'settings.common.actions.reset': 'Zresetuj',
'settings.common.actions.saveChanges': 'Zapisz zmiany',
'settings.common.actions.saving': 'Zapisywanie...',
'settings.common.status.saved': 'Zapisano',
'settings.common.status.saveFailed': 'Nie udało się zapisać',
'settings.common.badge.modified': 'Zmodyfikowano',
'settings.common.badge.new': 'Nowy',
'settings.common.field.description': 'Opis',
@@ -731,6 +761,8 @@ export const settingsDict = {
'settings.openchamber.desktopNetwork.field.keepAwakeDescription': 'Aby telefony nadal mogły otwierać tę aplikację. Ekran nadal może się wyłączyć.',
'settings.openchamber.desktopNetwork.field.warning': 'Ostrzeżenie: po włączeniu aplikacja jest dostępna dla każdego w tej samej sieci lokalnej.',
'settings.openchamber.desktopNetwork.field.passwordRequiredWarning': 'Dostęp LAN wymaga hasła UI pulpitu. Dopóki go nie ustawisz, aplikacja pulpitu uruchamia się tylko lokalnie.',
'settings.openchamber.desktopPassword.actions.showPassword': 'Pokaż hasło',
'settings.openchamber.desktopPassword.actions.hidePassword': 'Ukryj hasło',
'settings.openchamber.desktopPassword.field.password': 'Hasło UI pulpitu',
'settings.openchamber.desktopPassword.field.passwordPlaceholder': 'Hasło nie jest wymagane',
'settings.openchamber.desktopPassword.field.passwordDescription': 'OpenChamber pyta po restarcie, a potem po wygaśnięciu sesji logowania: po 12 godzinach albo po 7 dniach z opcją Zaufaj temu urządzeniu. Zostaw puste, aby wyłączyć logowanie.',
@@ -915,7 +947,7 @@ export const settingsDict = {
'settings.openchamber.tunnel.state.noExpiry': 'Bez wygaśnięcia',
'settings.openchamber.tunnel.state.revoked': 'Unieważniony',
'settings.openchamber.tunnel.state.tunnelReady': 'Tunel gotowy',
'settings.openchamber.tunnel.title': 'Zdalny Tunel',
'settings.openchamber.tunnel.title': 'Tunel zewnętrzny',
'settings.openchamber.tunnel.toast.addManagedRemoteTokenBeforeStarting': 'Dodaj token tunelu managed remote przed uruchomieniem',
'settings.openchamber.tunnel.toast.checkAvailabilityFailed': 'Nie udało się sprawdzić dostępności tunelu',
'settings.openchamber.tunnel.toast.connectLinkCopied': 'Link połączenia został skopiowany',
@@ -1090,8 +1122,11 @@ export const settingsDict = {
'settings.openchamber.visual.option.pwaOrientation.system.description': 'Uwzględniaj ustawienie obrotu urządzenia.',
'settings.openchamber.visual.option.pwaOrientation.system.label': 'Zgodnie z systemem',
'settings.openchamber.visual.option.themeMode.dark': 'Ciemny',
'settings.openchamber.visual.option.themeMode.dark.description': 'Zawsze używaj ciemnego wyglądu',
'settings.openchamber.visual.option.themeMode.light': 'Jasny',
'settings.openchamber.visual.option.themeMode.light.description': 'Zawsze używaj jasnego wyglądu',
'settings.openchamber.visual.option.themeMode.system': 'Systemowy',
'settings.openchamber.visual.option.themeMode.system.description': 'Zgodnie z ustawieniem systemu',
'settings.openchamber.visual.option.timeFormat.12h.description': 'Pokazuj czas jako 02:15 PM.',
'settings.openchamber.visual.option.timeFormat.12h.label': '12-godzinny',
'settings.openchamber.visual.option.timeFormat.24h.description': 'Pokazuj czas jako 14:15.',
@@ -1111,7 +1146,11 @@ export const settingsDict = {
'settings.openchamber.visual.section.activityDefaultAria': 'Domyślny tryb aktywności',
'settings.openchamber.visual.section.chatRenderMode': 'Tryb renderowania czatu',
'settings.openchamber.visual.section.chatRenderModeAria': 'Tryb renderowania czatu',
'settings.openchamber.visual.section.chatDisplay': 'Wyświetlanie',
'settings.openchamber.visual.section.chatMessageOptions': 'Opcje wiadomości',
'settings.openchamber.visual.section.chatFeatures': 'Funkcje',
'settings.openchamber.visual.section.colorMode': 'Tryb kolorów',
'settings.openchamber.visual.section.colorModeAndTheme': 'Tryb kolorów i motyw',
'settings.openchamber.visual.section.mobileLayout': 'Układ mobilny',
'settings.openchamber.visual.option.mobileLayout.default': 'Poprzedni',
'settings.openchamber.visual.option.mobileLayout.new': 'Nowy',
@@ -1123,13 +1162,15 @@ export const settingsDict = {
'settings.openchamber.visual.section.messageStreamTransport': 'Transport strumienia wiadomości',
'settings.openchamber.visual.section.navigation': 'Nawigacja',
'settings.openchamber.visual.section.privacy': 'Prywatność',
'settings.openchamber.visual.section.showToolsOpenedByDefault': 'Pokaż narzędzia domyślnie otwarte:',
'settings.openchamber.visual.section.showToolsOpenedByDefault': 'Pokaż narzędzia domyślnie otwarte',
'settings.openchamber.visual.section.sessionAssistance': 'Wsparcie sesji',
'settings.openchamber.visual.section.reasoning': 'Rozumowanie',
'settings.openchamber.visual.section.messageAppearance': 'Wygląd wiadomości',
'settings.openchamber.visual.section.toolsAndFiles': 'Narzędzia i pliki',
'settings.openchamber.visual.section.composer': 'Pole wiadomości',
'settings.openchamber.visual.section.spacingAndLayout': 'Odstępy i układ',
'settings.openchamber.visual.section.densityAndType': 'Gęstość i typografia',
'settings.openchamber.visual.section.appInstall': 'Instalacja aplikacji',
'settings.openchamber.visual.section.userMessageRendering': 'Renderowanie wiadomości użytkownika',
'settings.openchamber.visual.section.userMessageRenderingAria': 'Tryb renderowania wiadomości użytkownika',
'settings.openchamber.worktrees.list.deleteWorktreeAria': 'Usuń worktree {name}',
@@ -1152,22 +1193,31 @@ export const settingsDict = {
'settings.openchamber.worktrees.state.selectProject': 'Wybierz projekt, aby zarządzać worktree.',
'settings.page.agents.title': 'Agenci',
'settings.page.appearance.title': 'Wygląd',
'settings.page.appearance.description': 'Dostosuj wygląd i odczucie OpenChamber.',
'settings.page.behavior.title': 'Zachowanie',
'settings.page.behavior.description': 'Kieruj sposobem, w jaki agent odpowiada.',
'settings.page.chat.title': 'Czat',
'settings.page.chat.description': 'Skonfiguruj sposób wyświetlania wiadomości i narzędzi.',
'settings.page.commands.title': 'Polecenia',
'settings.page.git.title': 'Git',
'settings.page.magicPrompts.title': 'Magiczne Prompty',
'settings.page.mcp.title': 'MCP',
'settings.page.plugins.title': 'Plugins',
'settings.page.notifications.title': 'Powiadomienia',
'settings.page.notifications.description': 'Wybierz, kiedy i jak otrzymywać powiadomienia.',
'settings.page.projects.title': 'Projekty',
'settings.page.providers.title': 'Dostawcy',
'settings.page.remoteInstances.title': 'Zdalne instancje',
'settings.page.general.title': 'Ogólne',
'settings.page.general.description': 'Uruchamianie aplikacji, bezpieczeństwo, połączenie i prywatność.',
'settings.page.sessions.title': 'Sesje',
'settings.page.sessions.description': 'Ustaw wartości domyślne i retencję sesji.',
'settings.page.shortcuts.title': 'Skróty',
'settings.page.shortcuts.description': 'Dostosuj skróty klawiszowe.',
'settings.page.skills.title': 'Umiejętności',
'settings.page.skillsCatalog.title': 'Katalog umiejętności',
'settings.page.tunnel.title': 'Zdalny Tunel',
'settings.page.tunnel.title': 'Tunel zewnętrzny',
'settings.page.tunnel.description': 'Udostępnij tę instancję przez zdalny tunel.',
'settings.page.about.title': 'O aplikacji',
'settings.page.snippets.title': 'Fragmenty',
'settings.snippets.sidebar.title': 'Fragmenty',
@@ -1227,6 +1277,7 @@ export const settingsDict = {
'settings.promptTemplates.page.toast.saveUnexpectedError': 'Wystąpił nieoczekiwany błąd podczas zapisywania',
'settings.page.usage.title': 'Użycie',
'settings.page.voice.title': 'Głos',
'settings.page.voice.description': 'Skonfiguruj mowę i dyktowanie.',
'settings.projects.actions.actions.add': 'Dodaj akcję',
'settings.projects.actions.actions.save': 'Zapisz akcje',
'settings.projects.actions.description': 'Polecenia dla projektu wyświetlane w nagłówku obok nazwy projektu.',
@@ -1841,6 +1892,10 @@ export const settingsDict = {
'settings.view.search.aria': 'Szukaj ustawień',
'settings.view.search.clear': 'Wyczyść wyszukiwanie ustawień',
'settings.view.search.noResults': 'Brak pasujących ustawień',
'settings.view.nav.group.general': 'OpenChamber',
'settings.view.nav.group.projects': 'Przestrzeń robocza',
'settings.view.nav.group.opencode': 'OpenCode',
'settings.view.nav.group.content': 'Biblioteka',
'settings.view.badge.beta': 'beta',
'settings.view.home.cards.agents.description': 'Prompty, narzędzia, uprawnienia',
'settings.view.home.cards.agents.title': 'Agenci',
@@ -1899,6 +1954,7 @@ export const settingsDict = {
'settings.voice.page.preview.customServerLine': 'Cześć! To jest podgląd własnego serwera TTS.',
'settings.voice.page.preview.voiceLine': 'Cześć! Jestem {voiceName}. Tak brzmi mój głos.',
'settings.voice.page.provider.browser': 'Przeglądarka',
'settings.voice.page.provider.openai': 'OpenAI',
'settings.voice.page.provider.custom': 'Własny',
'settings.voice.page.provider.say': 'Say',
'settings.voice.page.provider.server': 'Serwer',
@@ -47,6 +47,10 @@ export const settingsDict = {
"settings.view.search.aria": "Buscar configurações",
"settings.view.search.clear": "Limpar busca de configurações",
"settings.view.search.noResults": "Nenhuma configuração encontrada",
"settings.view.nav.group.general": "OpenChamber",
"settings.view.nav.group.projects": "Espaço de trabalho",
"settings.view.nav.group.opencode": "OpenCode",
"settings.view.nav.group.content": "Biblioteca",
"settings.page.projects.title": "Projetos",
"settings.page.remoteInstances.title": "Instâncias remotas",
"settings.page.providers.title": "Provedores",
@@ -59,13 +63,22 @@ export const settingsDict = {
"settings.page.skillsCatalog.title": "Catálogo de habilidades",
"settings.page.git.title": "Git",
"settings.page.appearance.title": "Aparência",
"settings.page.appearance.description": "Personalize a aparência e a sensação do OpenChamber.",
"settings.page.chat.title": "Chat",
"settings.page.chat.description": "Configure como as mensagens e as ferramentas são exibidas.",
"settings.page.shortcuts.title": "Atalhos",
"settings.page.shortcuts.description": "Personalize os atalhos de teclado.",
"settings.page.general.title": "Geral",
"settings.page.general.description": "Inicialização do aplicativo, segurança, conexão e privacidade.",
"settings.page.sessions.title": "Sessões",
"settings.page.sessions.description": "Defina padrões e retenção das sessões.",
"settings.page.magicPrompts.title": "Prompts mágicos",
"settings.page.notifications.title": "Notificações",
"settings.page.notifications.description": "Escolha quando e como receber notificações.",
"settings.page.voice.title": "Voz",
"settings.page.tunnel.title": "Túnel remoto",
"settings.page.voice.description": "Configure fala e ditado.",
"settings.page.tunnel.title": "Túnel externo",
"settings.page.tunnel.description": "Exponha esta instância por um túnel remoto.",
"settings.page.about.title": "Sobre",
"settings.page.snippets.title": "Snippets",
"settings.snippets.sidebar.title": "Snippets",
@@ -90,7 +103,7 @@ export const settingsDict = {
"settings.snippets.page.toast.contentRequired": "O conteúdo do snippet é obrigatório",
"settings.snippets.page.toast.saveFailed": "Falha ao salvar snippet",
"settings.snippets.page.toast.saved": "Snippet salvo",
"settings.openchamber.tunnel.title": "Túnel remoto",
"settings.openchamber.tunnel.title": "Túnel externo",
"settings.openchamber.tunnel.description": "Configure acesso remoto seguro com links rápidos ou com seu próprio túnel gerenciado da Cloudflare.",
"settings.openchamber.tunnel.note.serverSideEnforced": "O acesso seguro ao túnel é aplicado no servidor.",
"settings.openchamber.tunnel.note.connectLinksOneTime": "Os links de conexão são de uso único: eles são revogados quando o túnel para ou quando o TTL do link expira.",
@@ -366,6 +379,7 @@ export const settingsDict = {
"settings.remoteInstances.page.logsDialog.selectedInstanceFallback": "Instância selecionada",
"settings.remoteInstances.page.patternDialog.descriptionWithHost": "{host} exige um destino concreto.",
"settings.remoteInstances.page.patternDialog.description": "Digite destino.",
"settings.common.infoAria": "Mais informações",
"settings.common.actions.cancel": "Cancelar",
"settings.common.actions.create": "Criar",
"settings.common.actions.delete": "Excluir",
@@ -376,6 +390,8 @@ export const settingsDict = {
"settings.common.actions.copyAll": "Copiar tudo",
"settings.common.actions.clear": "Limpar",
"settings.common.actions.saving": "Salvando...",
'settings.common.status.saved': 'Salvo',
'settings.common.status.saveFailed': 'Falha ao salvar',
"settings.common.actions.saveChanges": "Salvar alterações",
"settings.common.scope.global": "Global",
"settings.common.scope.project": "Projeto",
@@ -460,6 +476,33 @@ export const settingsDict = {
"settings.agents.page.section.identityRole": "Identidade e função",
"settings.agents.page.section.modelParameters": "Modelo e parâmetros",
"settings.agents.page.section.systemPrompt": "Prompt do sistema",
"settings.agents.page.permissionsEditor.sectionInfo": "Edita as regras de permissão do próprio agente exatamente como estão salvas na config. “Herdar” deixa a chave sem definição — valem a config global e os padrões.",
"settings.agents.page.permissionsEditor.defaultLabel": "Padrão para todas as ferramentas",
"settings.agents.page.permissionsEditor.defaultAria": "Permissão padrão para todas as ferramentas",
"settings.agents.page.permissionsEditor.defaultInfo": "O fallback no nível do agente (a chave *). Vale para ferramentas sem regra própria.",
"settings.agents.page.permissionsEditor.effectiveHint": "→ {action}",
"settings.agents.page.permissionsEditor.ruleCount": "{count} regras",
"settings.agents.page.permissionsEditor.keyAria": "Permissão para {key}",
"settings.agents.page.permissionsEditor.patternPlaceholder": "Padrão, ex.: rm -rf * ou /path/**",
"settings.agents.page.permissionsEditor.patternActionAria": "Ação para o padrão de {key}",
"settings.agents.page.permissionsEditor.customKeyPlaceholder": "Chave de permissão personalizada…",
"settings.agents.page.permissionsEditor.sessionRulesTitle": "Concedidas na sessão (não salvas)",
"settings.agents.page.permissionsEditor.sessionRulesInfo": "Regras concedidas em sessões ao vivo (respostas “sempre permitir”). Valem agora, mas não fazem parte da config salva e somem ao reiniciar.",
"settings.agents.page.permissionsEditor.action.default": "Padrão",
"settings.agents.page.permissionsEditor.action.inherit": "Herdar",
"settings.agents.page.permissionsEditor.action.allow": "Permitir",
"settings.agents.page.permissionsEditor.action.ask": "Perguntar",
"settings.agents.page.permissionsEditor.action.deny": "Negar",
"settings.agents.page.permissionsEditor.actions.save": "Salvar permissões",
"settings.agents.page.permissionsEditor.actions.discard": "Descartar",
"settings.agents.page.permissionsEditor.actions.addRule": "Adicionar regra",
"settings.agents.page.permissionsEditor.actions.addKey": "Adicionar chave",
"settings.agents.page.permissionsEditor.actions.removeRuleAria": "Remover regra",
"settings.agents.page.permissionsEditor.actions.retry": "Tentar novamente",
"settings.agents.page.permissionsEditor.state.loadFailed": "Não foi possível carregar a config de permissões do agente.",
"settings.agents.page.permissionsEditor.toast.saved": "Permissões salvas",
"settings.agents.page.permissionsEditor.toast.savedRestartRequired": "Permissões salvas — reinicie o servidor OpenCode para aplicar",
"settings.agents.page.permissionsEditor.toast.saveFailed": "Falha ao salvar as permissões",
"settings.agents.page.section.toolPermissions": "Permissões de ferramentas",
"settings.agents.page.field.agentName": "Nome do agente",
"settings.agents.page.field.agentNamePlaceholder": "nome-agente",
@@ -495,6 +538,7 @@ export const settingsDict = {
"settings.agents.page.permissions.permissionPlaceholder": "Permissão...",
"settings.agents.page.permissions.patternPlaceholder": "Padrão (ex.: *)",
"settings.page.behavior.title": "Comportamento",
"settings.page.behavior.description": "Oriente como o agente responde.",
"settings.behavior.page.title": "Comportamento",
"settings.behavior.page.warning.title": "Regras globais são combinadas com regras do projeto",
"settings.behavior.page.warning.description": "As alterações feitas aqui atualizam {path}. O OpenCode também inclui qualquer AGENTS.md a nível de projeto quando existir.",
@@ -880,6 +924,8 @@ export const settingsDict = {
"settings.openchamber.desktopNetwork.field.allowLanAccessDescription": "Reinicia o aplicativo para que os telefones, tablets e outros computadores em seu Wi-Fi possam abri-lo.",
"settings.openchamber.desktopNetwork.field.warning": "Aviso: enquanto estiver habilitado, o aplicativo ficará acessível a qualquer pessoa na mesma rede local.",
"settings.openchamber.desktopNetwork.field.passwordRequiredWarning": "O acesso LAN exige uma senha da UI do desktop. Até configurar uma, o app de desktop inicia apenas localmente.",
"settings.openchamber.desktopPassword.actions.showPassword": "Mostrar senha",
"settings.openchamber.desktopPassword.actions.hidePassword": "Ocultar senha",
"settings.openchamber.desktopPassword.field.password": "Senha da UI do desktop",
"settings.openchamber.desktopPassword.field.passwordPlaceholder": "Nenhuma senha obrigatória",
"settings.openchamber.desktopPassword.field.passwordDescription": "O OpenChamber pede após reiniciar e depois quando a sessão expira: em 12 horas, ou 7 dias com Confiar neste dispositivo. Deixe vazio para desativar o login.",
@@ -1585,6 +1631,7 @@ export const settingsDict = {
"settings.voice.page.section.playbackAndSummary": "Reprodução",
"settings.voice.page.field.provider": "Provedor",
"settings.voice.page.provider.browser": "Navegador",
"settings.voice.page.provider.openai": "OpenAI",
"settings.voice.page.provider.custom": "Personalizado",
"settings.voice.page.provider.say": "Falar",
"settings.voice.page.provider.server": "Servidor",
@@ -1664,18 +1711,24 @@ export const settingsDict = {
"settings.voice.page.field.ttsInputModeRaw": "Markdown bruto",
"settings.voice.page.field.ttsInputModeSummarized": "resumido",
"settings.openchamber.visual.section.colorMode": "Modo de cor",
"settings.openchamber.visual.section.colorModeAndTheme": "Modo de cor e tema",
"settings.openchamber.visual.section.mobileLayout": "Layout móvel",
"settings.openchamber.visual.option.mobileLayout.default": "Anterior",
"settings.openchamber.visual.option.mobileLayout.new": "Novo",
"settings.openchamber.visual.section.localization": "Localização",
"settings.openchamber.visual.section.spacingAndLayout": "Espaçamento e layout",
"settings.openchamber.visual.section.densityAndType": "Densidade e tipografia",
"settings.openchamber.visual.section.appInstall": "Instalação do app",
"settings.openchamber.visual.section.navigation": "Navegação",
"settings.openchamber.visual.section.chatRenderMode": "Modo de renderizado de chat",
"settings.openchamber.visual.section.chatRenderModeAria": "Modo de renderizado de chat",
"settings.openchamber.visual.section.chatDisplay": "Exibição",
"settings.openchamber.visual.section.chatMessageOptions": "Opções de mensagem",
"settings.openchamber.visual.section.chatFeatures": "Recursos",
"settings.openchamber.visual.section.messageStreamTransport": "Transporte do fluxo de mensagens",
"settings.openchamber.visual.section.activityDefault": "Modo padrão de atividade",
"settings.openchamber.visual.section.activityDefaultAria": "Modo padrão de atividade",
"settings.openchamber.visual.section.showToolsOpenedByDefault": "Mostrar ferramentas abertas por padrão:",
"settings.openchamber.visual.section.showToolsOpenedByDefault": "Mostrar ferramentas abertas por padrão",
"settings.openchamber.visual.section.sessionAssistance": "Assistência da sessão",
"settings.openchamber.visual.section.reasoning": "Raciocínio",
"settings.openchamber.visual.section.messageAppearance": "Aparência das mensagens",
@@ -1806,8 +1859,11 @@ export const settingsDict = {
"settings.openchamber.visual.field.sendAnonymousUsageReports": "Enviar relatórios anônimos de uso",
"settings.openchamber.visual.field.sendAnonymousUsageReportsHint": "Ajuda-nos a entender quais versões do aplicativo são usadas ativamente para priorizar melhorias. Coletamos apenas a versão do aplicativo, a plataforma e o ambiente de execução; não coletamos dados pessoais nem código.",
"settings.openchamber.visual.option.themeMode.system": "Sistema",
"settings.openchamber.visual.option.themeMode.system.description": "Seguir configuração do sistema",
"settings.openchamber.visual.option.themeMode.light": "Claro",
"settings.openchamber.visual.option.themeMode.light.description": "Sempre usar aparência clara",
"settings.openchamber.visual.option.themeMode.dark": "Escuro",
"settings.openchamber.visual.option.themeMode.dark.description": "Sempre usar aparência escura",
"settings.openchamber.visual.option.diffLayout.dynamic.label": "Dinâmico",
"settings.openchamber.visual.option.diffLayout.dynamic.description": "Novo em linha, modificado lado a lado.",
"settings.openchamber.visual.option.diffLayout.inline.label": "Sempre em linha",
@@ -47,6 +47,10 @@ export const settingsDict = {
"settings.view.search.aria": "Пошук налаштувань",
"settings.view.search.clear": "Очистити пошук налаштувань",
"settings.view.search.noResults": "Нічого не знайдено",
"settings.view.nav.group.general": "OpenChamber",
"settings.view.nav.group.projects": "Робочий простір",
"settings.view.nav.group.opencode": "OpenCode",
"settings.view.nav.group.content": "Бібліотека",
"settings.page.projects.title": "Проєкти",
"settings.page.remoteInstances.title": "Віддалені інстанси",
"settings.page.providers.title": "Провайдери",
@@ -59,13 +63,22 @@ export const settingsDict = {
"settings.page.skillsCatalog.title": "Каталог навичок",
"settings.page.git.title": "Git",
"settings.page.appearance.title": "Зовнішній вигляд",
"settings.page.appearance.description": "Налаштуйте вигляд і відчуття OpenChamber.",
"settings.page.chat.title": "Чат",
"settings.page.chat.description": "Налаштуйте відображення повідомлень і інструментів.",
"settings.page.shortcuts.title": "Гарячі клавіші",
"settings.page.shortcuts.description": "Налаштуйте комбінації клавіш.",
"settings.page.general.title": "Загальні",
"settings.page.general.description": "Запуск застосунку, безпека, з'єднання та приватність.",
"settings.page.sessions.title": "Сесії",
"settings.page.sessions.description": "Встановіть значення за замовчуванням і зберігання сесій.",
"settings.page.magicPrompts.title": "Магічні промпти",
"settings.page.notifications.title": "Сповіщення",
"settings.page.notifications.description": "Оберіть, коли і як отримувати сповіщення.",
"settings.page.voice.title": "Голос",
"settings.page.tunnel.title": "Віддалений тунель",
"settings.page.voice.description": "Налаштуйте мовлення та диктування.",
"settings.page.tunnel.title": "Зовнішній тунель",
"settings.page.tunnel.description": "Відкрийте цей екземпляр через віддалений тунель.",
"settings.page.about.title": "Про застосунок",
"settings.page.snippets.title": "Сніпети",
"settings.snippets.sidebar.title": "Сніпети",
@@ -90,7 +103,7 @@ export const settingsDict = {
"settings.snippets.page.toast.contentRequired": "Вміст сніпета обов'язковий",
"settings.snippets.page.toast.saveFailed": "Не вдалося зберегти сніпет",
"settings.snippets.page.toast.saved": "Сніпет збережено",
"settings.openchamber.tunnel.title": "Віддалений тунель",
"settings.openchamber.tunnel.title": "Зовнішній тунель",
"settings.openchamber.tunnel.description": "Налаштуйте безпечний віддалений доступ за допомогою швидких посилань або власного керованого тунелю Cloudflare.",
"settings.openchamber.tunnel.note.serverSideEnforced": "Безпечний доступ до тунелю забезпечується на стороні сервера.",
"settings.openchamber.tunnel.note.connectLinksOneTime": "Посилання для підключення одноразові: вони відкликаються, коли тунель зупиняється або минає TTL посилання.",
@@ -366,6 +379,7 @@ export const settingsDict = {
"settings.remoteInstances.page.logsDialog.selectedInstanceFallback": "Вибраний інстанс",
"settings.remoteInstances.page.patternDialog.descriptionWithHost": "Для {host} потрібно вказати конкретне призначення.",
"settings.remoteInstances.page.patternDialog.description": "Введіть призначення.",
"settings.common.infoAria": "Докладніше",
"settings.common.actions.cancel": "Скасувати",
"settings.common.actions.create": "Створити",
"settings.common.actions.delete": "Видалити",
@@ -376,6 +390,8 @@ export const settingsDict = {
"settings.common.actions.copyAll": "Копіювати все",
"settings.common.actions.clear": "Очистити",
"settings.common.actions.saving": "Збереження...",
'settings.common.status.saved': 'Збережено',
'settings.common.status.saveFailed': 'Не вдалося зберегти',
"settings.common.actions.saveChanges": "Зберегти зміни",
"settings.common.scope.global": "Глобальний",
"settings.common.scope.project": "Проєкт",
@@ -460,6 +476,33 @@ export const settingsDict = {
"settings.agents.page.section.identityRole": "Ідентичність і роль",
"settings.agents.page.section.modelParameters": "Модель і параметри",
"settings.agents.page.section.systemPrompt": "Системний промпт",
"settings.agents.page.permissionsEditor.sectionInfo": "Редагує власні правила доступу цього агента рівно так, як вони збережені в його конфігу. «Успадкувати» лишає ключ незаданим — діють глобальний конфіг і дефолти.",
"settings.agents.page.permissionsEditor.defaultLabel": "Дефолт для всіх інструментів",
"settings.agents.page.permissionsEditor.defaultAria": "Дефолтний дозвіл для всіх інструментів",
"settings.agents.page.permissionsEditor.defaultInfo": "Фолбек рівня агента (ключ *). Діє для інструментів без власного правила.",
"settings.agents.page.permissionsEditor.effectiveHint": "→ {action}",
"settings.agents.page.permissionsEditor.ruleCount": "{count} правил",
"settings.agents.page.permissionsEditor.keyAria": "Дозвіл для {key}",
"settings.agents.page.permissionsEditor.patternPlaceholder": "Патерн, напр. rm -rf * або /path/**",
"settings.agents.page.permissionsEditor.patternActionAria": "Дія для патерна {key}",
"settings.agents.page.permissionsEditor.customKeyPlaceholder": "Власний ключ дозволу…",
"settings.agents.page.permissionsEditor.sessionRulesTitle": "Надані в сесії (не збережені)",
"settings.agents.page.permissionsEditor.sessionRulesInfo": "Правила, надані під час живих сесій (відповіді «завжди дозволяти»). Вони діють зараз, але не є частиною збереженого конфігу і зникнуть після рестарту.",
"settings.agents.page.permissionsEditor.action.default": "Стандартно",
"settings.agents.page.permissionsEditor.action.inherit": "Успадкувати",
"settings.agents.page.permissionsEditor.action.allow": "Дозволити",
"settings.agents.page.permissionsEditor.action.ask": "Питати",
"settings.agents.page.permissionsEditor.action.deny": "Заборонити",
"settings.agents.page.permissionsEditor.actions.save": "Зберегти дозволи",
"settings.agents.page.permissionsEditor.actions.discard": "Скасувати",
"settings.agents.page.permissionsEditor.actions.addRule": "Додати правило",
"settings.agents.page.permissionsEditor.actions.addKey": "Додати ключ",
"settings.agents.page.permissionsEditor.actions.removeRuleAria": "Видалити правило",
"settings.agents.page.permissionsEditor.actions.retry": "Повторити",
"settings.agents.page.permissionsEditor.state.loadFailed": "Не вдалося завантажити конфіг дозволів агента.",
"settings.agents.page.permissionsEditor.toast.saved": "Дозволи збережено",
"settings.agents.page.permissionsEditor.toast.savedRestartRequired": "Дозволи збережено — перезапустіть сервер OpenCode, щоб застосувати",
"settings.agents.page.permissionsEditor.toast.saveFailed": "Не вдалося зберегти дозволи",
"settings.agents.page.section.toolPermissions": "Дозволи інструмента",
"settings.agents.page.field.agentName": "Ім'я агента",
"settings.agents.page.field.agentNamePlaceholder": "Ім’я агента",
@@ -495,6 +538,7 @@ export const settingsDict = {
"settings.agents.page.permissions.permissionPlaceholder": "Дозвіл...",
"settings.agents.page.permissions.patternPlaceholder": "Шаблон (наприклад, *)",
"settings.page.behavior.title": "Поведінка",
"settings.page.behavior.description": "Керуйте тим, як агент відповідає.",
"settings.behavior.page.title": "Поведінка",
"settings.behavior.page.warning.title": "Глобальні правила поєднуються з правилами проєкту",
"settings.behavior.page.warning.description": "Зміни тут оновлюють {path}. OpenCode також додає правила з AGENTS.md на рівні проєкту, якщо вони існують.",
@@ -880,6 +924,8 @@ export const settingsDict = {
"settings.openchamber.desktopNetwork.field.allowLanAccessDescription": "Перезапускає застосунок, щоб телефони, планшети та інші комп’ютери в мережі Wi-Fi могли його відкрити.",
"settings.openchamber.desktopNetwork.field.warning": "Попередження: якщо це ввімкнено, застосунок доступний усім у тій самій локальній мережі.",
"settings.openchamber.desktopNetwork.field.passwordRequiredWarning": "Для LAN-доступу потрібен пароль десктопного UI. Доки його не задано, десктопний застосунок запускається лише локально.",
"settings.openchamber.desktopPassword.actions.showPassword": "Показати пароль",
"settings.openchamber.desktopPassword.actions.hidePassword": "Приховати пароль",
"settings.openchamber.desktopPassword.field.password": "Пароль для десктопного UI",
"settings.openchamber.desktopPassword.field.passwordPlaceholder": "Пароль не потрібен",
"settings.openchamber.desktopPassword.field.passwordDescription": "OpenChamber попросить пароль після перезапуску, а потім коли сесія логіну спливе: через 12 годин або через 7 днів із «Довіряти цьому пристрою». Залиште порожнім, щоб вимкнути логін.",
@@ -1585,6 +1631,7 @@ export const settingsDict = {
"settings.voice.page.section.playbackAndSummary": "Відтворення",
"settings.voice.page.field.provider": "Провайдер",
"settings.voice.page.provider.browser": "Браузер",
"settings.voice.page.provider.openai": "OpenAI",
"settings.voice.page.provider.custom": "Власний",
"settings.voice.page.provider.say": "Say",
"settings.voice.page.provider.server": "Сервер",
@@ -1664,18 +1711,24 @@ export const settingsDict = {
"settings.voice.page.field.ttsInputModeRaw": "Сирий Markdown",
"settings.voice.page.field.ttsInputModeSummarized": "скорочений",
"settings.openchamber.visual.section.colorMode": "Режим теми",
"settings.openchamber.visual.section.colorModeAndTheme": "Режим кольору та тема",
"settings.openchamber.visual.section.mobileLayout": "Мобільний макет",
"settings.openchamber.visual.option.mobileLayout.default": "Попередній",
"settings.openchamber.visual.option.mobileLayout.new": "Новий",
"settings.openchamber.visual.section.localization": "Локалізація",
"settings.openchamber.visual.section.spacingAndLayout": "Відступи й компонування",
"settings.openchamber.visual.section.densityAndType": "Щільність і шрифти",
"settings.openchamber.visual.section.appInstall": "Встановлення застосунку",
"settings.openchamber.visual.section.navigation": "Навігація",
"settings.openchamber.visual.section.chatRenderMode": "Режим візуалізації чату",
"settings.openchamber.visual.section.chatRenderModeAria": "Режим візуалізації чату",
"settings.openchamber.visual.section.chatDisplay": "Відображення",
"settings.openchamber.visual.section.chatMessageOptions": "Параметри повідомлень",
"settings.openchamber.visual.section.chatFeatures": "Функції",
"settings.openchamber.visual.section.messageStreamTransport": "Транспорт потокових повідомлень",
"settings.openchamber.visual.section.activityDefault": "Активність за замовчуванням",
"settings.openchamber.visual.section.activityDefaultAria": "Режим активності за замовчуванням",
"settings.openchamber.visual.section.showToolsOpenedByDefault": "Показувати інструменти відкритими за замовчуванням:",
"settings.openchamber.visual.section.showToolsOpenedByDefault": "Показувати інструменти відкритими за замовчуванням",
"settings.openchamber.visual.section.sessionAssistance": "Допомога із сесією",
"settings.openchamber.visual.section.reasoning": "Міркування",
"settings.openchamber.visual.section.messageAppearance": "Вигляд повідомлень",
@@ -1806,8 +1859,11 @@ export const settingsDict = {
"settings.openchamber.visual.field.sendAnonymousUsageReports": "Надсилати анонімні звіти про використання",
"settings.openchamber.visual.field.sendAnonymousUsageReportsHint": "Допомагає нам зрозуміти, які версії застосунків активно використовуються, щоб ми могли визначити пріоритети покращень. Збираються лише версія застосунку, платформа та середовище виконання – без особистих даних чи коду.",
"settings.openchamber.visual.option.themeMode.system": "Системна",
"settings.openchamber.visual.option.themeMode.system.description": "Дотримуватися системних налаштувань",
"settings.openchamber.visual.option.themeMode.light": "Світла",
"settings.openchamber.visual.option.themeMode.light.description": "Завжди використовувати світлий вигляд",
"settings.openchamber.visual.option.themeMode.dark": "Темна",
"settings.openchamber.visual.option.themeMode.dark.description": "Завжди використовувати темний вигляд",
"settings.openchamber.visual.option.diffLayout.dynamic.label": "Динамічний",
"settings.openchamber.visual.option.diffLayout.dynamic.description": "Нові файли показуються inline, змінені — поруч.",
"settings.openchamber.visual.option.diffLayout.inline.label": "Завжди inline",
@@ -47,6 +47,10 @@ export const settingsDict = {
'settings.view.search.aria': '搜索设置',
'settings.view.search.clear': '清除设置搜索',
'settings.view.search.noResults': '没有匹配的设置',
'settings.view.nav.group.general': 'OpenChamber',
'settings.view.nav.group.projects': '工作区',
'settings.view.nav.group.opencode': 'OpenCode',
'settings.view.nav.group.content': '资源库',
'settings.page.projects.title': '项目',
'settings.page.remoteInstances.title': '远程实例',
'settings.page.providers.title': '提供商',
@@ -59,13 +63,22 @@ export const settingsDict = {
'settings.page.skillsCatalog.title': '技能目录',
'settings.page.git.title': 'Git',
'settings.page.appearance.title': '外观',
'settings.page.appearance.description': '自定义 OpenChamber 的外观与体验。',
'settings.page.chat.title': '聊天',
'settings.page.chat.description': '配置消息和工具的显示方式。',
'settings.page.shortcuts.title': '快捷键',
'settings.page.shortcuts.description': '自定义键盘快捷键。',
'settings.page.general.title': '通用',
'settings.page.general.description': '应用启动、安全、连接与隐私。',
'settings.page.sessions.title': '会话',
'settings.page.sessions.description': '设置会话的默认值和保留策略。',
'settings.page.magicPrompts.title': '魔法提示词',
'settings.page.notifications.title': '通知',
'settings.page.notifications.description': '选择何时以及如何接收通知。',
'settings.page.voice.title': '语音',
'settings.page.tunnel.title': '远程隧道',
'settings.page.voice.description': '配置语音朗读与听写。',
'settings.page.tunnel.title': '外部隧道',
'settings.page.tunnel.description': '通过远程隧道公开此实例。',
'settings.page.about.title': '关于',
'settings.page.snippets.title': '代码片段',
'settings.snippets.sidebar.title': '代码片段',
@@ -90,7 +103,7 @@ export const settingsDict = {
'settings.snippets.page.toast.contentRequired': '代码片段内容为必填项',
'settings.snippets.page.toast.saveFailed': '保存代码片段失败',
'settings.snippets.page.toast.saved': '代码片段已保存',
'settings.openchamber.tunnel.title': '远程隧道',
'settings.openchamber.tunnel.title': '外部隧道',
'settings.openchamber.tunnel.description': '通过快速链接或你自己的 Cloudflare Managed Remote 隧道配置安全远程访问。',
'settings.openchamber.tunnel.note.serverSideEnforced': '安全隧道访问在服务端强制执行。',
'settings.openchamber.tunnel.note.connectLinksOneTime': '连接链接为一次性,隧道停止或连接链接 TTL 到期后会被吊销。',
@@ -366,6 +379,7 @@ export const settingsDict = {
'settings.remoteInstances.page.logsDialog.selectedInstanceFallback': '所选实例',
'settings.remoteInstances.page.patternDialog.descriptionWithHost': '{host} 需要一个具体目标地址。',
'settings.remoteInstances.page.patternDialog.description': '输入目标地址。',
'settings.common.infoAria': '更多信息',
'settings.common.actions.cancel': '取消',
'settings.common.actions.create': '创建',
'settings.common.actions.delete': '删除',
@@ -376,6 +390,8 @@ export const settingsDict = {
'settings.common.actions.copyAll': '全部复制',
'settings.common.actions.clear': '清除',
'settings.common.actions.saving': '保存中...',
'settings.common.status.saved': '已保存',
'settings.common.status.saveFailed': '保存失败',
'settings.common.actions.saveChanges': '保存更改',
'settings.common.scope.global': '全局',
'settings.common.scope.project': '项目',
@@ -460,6 +476,33 @@ export const settingsDict = {
'settings.agents.page.section.identityRole': '身份与角色',
'settings.agents.page.section.modelParameters': '模型与参数',
'settings.agents.page.section.systemPrompt': '系统提示词',
'settings.agents.page.permissionsEditor.sectionInfo': '按配置文件中保存的原样编辑此代理自身的权限规则。“继承”表示不设置该键,将应用全局配置和默认值。',
'settings.agents.page.permissionsEditor.defaultLabel': '所有工具的默认值',
'settings.agents.page.permissionsEditor.defaultAria': '所有工具的默认权限',
'settings.agents.page.permissionsEditor.defaultInfo': '代理级别的回退(* 键),适用于没有单独规则的工具。',
'settings.agents.page.permissionsEditor.effectiveHint': '→ {action}',
'settings.agents.page.permissionsEditor.ruleCount': '{count} 条规则',
'settings.agents.page.permissionsEditor.keyAria': '{key} 的权限',
'settings.agents.page.permissionsEditor.patternPlaceholder': '模式,例如 rm -rf * 或 /path/**',
'settings.agents.page.permissionsEditor.patternActionAria': '{key} 模式的操作',
'settings.agents.page.permissionsEditor.customKeyPlaceholder': '自定义权限键…',
'settings.agents.page.permissionsEditor.sessionRulesTitle': '会话中授予(未保存)',
'settings.agents.page.permissionsEditor.sessionRulesInfo': '在会话中授予的规则(如“始终允许”的回答)。当前生效,但不属于已保存的配置,重启后消失。',
'settings.agents.page.permissionsEditor.action.default': '默认',
'settings.agents.page.permissionsEditor.action.inherit': '继承',
'settings.agents.page.permissionsEditor.action.allow': '允许',
'settings.agents.page.permissionsEditor.action.ask': '询问',
'settings.agents.page.permissionsEditor.action.deny': '拒绝',
'settings.agents.page.permissionsEditor.actions.save': '保存权限',
'settings.agents.page.permissionsEditor.actions.discard': '放弃',
'settings.agents.page.permissionsEditor.actions.addRule': '添加规则',
'settings.agents.page.permissionsEditor.actions.addKey': '添加键',
'settings.agents.page.permissionsEditor.actions.removeRuleAria': '删除规则',
'settings.agents.page.permissionsEditor.actions.retry': '重试',
'settings.agents.page.permissionsEditor.state.loadFailed': '无法加载代理的权限配置。',
'settings.agents.page.permissionsEditor.toast.saved': '权限已保存',
'settings.agents.page.permissionsEditor.toast.savedRestartRequired': '权限已保存 — 重启 OpenCode 服务器后生效',
'settings.agents.page.permissionsEditor.toast.saveFailed': '保存权限失败',
'settings.agents.page.section.toolPermissions': '工具权限',
'settings.agents.page.field.agentName': '智能体名称',
'settings.agents.page.field.agentNamePlaceholder': 'agent-name',
@@ -495,6 +538,7 @@ export const settingsDict = {
'settings.agents.page.permissions.permissionPlaceholder': '权限...',
'settings.agents.page.permissions.patternPlaceholder': '模式(例如 *',
'settings.page.behavior.title': '行为',
'settings.page.behavior.description': '引导智能体的回复方式。',
'settings.behavior.page.title': '行为',
'settings.behavior.page.warning.title': '全局规则会与项目规则合并使用',
'settings.behavior.page.warning.description': '此处所做的更改会更新 {path}。当项目级 AGENTS.md 存在时,OpenCode 也会包含它。',
@@ -880,6 +924,8 @@ export const settingsDict = {
'settings.openchamber.desktopNetwork.field.allowLanAccessDescription': '会重启应用,以便手机、平板和同一 Wi‑Fi 下的其他电脑访问。',
'settings.openchamber.desktopNetwork.field.warning': '警告:启用后,同一本地网络中的任何人都可访问此应用。',
'settings.openchamber.desktopNetwork.field.passwordRequiredWarning': '局域网访问需要桌面 UI 密码。在设置密码之前,桌面应用只会以本机访问模式启动。',
'settings.openchamber.desktopPassword.actions.showPassword': '显示密码',
'settings.openchamber.desktopPassword.actions.hidePassword': '隐藏密码',
'settings.openchamber.desktopPassword.field.password': '桌面 UI 密码',
'settings.openchamber.desktopPassword.field.passwordPlaceholder': '不需要密码',
'settings.openchamber.desktopPassword.field.passwordDescription': 'OpenChamber 会在重启后要求输入密码,之后在登录会话过期时再次要求:12 小时后,或选择“信任此设备”后 7 天。留空可关闭登录。',
@@ -1585,6 +1631,7 @@ export const settingsDict = {
'settings.voice.page.section.playbackAndSummary': '播放',
'settings.voice.page.field.provider': '提供方',
'settings.voice.page.provider.browser': '浏览器',
'settings.voice.page.provider.openai': 'OpenAI',
'settings.voice.page.provider.custom': '自定义',
'settings.voice.page.provider.say': 'Say',
'settings.voice.page.provider.server': '服务器',
@@ -1664,18 +1711,24 @@ export const settingsDict = {
'settings.voice.page.field.ttsInputModeRaw': '原始 Markdown',
'settings.voice.page.field.ttsInputModeSummarized': '摘要',
'settings.openchamber.visual.section.colorMode': '颜色模式',
'settings.openchamber.visual.section.colorModeAndTheme': '颜色模式与主题',
'settings.openchamber.visual.section.mobileLayout': '移动端布局',
'settings.openchamber.visual.option.mobileLayout.default': '旧版',
'settings.openchamber.visual.option.mobileLayout.new': '新版',
'settings.openchamber.visual.section.localization': '本地化',
'settings.openchamber.visual.section.spacingAndLayout': '间距与布局',
'settings.openchamber.visual.section.densityAndType': '密度与字体',
'settings.openchamber.visual.section.appInstall': '应用安装',
'settings.openchamber.visual.section.navigation': '导航',
'settings.openchamber.visual.section.chatRenderMode': '聊天渲染模式',
'settings.openchamber.visual.section.chatRenderModeAria': '聊天渲染模式',
'settings.openchamber.visual.section.chatDisplay': '显示',
'settings.openchamber.visual.section.chatMessageOptions': '消息选项',
'settings.openchamber.visual.section.chatFeatures': '功能',
'settings.openchamber.visual.section.messageStreamTransport': '消息流传输方式',
'settings.openchamber.visual.section.activityDefault': '活动默认状态',
'settings.openchamber.visual.section.activityDefaultAria': '活动默认模式',
'settings.openchamber.visual.section.showToolsOpenedByDefault': '默认展开以下工具',
'settings.openchamber.visual.section.showToolsOpenedByDefault': '默认展开以下工具',
'settings.openchamber.visual.section.sessionAssistance': '会话辅助',
'settings.openchamber.visual.section.reasoning': '推理',
'settings.openchamber.visual.section.messageAppearance': '消息外观',
@@ -1806,8 +1859,11 @@ export const settingsDict = {
'settings.openchamber.visual.field.sendAnonymousUsageReports': '发送匿名使用报告',
'settings.openchamber.visual.field.sendAnonymousUsageReportsHint': '帮助我们了解哪些应用版本正在被积极使用,以便优先改进。仅收集应用版本、平台和运行时信息,不收集个人数据或代码。',
'settings.openchamber.visual.option.themeMode.system': '跟随系统',
'settings.openchamber.visual.option.themeMode.system.description': '跟随系统设置',
'settings.openchamber.visual.option.themeMode.light': '浅色',
'settings.openchamber.visual.option.themeMode.light.description': '始终使用浅色外观',
'settings.openchamber.visual.option.themeMode.dark': '深色',
'settings.openchamber.visual.option.themeMode.dark.description': '始终使用深色外观',
'settings.openchamber.visual.option.diffLayout.dynamic.label': '动态',
'settings.openchamber.visual.option.diffLayout.dynamic.description': '新增文件用行内视图,修改文件用并排视图。',
'settings.openchamber.visual.option.diffLayout.inline.label': '始终行内',
@@ -45,6 +45,10 @@
'settings.view.search.aria': '搜尋設定',
'settings.view.search.clear': '清除設定搜尋',
'settings.view.search.noResults': '沒有符合的設定',
'settings.view.nav.group.general': 'OpenChamber',
'settings.view.nav.group.projects': '工作區',
'settings.view.nav.group.opencode': 'OpenCode',
'settings.view.nav.group.content': '資源庫',
'settings.page.projects.title': '專案',
'settings.page.remoteInstances.title': '遠端執行個體',
'settings.page.providers.title': '供應商',
@@ -56,13 +60,22 @@
'settings.page.skillsCatalog.title': 'Skills 目錄',
'settings.page.git.title': 'Git',
'settings.page.appearance.title': '外觀',
'settings.page.appearance.description': '自訂 OpenChamber 的外觀與體驗。',
'settings.page.chat.title': '聊天',
'settings.page.chat.description': '設定訊息與工具的顯示方式。',
'settings.page.shortcuts.title': '快速鍵',
'settings.page.shortcuts.description': '自訂鍵盤快速鍵。',
'settings.page.general.title': '一般',
'settings.page.general.description': '應用程式啟動、安全性、連線與隱私。',
'settings.page.sessions.title': '工作階段',
'settings.page.sessions.description': '設定工作階段的預設值與保留期限。',
'settings.page.magicPrompts.title': '魔法提示詞',
'settings.page.notifications.title': '通知',
'settings.page.notifications.description': '選擇何時以及如何收到通知。',
'settings.page.voice.title': '語音',
'settings.page.tunnel.title': 'Remote Tunnel',
'settings.page.voice.description': '設定語音朗讀與聽寫。',
'settings.page.tunnel.title': '外部通道',
'settings.page.tunnel.description': '透過遠端通道公開此實例。',
'settings.page.about.title': '關於',
'settings.page.snippets.title': '程式片段',
'settings.snippets.sidebar.title': '程式片段',
@@ -87,7 +100,7 @@
'settings.snippets.page.toast.contentRequired': '程式片段內容為必填項',
'settings.snippets.page.toast.saveFailed': '保存程式片段失敗',
'settings.snippets.page.toast.saved': '程式片段已保存',
'settings.openchamber.tunnel.title': 'Remote Tunnel',
'settings.openchamber.tunnel.title': '外部通道',
'settings.openchamber.tunnel.description': '透過快速連結或你自己的 Cloudflare managed remote tunnel 設定安全遠端存取。',
'settings.openchamber.tunnel.note.serverSideEnforced': '安全通道存取由伺服器端強制執行。',
'settings.openchamber.tunnel.note.connectLinksOneTime': '連線連結為一次性,Tunnel 停止或連線連結 TTL 到期後會被撤銷。',
@@ -363,6 +376,7 @@
'settings.remoteInstances.page.logsDialog.selectedInstanceFallback': '所選執行個體',
'settings.remoteInstances.page.patternDialog.descriptionWithHost': '{host} 需要一個具體目標地址。',
'settings.remoteInstances.page.patternDialog.description': '輸入目標地址。',
'settings.common.infoAria': '更多資訊',
'settings.common.actions.cancel': '取消',
'settings.common.actions.create': '建立',
'settings.common.actions.delete': '刪除',
@@ -373,6 +387,8 @@
'settings.common.actions.copyAll': '全部複製',
'settings.common.actions.clear': '清除',
'settings.common.actions.saving': '儲存中...',
'settings.common.status.saved': '已儲存',
'settings.common.status.saveFailed': '儲存失敗',
'settings.common.actions.saveChanges': '儲存變更',
'settings.common.scope.global': '全域',
'settings.common.scope.project': '專案',
@@ -457,6 +473,33 @@
'settings.agents.page.section.identityRole': '身分與角色',
'settings.agents.page.section.modelParameters': '模型與參數',
'settings.agents.page.section.systemPrompt': '系統提示詞',
'settings.agents.page.permissionsEditor.sectionInfo': '按設定檔中儲存的原樣編輯此代理自身的權限規則。「繼承」表示不設定該鍵,將套用全域設定與預設值。',
'settings.agents.page.permissionsEditor.defaultLabel': '所有工具的預設值',
'settings.agents.page.permissionsEditor.defaultAria': '所有工具的預設權限',
'settings.agents.page.permissionsEditor.defaultInfo': '代理層級的後備(* 鍵),適用於沒有個別規則的工具。',
'settings.agents.page.permissionsEditor.effectiveHint': '→ {action}',
'settings.agents.page.permissionsEditor.ruleCount': '{count} 條規則',
'settings.agents.page.permissionsEditor.keyAria': '{key} 的權限',
'settings.agents.page.permissionsEditor.patternPlaceholder': '模式,例如 rm -rf * 或 /path/**',
'settings.agents.page.permissionsEditor.patternActionAria': '{key} 模式的動作',
'settings.agents.page.permissionsEditor.customKeyPlaceholder': '自訂權限鍵…',
'settings.agents.page.permissionsEditor.sessionRulesTitle': '工作階段中授予(未儲存)',
'settings.agents.page.permissionsEditor.sessionRulesInfo': '在工作階段中授予的規則(如「一律允許」的回答)。目前生效,但不屬於已儲存的設定,重新啟動後會消失。',
'settings.agents.page.permissionsEditor.action.default': '預設',
'settings.agents.page.permissionsEditor.action.inherit': '繼承',
'settings.agents.page.permissionsEditor.action.allow': '允許',
'settings.agents.page.permissionsEditor.action.ask': '詢問',
'settings.agents.page.permissionsEditor.action.deny': '拒絕',
'settings.agents.page.permissionsEditor.actions.save': '儲存權限',
'settings.agents.page.permissionsEditor.actions.discard': '放棄',
'settings.agents.page.permissionsEditor.actions.addRule': '新增規則',
'settings.agents.page.permissionsEditor.actions.addKey': '新增鍵',
'settings.agents.page.permissionsEditor.actions.removeRuleAria': '刪除規則',
'settings.agents.page.permissionsEditor.actions.retry': '重試',
'settings.agents.page.permissionsEditor.state.loadFailed': '無法載入代理的權限設定。',
'settings.agents.page.permissionsEditor.toast.saved': '權限已儲存',
'settings.agents.page.permissionsEditor.toast.savedRestartRequired': '權限已儲存 — 重新啟動 OpenCode 伺服器後生效',
'settings.agents.page.permissionsEditor.toast.saveFailed': '儲存權限失敗',
'settings.agents.page.section.toolPermissions': '工具權限',
'settings.agents.page.field.agentName': 'Agent 名稱',
'settings.agents.page.field.agentNamePlaceholder': 'agent-name',
@@ -492,6 +535,7 @@
'settings.agents.page.permissions.permissionPlaceholder': '權限...',
'settings.agents.page.permissions.patternPlaceholder': '模式(例如 *',
'settings.page.behavior.title': '行為',
'settings.page.behavior.description': '引導代理的回應方式。',
'settings.behavior.page.title': '行為',
'settings.behavior.page.warning.title': '全域規則會與專案規則合併使用',
'settings.behavior.page.warning.description': '此處所做的變更會更新 {path}。當專案級 AGENTS.md 存在時,OpenCode 也會包含它。',
@@ -1496,6 +1540,7 @@
'settings.voice.page.section.playbackAndSummary': '播放',
'settings.voice.page.field.provider': '提供方',
'settings.voice.page.provider.browser': '瀏覽器',
'settings.voice.page.provider.openai': 'OpenAI',
'settings.voice.page.provider.custom': '自訂',
'settings.voice.page.provider.say': 'Say',
'settings.voice.page.provider.server': '伺服器',
@@ -1575,16 +1620,22 @@
'settings.voice.page.field.ttsInputModeRaw': '原始 Markdown',
'settings.voice.page.field.ttsInputModeSummarized': '摘要',
'settings.openchamber.visual.section.colorMode': '顏色模式',
'settings.openchamber.visual.section.colorModeAndTheme': '色彩模式與主題',
'settings.openchamber.visual.section.localization': '在地化',
'settings.openchamber.visual.section.spacingAndLayout': '間距與佈局',
'settings.openchamber.visual.section.densityAndType': '密度與字型',
'settings.openchamber.visual.section.appInstall': '應用程式安裝',
'settings.openchamber.visual.section.mobileLayout': '行動版版面',
'settings.openchamber.visual.section.navigation': '導覽',
'settings.openchamber.visual.section.chatRenderMode': '聊天渲染模式',
'settings.openchamber.visual.section.chatRenderModeAria': '聊天渲染模式',
'settings.openchamber.visual.section.chatDisplay': '顯示',
'settings.openchamber.visual.section.chatMessageOptions': '訊息選項',
'settings.openchamber.visual.section.chatFeatures': '功能',
'settings.openchamber.visual.section.messageStreamTransport': '訊息流傳輸方式',
'settings.openchamber.visual.section.activityDefault': '活動預設狀態',
'settings.openchamber.visual.section.activityDefaultAria': '活動預設模式',
'settings.openchamber.visual.section.showToolsOpenedByDefault': '預設展開以下工具',
'settings.openchamber.visual.section.showToolsOpenedByDefault': '預設展開以下工具',
'settings.openchamber.visual.section.sessionAssistance': '工作階段輔助',
'settings.openchamber.visual.section.reasoning': '推理',
'settings.openchamber.visual.section.messageAppearance': '訊息外觀',
@@ -1717,8 +1768,11 @@
'settings.openchamber.visual.field.sendAnonymousUsageReports': '送出匿名使用報告',
'settings.openchamber.visual.field.sendAnonymousUsageReportsHint': '協助我們了解哪些應用程式版本仍在被積極使用,以便優先改進。僅收集應用程式版本、平台與執行階段資訊,不收集個人資料或程式碼。',
'settings.openchamber.visual.option.themeMode.system': '跟隨系統',
'settings.openchamber.visual.option.themeMode.system.description': '跟隨系統設定',
'settings.openchamber.visual.option.themeMode.light': '淺色',
'settings.openchamber.visual.option.themeMode.light.description': '一律使用淺色外觀',
'settings.openchamber.visual.option.themeMode.dark': '深色',
'settings.openchamber.visual.option.themeMode.dark.description': '一律使用深色外觀',
'settings.openchamber.visual.option.diffLayout.dynamic.label': '動態',
'settings.openchamber.visual.option.diffLayout.dynamic.description': '新增檔案用行內檢視,修改檔案用並排檢視。',
'settings.openchamber.visual.option.diffLayout.inline.label': '始終行內',
@@ -1877,6 +1931,8 @@
'settings.openchamber.desktopNetwork.field.keepAwakeAria': 'OpenChamber 執行時保持電腦喚醒',
'settings.openchamber.desktopNetwork.field.keepAwake': 'OpenChamber 執行時保持電腦喚醒',
'settings.openchamber.desktopNetwork.field.keepAwakeDescription': '讓手機可以持續開啟此應用程式。螢幕仍可關閉。',
'settings.openchamber.desktopPassword.actions.showPassword': '顯示密碼',
'settings.openchamber.desktopPassword.actions.hidePassword': '隱藏密碼',
'settings.openchamber.desktopPassword.field.password': '桌面 UI 密碼',
'settings.openchamber.desktopPassword.field.passwordDescription': 'OpenChamber 會在重新啟動後要求輸入密碼,之後會在登入工作階段過期時再次要求:12 小時後,或選擇「信任此裝置」後 7 天。留空可停用登入。',
'settings.openchamber.desktopPassword.field.passwordPlaceholder': '不需要密碼',
+24 -1
View File
@@ -5,7 +5,14 @@ import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { startModelPrefsAutoSave } from '@/lib/modelPrefsAutoSave';
import { startAppearanceAutoSave } from '@/lib/appearanceAutoSave';
import { useUIStore } from '@/stores/useUIStore';
import { applyPersistedHomeDirectoryToWindow, invalidateSettingsCache, syncDesktopSettings, updateDesktopSettings } from './persistence';
import {
applyPersistedHomeDirectoryToWindow,
getSettingsSaveState,
invalidateSettingsCache,
subscribeToSettingsSaveState,
syncDesktopSettings,
updateDesktopSettings,
} from './persistence';
import { switchRuntimeEndpoint } from './runtime-switch';
type TestWindow = {
@@ -216,6 +223,22 @@ describe('updateDesktopSettings', () => {
expect(secondResolved).toBe(true);
});
test('publishes saving and saved states for an immediate setting update', async () => {
const states: string[] = [];
registerSettingsSave(async (changes) => changes as SettingsPayload);
const unsubscribe = subscribeToSettingsSaveState(() => {
states.push(getSettingsSaveState());
});
try {
await updateDesktopSettings({ useSystemTheme: false, themeVariant: 'light' });
// Success is silent: the shared state machine maps 'saved' back to 'idle'.
expect(states).toEqual(['saving', 'idle']);
} finally {
unsubscribe();
}
});
test('drains a pending save to the previous runtime and ignores its stale response', async () => {
switchRuntimeEndpoint({ apiBaseUrl: 'https://settings-a.example', runtimeKey: 'settings-a' });
const saveResult = deferred<SettingsPayload>();
+60 -2
View File
@@ -142,6 +142,51 @@ const dispatchSettingsSynced = (settings: DesktopSettings): void => {
window.dispatchEvent(new CustomEvent<DesktopSettings>('openchamber:settings-synced', { detail: settings }));
};
type SettingsSaveState = 'idle' | 'saving' | 'error';
let _settingsSaveState: SettingsSaveState = 'idle';
let _settingsSaveStateResetTimer: ReturnType<typeof setTimeout> | null = null;
const _settingsSaveStateListeners = new Set<() => void>();
export const getSettingsSaveState = (): SettingsSaveState => _settingsSaveState;
export const subscribeToSettingsSaveState = (listener: () => void): (() => void) => {
_settingsSaveStateListeners.add(listener);
return () => _settingsSaveStateListeners.delete(listener);
};
/**
* Drive the shared settings save indicator from pages that persist through
* their own APIs instead of updateDesktopSettings. 'error' resets to idle.
*/
export const reportSettingsSaveState = (state: 'saving' | 'saved' | 'error'): void => {
dispatchSettingsSaveState(state);
};
const dispatchSettingsSaveState = (state: 'saving' | 'saved' | 'error'): void => {
if (_settingsSaveStateResetTimer) {
clearTimeout(_settingsSaveStateResetTimer);
_settingsSaveStateResetTimer = null;
}
// Quiet indicator: success is the normal case and renders nothing ('saved' → idle);
// only in-flight saves and failures surface in the UI.
const nextState: SettingsSaveState = state === 'saved' ? 'idle' : state;
if (nextState !== _settingsSaveState) {
_settingsSaveState = nextState;
_settingsSaveStateListeners.forEach((listener) => listener());
}
if (nextState === 'error') {
_settingsSaveStateResetTimer = setTimeout(() => dispatchSettingsSaveState('saved'), 6000);
}
if (typeof window === 'undefined') {
return;
}
window.dispatchEvent(new CustomEvent<'saving' | 'saved' | 'error'>('openchamber:settings-save-state', { detail: state }));
};
type PersistApi = {
hasHydrated?: () => boolean;
onFinishHydration?: (callback: () => void) => (() => void) | undefined;
@@ -1522,7 +1567,11 @@ async function _flushSettingsUpdate(): Promise<void> {
_settingsFlushTimer = null;
_settingsFlushWaiters = [];
try {
if (!changes || !context || Object.keys(changes).length === 0 || !isSettingsRuntimeContextCurrent(context)) return;
if (!changes || !context || Object.keys(changes).length === 0 || !isSettingsRuntimeContextCurrent(context)) {
// Nothing will be written — clear any pending "Saving…" indicator.
dispatchSettingsSaveState('saved');
return;
}
const runtimeSettings = getRuntimeSettingsAPI();
if (runtimeSettings) {
@@ -1535,6 +1584,7 @@ async function _flushSettingsUpdate(): Promise<void> {
dispatchSettingsSynced(updated);
_settingsCache = null;
}
dispatchSettingsSaveState(updated ? 'saved' : 'error');
return;
} catch (error) {
if (!isSettingsRuntimeContextCurrent(context)) return;
@@ -1556,6 +1606,7 @@ async function _flushSettingsUpdate(): Promise<void> {
if (!isSettingsRuntimeContextCurrent(context)) return;
if (!response.ok) {
console.warn('Failed to update shared settings via API:', response.status, response.statusText);
dispatchSettingsSaveState('error');
return;
}
@@ -1565,11 +1616,17 @@ async function _flushSettingsUpdate(): Promise<void> {
persistToLocalStorage(updated);
applyDesktopUiPreferences(updated);
dispatchSettingsSynced(updated);
dispatchSettingsSaveState('saved');
// Invalidate GET cache so next read sees the fresh data
_settingsCache = null;
} else {
dispatchSettingsSaveState('error');
}
} catch (error) {
if (isSettingsRuntimeContextCurrent(context)) console.warn('Failed to update shared settings via API:', error);
if (isSettingsRuntimeContextCurrent(context)) {
console.warn('Failed to update shared settings via API:', error);
dispatchSettingsSaveState('error');
}
}
} finally {
waiters.forEach((resolve) => resolve());
@@ -1590,6 +1647,7 @@ export const updateDesktopSettings = async (changes: Partial<DesktopSettings>):
_pendingSettingsChanges = { ...(_pendingSettingsChanges ?? {}), ...changes };
_pendingSettingsContext = context;
dispatchSettingsSaveState('saving');
if (_settingsFlushTimer) {
clearTimeout(_settingsFlushTimer);
+20 -16
View File
@@ -2,6 +2,7 @@ import type { SidebarSection } from '@/constants/sidebar';
export type SettingsPageSlug =
| 'home'
| 'general'
| 'projects'
| 'remote-instances'
| 'providers'
@@ -26,14 +27,10 @@ export type SettingsPageSlug =
| 'about';
type SettingsPageGroup =
| 'appearance'
| 'projects'
| 'general'
| 'projects'
| 'opencode'
| 'git'
| 'skills'
| 'usage'
| 'advanced';
| 'content';
export interface SettingsRuntimeContext {
isVSCode: boolean;
@@ -61,6 +58,13 @@ export const SETTINGS_PAGE_METADATA: readonly SettingsPageMeta[] = [
description: 'Search and jump to common pages.',
keywords: ['search', 'settings'],
},
{
slug: 'general',
title: 'General',
group: 'general',
kind: 'single',
keywords: ['general', 'startup', 'launch at login', 'autostart', 'tray', 'password', 'passkey', 'security', 'privacy', 'telemetry', 'transport', 'network', 'lan', 'binary', 'cli'],
},
{
slug: 'projects',
title: 'Projects',
@@ -86,7 +90,7 @@ export const SETTINGS_PAGE_METADATA: readonly SettingsPageMeta[] = [
{
slug: 'usage',
title: 'Usage',
group: 'usage',
group: 'general',
kind: 'split',
keywords: ['quota', 'billing', 'tokens', 'usage', 'limits'],
},
@@ -128,21 +132,21 @@ export const SETTINGS_PAGE_METADATA: readonly SettingsPageMeta[] = [
{
slug: 'skills.installed',
title: 'Skills',
group: 'skills',
group: 'content',
kind: 'split',
keywords: ['skill', 'skills', 'instructions', 'install', 'catalog'],
},
{
slug: 'skills.catalog',
title: 'Skills Catalog',
group: 'skills',
group: 'content',
kind: 'single',
keywords: ['install', 'catalog', 'external', 'repository', 'skills catalog'],
},
{
slug: 'git',
title: 'Git',
group: 'git',
group: 'projects',
kind: 'single',
keywords: ['git', 'github', 'identity', 'identities', 'ssh', 'profiles', 'credentials', 'keys', 'commit', 'gitmoji', 'oauth', 'prs', 'issues'],
isAvailable: (ctx) => !ctx.isVSCode,
@@ -150,7 +154,7 @@ export const SETTINGS_PAGE_METADATA: readonly SettingsPageMeta[] = [
{
slug: 'appearance',
title: 'Appearance',
group: 'appearance',
group: 'general',
kind: 'single',
keywords: ['theme', 'font', 'spacing', 'padding', 'corner radius', 'radius', 'input bar', 'keyboard', 'viewport', 'mobile', 'terminal', 'pwa', 'install name', 'app shortcuts'],
},
@@ -179,7 +183,7 @@ export const SETTINGS_PAGE_METADATA: readonly SettingsPageMeta[] = [
{
slug: 'magic-prompts',
title: 'Magic Prompts',
group: 'general',
group: 'content',
kind: 'split',
keywords: ['prompts', 'templates', 'git', 'github', 'review', 'commit', 'pull request'],
isAvailable: (ctx) => !ctx.isVSCode,
@@ -187,15 +191,15 @@ export const SETTINGS_PAGE_METADATA: readonly SettingsPageMeta[] = [
{
slug: 'snippets',
title: 'Snippets',
group: 'general',
group: 'content',
kind: 'split',
keywords: ['prompt', 'templates', 'multi-run', 'strategy', 'approach'],
},
{ slug: 'notifications', title: 'Notifications', group: 'general', kind: 'single', keywords: ['alerts', 'native', 'summary', 'summarization'], },
{ slug: 'voice', title: 'Voice', group: 'advanced', kind: 'single', keywords: ['tts', 'speech', 'voice'], isAvailable: (ctx) => !ctx.isVSCode },
{ slug: 'tunnel', title: 'Remote Tunnel', group: 'advanced', kind: 'single', keywords: ['tunnel', 'cloudflare', 'qr', 'remote', 'mobile', 'share'], isAvailable: (ctx) => !ctx.isVSCode },
{ slug: 'about', title: 'About', group: 'advanced', kind: 'single', keywords: ['about', 'version', 'updates', 'release', 'changelog'], isAvailable: (ctx) => ctx.isMobile },
{ slug: 'voice', title: 'Voice', group: 'general', kind: 'single', keywords: ['tts', 'speech', 'voice'], isAvailable: (ctx) => !ctx.isVSCode },
{ slug: 'tunnel', title: 'External Tunnel', group: 'projects', kind: 'single', keywords: ['tunnel', 'external', 'cloudflare', 'qr', 'remote', 'mobile', 'share'], isAvailable: (ctx) => !ctx.isVSCode },
{ slug: 'about', title: 'About', group: 'general', kind: 'single', keywords: ['about', 'version', 'updates', 'release', 'changelog'], isAvailable: (ctx) => ctx.isMobile && !ctx.isVSCode },
] as const;
const LEGACY_SIDEBAR_SECTION_TO_SETTINGS_SLUG: Record<SidebarSection, SettingsPageSlug> = {
+17 -14
View File
@@ -118,7 +118,7 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
},
{
id: 'appearance.terminal-shell',
page: 'appearance',
page: 'general',
titleKey: 'settings.openchamber.visual.field.terminalShell',
descriptionKey: 'settings.openchamber.visual.field.terminalShellHint',
keywords: ['terminal', 'shell', 'bash', 'zsh', 'fish', 'pwsh', 'powershell'],
@@ -142,22 +142,25 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
titleKey: 'settings.openchamber.visual.field.inputBarOffset',
descriptionKey: 'settings.openchamber.visual.field.inputBarOffsetTooltip',
keywords: ['input', 'home bar', 'offset'],
// Only the mobile composer applies this offset (ChatInput gates on isMobile).
isAvailable: (ctx) => ctx.isMobile,
},
{
id: 'appearance.expanded-editor-toolbar',
page: 'appearance',
page: 'general',
titleKey: 'settings.openchamber.visual.field.expandedEditorToolbar',
keywords: ['editor', 'toolbar', 'tabs', 'docked', 'files'],
isAvailable: (ctx) => !ctx.isVSCode,
},
{
id: 'appearance.file-editor-keymap',
page: 'appearance',
page: 'general',
titleKey: 'settings.openchamber.visual.field.fileEditorKeymap',
keywords: ['editor', 'vim', 'keymap'],
},
{
id: 'appearance.terminal-quick-keys',
page: 'appearance',
page: 'general',
titleKey: 'settings.openchamber.visual.field.terminalQuickKeys',
descriptionKey: 'settings.openchamber.visual.field.terminalQuickKeysTooltip',
keywords: ['terminal', 'keyboard', 'esc', 'ctrl', 'arrows'],
@@ -165,7 +168,7 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
},
{
id: 'appearance.usage-reports',
page: 'appearance',
page: 'general',
titleKey: 'settings.openchamber.visual.field.sendAnonymousUsageReports',
descriptionKey: 'settings.openchamber.visual.field.sendAnonymousUsageReportsHint',
keywords: ['telemetry', 'analytics'],
@@ -178,7 +181,7 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
},
{
id: 'chat.message-transport',
page: 'chat',
page: 'general',
titleKey: 'settings.openchamber.visual.section.messageStreamTransport',
keywords: ['streaming', 'sse', 'websocket'],
},
@@ -379,7 +382,7 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
},
{
id: 'sessions.desktop-launch-at-login',
page: 'sessions',
page: 'general',
titleKey: 'settings.openchamber.desktopNetwork.field.launchAtLogin',
descriptionKey: 'settings.openchamber.desktopNetwork.field.launchAtLoginDescription',
keywords: ['desktop', 'startup', 'login'],
@@ -387,7 +390,7 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
},
{
id: 'sessions.desktop-window-controls-position',
page: 'sessions',
page: 'general',
titleKey: 'settings.openchamber.desktopNetwork.field.windowControlsPosition',
descriptionKey: 'settings.openchamber.desktopNetwork.field.windowControlsPositionDescription',
keywords: ['desktop', 'window', 'controls', 'minimize', 'maximize', 'close', 'titlebar', 'linux', 'windows'],
@@ -395,7 +398,7 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
},
{
id: 'sessions.desktop-minimize-to-tray',
page: 'sessions',
page: 'general',
titleKey: 'settings.openchamber.desktopNetwork.field.minimizeToTray',
descriptionKey: 'settings.openchamber.desktopNetwork.field.minimizeToTrayDescription',
keywords: ['desktop', 'tray', 'system tray', 'minimize', 'close', 'background', 'windows'],
@@ -403,7 +406,7 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
},
{
id: 'sessions.desktop-keep-awake',
page: 'sessions',
page: 'general',
titleKey: 'settings.openchamber.desktopNetwork.field.keepAwake',
descriptionKey: 'settings.openchamber.desktopNetwork.field.keepAwakeDescription',
keywords: ['desktop', 'sleep', 'awake', 'server', 'mobile', 'phone'],
@@ -411,7 +414,7 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
},
{
id: 'sessions.desktop-ui-password',
page: 'sessions',
page: 'general',
titleKey: 'settings.openchamber.desktopPassword.field.password',
descriptionKey: 'settings.openchamber.desktopPassword.field.passwordDescription',
keywords: ['desktop', 'password', 'auth', 'login'],
@@ -419,7 +422,7 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
},
{
id: 'sessions.desktop-lan-access',
page: 'sessions',
page: 'general',
titleKey: 'settings.openchamber.desktopNetwork.field.allowLanAccess',
descriptionKey: 'settings.openchamber.desktopNetwork.field.allowLanAccessDescription',
keywords: ['desktop', 'lan', 'network', 'phone', 'tablet'],
@@ -427,14 +430,14 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
},
{
id: 'sessions.opencode-binary',
page: 'sessions',
page: 'general',
titleKey: 'settings.openchamber.opencodeCli.field.binaryPath',
keywords: ['opencode', 'cli', 'binary', 'path'],
isAvailable: (ctx) => !ctx.isVSCode,
},
{
id: 'sessions.opencode-update-notifications',
page: 'sessions',
page: 'general',
titleKey: 'settings.openchamber.opencodeCli.field.showUpdateNotifications',
keywords: ['opencode', 'cli', 'updates'],
isAvailable: (ctx) => !ctx.isVSCode,
@@ -564,6 +564,8 @@ const sidebarBaseRgb = hexToRgb(theme.colors.surface.muted);
vars.push(` --text-meta: ${typography.meta};`);
vars.push(' /* Micro text - badges, shortcuts, indicators */');
vars.push(` --text-micro: ${typography.micro};`);
vars.push(' /* Settings page titles - larger than section headers */');
vars.push(` --text-settings-page-title: ${typography.settingsPageTitle};`);
vars.push(' /* Heading line height and letter spacing */');
vars.push(' --h1-line-height: 1.25rem;');
+3
View File
@@ -5,6 +5,8 @@ export const SEMANTIC_TYPOGRAPHY = {
uiLabel: '0.8750rem',
meta: '0.875rem',
micro: '0.875rem',
/** Settings page / detail-pane title — larger than section headers */
settingsPageTitle: '1.125rem',
} as const;
export const VSCODE_TYPOGRAPHY = {
@@ -15,6 +17,7 @@ export const VSCODE_TYPOGRAPHY = {
uiLabel: '0.8438rem',
meta: '0.8438rem',
micro: '0.7813rem',
settingsPageTitle: '1.0625rem',
} as const;
export type SemanticTypographyKey = keyof typeof SEMANTIC_TYPOGRAPHY;
+1 -1
View File
@@ -39,7 +39,7 @@ const getCurrentDirectory = (): string | null => {
return null;
};
const getConfigDirectory = (): string | null => {
export const getConfigDirectory = (): string | null => {
try {
const projectsStore = useProjectsStore.getState();
const activeProject = projectsStore.getActiveProject?.();
@@ -10,7 +10,7 @@ import {
discoverGitCredentials,
getGlobalGitIdentity
} from "@/lib/gitApi";
import { updateDesktopSettings } from "@/lib/persistence";
import { reportSettingsSaveState, updateDesktopSettings } from "@/lib/persistence";
import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry";
import { runtimeFetch } from "@/lib/runtime-fetch";
@@ -206,11 +206,14 @@ export const useGitIdentitiesStore = create<GitIdentitiesStore>()(
icon: profileData.icon || 'branch'
};
reportSettingsSaveState('saving');
await createGitIdentity(profile);
reportSettingsSaveState('saved');
await get().loadProfiles();
return true;
} catch (error) {
reportSettingsSaveState('error');
console.error("Failed to create git identity profile:", error);
return false;
}
@@ -225,11 +228,14 @@ export const useGitIdentitiesStore = create<GitIdentitiesStore>()(
}
const updated = { ...existing, ...updates };
reportSettingsSaveState('saving');
await updateGitIdentity(id, updated);
reportSettingsSaveState('saved');
await get().loadProfiles();
return true;
} catch (error) {
reportSettingsSaveState('error');
console.error("Failed to update git identity profile:", error);
return false;
}
@@ -237,7 +243,9 @@ export const useGitIdentitiesStore = create<GitIdentitiesStore>()(
deleteProfile: async (id) => {
try {
reportSettingsSaveState('saving');
await deleteGitIdentity(id);
reportSettingsSaveState('saved');
if (get().selectedProfileId === id) {
set({ selectedProfileId: null });
@@ -246,6 +254,7 @@ export const useGitIdentitiesStore = create<GitIdentitiesStore>()(
await get().loadProfiles();
return true;
} catch (error) {
reportSettingsSaveState('error');
console.error("Failed to delete git identity profile:", error);
return false;
}
+31 -8
View File
@@ -24,6 +24,7 @@
--text-ui-label: 0.8750rem;
--text-meta: 0.875rem;
--text-micro: 0.875rem;
--text-settings-page-title: 1.125rem;
/* Default Light Theme */
--background: oklch(0.97 0.02 85); /* Warm sand background */
@@ -45,7 +46,7 @@
--border: oklch(0.85 0.02 70); /* Warm border */
--input: oklch(0.88 0.02 75); /* Input background */
--ring: oklch(0.65 0.2 55); /* Focus ring color */
--radius: 0.625rem;
--radius: 0.5625rem;
--chart-1: oklch(0.58 0.15 230); /* Blue */
--chart-2: oklch(0.58 0.15 145); /* Oasis green */
--chart-3: oklch(0.65 0.2 55); /* Orange */
@@ -189,26 +190,48 @@
display: list-item !important;
}
/* Remove focus rings globally (except inputs/textareas/dropdowns/selects) */
*:focus:not(input):not(textarea):not([data-slot="dropdown-menu-content"]):not([data-slot="dropdown-menu-sub-content"]):not([data-slot="select-content"]) {
/* Remove focus rings globally (except inputs/textareas/dropdowns/selects/explicit accent rings) */
*:focus:not(input):not(textarea):not([data-slot="dropdown-menu-content"]):not([data-slot="dropdown-menu-sub-content"]):not([data-slot="select-content"]):not(.focus-ring-accent):not([data-focus-ring="accent"]) {
outline: none !important;
box-shadow: none !important;
}
*:focus-visible:not(input):not(textarea):not([data-slot="dropdown-menu-content"]):not([data-slot="dropdown-menu-sub-content"]):not([data-slot="select-content"]) {
*:focus-visible:not(input):not(textarea):not([data-slot="dropdown-menu-content"]):not([data-slot="dropdown-menu-sub-content"]):not([data-slot="select-content"]):not(.focus-ring-accent):not([data-focus-ring="accent"]) {
outline: none !important;
box-shadow: none !important;
}
.focus-ring-accent:focus-visible,
[data-focus-ring="accent"]:focus-visible {
outline: 2px solid var(--primary-base) !important;
outline-offset: 2px !important;
box-shadow: none !important;
}
}
@theme inline {
--radius-none: 0;
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
--radius-lg: 0.625rem;
--radius-xl: 0.75rem;
--radius-sm: 0.25rem; /* 4px */
--radius-md: 0.4375rem; /* 7px */
--radius-lg: 0.5625rem; /* 9px */
--radius-xl: 0.75rem; /* 12px */
--radius-full: 9999px;
/* Semantic spacing aliases (4/8px grid) */
--space-1: var(--spacing-1); /* 4px */
--space-2: var(--spacing-2); /* 8px */
--space-3: var(--spacing-3); /* 12px */
--space-4: var(--spacing-4); /* 16px */
--space-6: var(--spacing-6); /* 24px */
--space-8: var(--spacing-8); /* 32px */
/* Elevation / stacking */
--z-base: 0;
--z-raised: 10;
--z-overlay: 40;
--z-modal: 50;
--transition-fast: 150ms ease-in-out;
/* Override Tailwind spacing scale with custom properties that respect --padding-scale */
--spacing-0: 0;
--spacing-0\.5: calc(0.125rem * var(--padding-scale, 1));
+2
View File
@@ -48,6 +48,7 @@
--text-ui-label: 0.875rem;
--text-meta: 0.875rem;
--text-micro: 0.8125rem;
--text-settings-page-title: 1.125rem;
}
/* Force override with higher specificity */
@@ -58,6 +59,7 @@
--text-ui-label: 0.875rem !important;
--text-meta: 0.875rem !important;
--text-micro: 0.8125rem !important;
--text-settings-page-title: 1.125rem !important;
}
/* Additional override for elements using typography classes */
+56
View File
@@ -10,18 +10,74 @@
.typography-ui-header {
font-size: var(--text-ui-header);
line-height: 1.45;
}
.typography-ui-label {
font-size: var(--text-ui-label);
line-height: 1.45;
}
.typography-meta {
font-size: var(--text-meta);
line-height: 1.45;
}
.typography-micro {
font-size: var(--text-micro);
line-height: 1.45;
}
/*
* Settings heading scale (by context):
* L1 page title typography-settings-page-title (+ text-muted-foreground)
* L2 section typography-settings-section-title (+ text-foreground)
* L3 group typography-settings-group-title (+ text-foreground)
* L4 field label typography-settings-field-label (+ text-foreground)
* typography-settings-title remains as L2 alias for older call sites.
*/
.typography-settings-page-title {
font-size: var(--text-settings-page-title);
font-weight: 600;
line-height: 1.25;
letter-spacing: -0.01em;
}
.typography-settings-section-title,
.typography-settings-title {
font-size: var(--text-ui-header);
font-weight: 600;
line-height: 1.2;
}
.typography-settings-group-title {
font-size: var(--text-ui-label);
font-weight: 500;
line-height: 1.2;
}
.typography-settings-field-label {
font-size: var(--text-ui-label);
font-weight: 400;
line-height: 1.2;
}
.typography-settings-description {
font-size: var(--text-meta);
font-weight: 400;
line-height: 1.5;
}
.typography-settings-link {
font-size: 0.8125rem; /* 13px */
font-weight: 500;
line-height: 1.2;
color: var(--primary-base);
transition: color var(--transition-fast, 150ms ease-in-out);
}
.typography-settings-link:hover {
text-decoration: underline;
}
.chat-column {