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
+45 -25
View File
@@ -15,54 +15,74 @@ When examples conflict, shared component/theme and localization contracts win. S
## Canonical Direction ## Canonical Direction
- Prefer flat hierarchy built with spacing and typography. Settings are built from the shared primitives in
- Avoid unnecessary cards, wrappers, row chrome, and redundant headings. `packages/ui/src/components/sections/shared/SettingsSection.tsx`,
- Keep controls compact and align related rows consistently. `SettingsPageLayout.tsx`, and `SettingsInfoHint.tsx`. Never hand-roll page
- Put checkbox/radio state before labels. chrome, section headers, field rows, checkbox rows, or info tooltips with raw
- Use subtle, stable selected-state styling without layout shifts. divs — use the primitives, and extend them (in the shared file) when a new
- Preserve responsive wrapping/stacking and long-text behavior. shape is genuinely missing.
- Flat hierarchy through spacing and typography; no cards, boxed backgrounds, or row chrome.
- Secondary helper text is hidden behind an info icon (`info` prop); the default view stays quiet.
- Controls have one standard size (`h-9` / select `size="settings"`) and capped widths — no full-bleed inputs.
- Layouts respond to the settings pane width via container queries (`@xl:` / `@3xl:`), never viewport `sm:`/`lg:` breakpoints (the pane is much narrower than the viewport inside the dialog).
- Checkbox/radio state comes before labels; selected states are subtle and never shift layout.
## Load References By Task ## Load References By Task
| Task | Required reference | | Task | Required reference |
|---|---| |---|---|
| Page hierarchy, typography, spacing, columns, responsive grids | `references/layout.md` | | Page skeleton, sections, hierarchy, nav placement, spacing, columns, responsiveness | `references/layout.md` |
| Chips, radios, checkboxes, numeric overrides, inputs, icon actions, pickers | `references/controls.md` | | Field rows, checkboxes, radios, chips, selects, inputs, numeric steppers, info hints | `references/controls.md` |
| Adding/moving controls, pages, availability, anchors, or search entries | `references/search.md` | | Adding/moving controls, pages, availability, anchors, or search entries | `references/search.md` |
Load every matching reference before editing. Load every matching reference before editing.
## Quick Control Selection ## Quick Primitive Selection
| Need | Shared pattern | | Need | Shared primitive |
|---|---| |---|---|
| Short selectable options | `Button variant="chip" size="xs"` + `aria-pressed` | | Page wrapper (title, description, save status, scrolling, `@container`) | `SettingsPageLayout` |
| Mutually exclusive mode list | `Radio` rows | | Titled block with divider | `SettingsSection` (`divider={false}` for the first one) |
| Boolean | `Checkbox` | | Label left / control right | `SettingsFieldRow` |
| Numeric value/override | `NumberInput` | | Label above control (two-column cells, wide controls) | `SettingsStackedField` |
| Text/path | `Input` with shared adjacent actions | | Boolean | `SettingsCheckboxRow` |
| Icon-only action | `Button size="icon"` + sprite `Icon` + localized `aria-label` | | Mutually exclusive list | `SettingsRadioGroup` + `SettingsRadioOption` |
| Short segmented options | `SettingsChipGroup` |
| Sub-cluster with a quiet L3 title inside a section | `SettingsControlGroup` |
| Two-column area on wide panes | `SettingsTwoColumn` |
| Helper text on demand (hover + tap) | `info` prop or `SettingsInfoHint` |
Do not introduce `ButtonSmall`, direct Remixicon components, hardcoded user-facing strings, or one-off color/button systems. Do not introduce raw `<Tooltip>`-based info icons, direct Remixicon components, hardcoded user-facing strings, or one-off color/button systems. New icons: reference a Remix icon name in code, then run `bun run icons:generate` to add it to the sprite.
## Description Policy (info hints)
- Explanatory prose (what a feature does, when it applies) goes behind the info icon via the `info` prop — never as always-visible `description`.
- Stays visible: security/data-loss warnings, destructive consequences, required syntax/placeholder lists the user reads while typing, dynamic status, empty states, validation errors, active-flow wizard instructions.
- Mixed text: keep the warning sentence visible, move the explanation to `info`.
## Save Feedback
`SettingsPageLayout showSaveStatus` renders the shared quiet indicator: success is silent, "Saving…" appears only past ~500 ms, failures show "Save failed". Anything persisted through `updateDesktopSettings` reports automatically; page-specific APIs must call `reportSettingsSaveState` from `@/lib/persistence`. Never add per-page save badges or success toasts for ordinary setting writes.
## Settings Search Contract ## Settings Search Contract
Every stable Settings control addition or move must consider search in the same change: Every stable Settings control addition or move must consider search in the same change:
- explicit registry item in `packages/ui/src/lib/settings/search.ts` when searchable; - explicit registry item in `packages/ui/src/lib/settings/search.ts` when searchable;
- matching `data-settings-item` anchor; - matching `data-settings-item` anchor (primitives accept `settingsItem`);
- localized title/description keys; - localized title/description keys;
- availability matching actual render conditions; - availability matching actual render conditions;
- state preparation before highlighting conditional targets. - when a control moves to another page, update the item's `page` too.
Dynamic entity rows normally are not indexed. Load `references/search.md` for exact rules. Dynamic entity rows normally are not indexed. Load `references/search.md` for exact rules.
## Review Checklist ## Review Checklist
- Hierarchy reads through spacing and typography without unnecessary boxes. - Built from shared primitives; no ad-hoc page/section/row markup.
- Shared controls are used with localized visible/accessibility text. - Explanatory text hidden behind `info`; warnings/syntax/status still visible.
- Desktop alignment degrades cleanly on narrow/mobile layouts. - Container-query (`@xl:`/`@3xl:`) responsiveness — no viewport breakpoints in pane content.
- Disabled state affects the control, not unrelated labels, unless intentional. - Controls use the standard size and width caps; no stretched full-width inputs.
- Long labels and adjacent actions do not overflow. - Localized visible and accessibility text everywhere.
- Search registry, anchor, localization, and availability agree. - Search registry, anchor, page, localization, and availability agree.
- Nearby Settings precedent and relevant tests remain consistent. - Nearby Settings precedent and relevant tests remain consistent.
@@ -1,83 +1,83 @@
# Settings Controls # Settings Controls
Load `theme-system` for button/icon/color contracts and `locale-ui-patterns` for every visible or accessible string. Load `theme-system` for button/icon/color contracts and `locale-ui-patterns`
for every visible or accessible string. All primitives/constants come from
`packages/ui/src/components/sections/shared/SettingsSection.tsx` (+
`SettingsInfoHint.tsx`).
## Choosing A Control ## Standard Sizes And Widths
- Short chip-like option set: shared `Button variant="chip" size="xs"` with `aria-pressed`. One control size across Settings — `h-8`:
- Explicit mutually exclusive list: shared `Radio`.
- Boolean value: shared `Checkbox`, not paired show/hide buttons.
- Numeric value: shared `NumberInput`.
- Text/path value: shared `Input` plus shared actions.
Do not couple unrelated toggles beneath a synthetic heading. - `SelectTrigger`: `size={SETTINGS_SELECT_SIZE}` ('settings' → h-8, rounded-md, px-3).
- Custom dropdown triggers (ModelSelector / AgentSelector): `SETTINGS_CUSTOM_TRIGGER_CLASS`.
- Text `Input` next to dropdowns: `h-8 rounded-md px-3` (match the trigger footprint).
- Icon action next to a control: `SETTINGS_ICON_BUTTON_CLASS`.
## Segmented Option Widths are capped — never let controls span the pane:
- Field-row control cluster / stacked-field default cap: `max-w-[24rem]` (built into `SettingsStackedField`; use `SETTINGS_CONTROL_CLUSTER_CLASS` elsewhere).
- Field-row selects: `SETTINGS_SELECT_ROW_TRIGGER_CLASS` (full width narrow, `@xl:w-56` wide).
- Stacked-field selects: `SETTINGS_SELECT_TRIGGER_CLASS` (fills the capped container).
- Genuinely full-width content (dialog textareas): opt out with `controlClassName="w-full max-w-none"`.
## Field Rows
```tsx ```tsx
<Button variant="chip" size="xs" aria-pressed={isSelected}> <SettingsFieldRow
{t(labelKey)} label={t('...label')}
</Button> info={t('...hint')} // helper text behind the info icon
settingsItem="page.some-setting"
>
<Select …>
<SelectTrigger size={SETTINGS_SELECT_SIZE} className={SETTINGS_SELECT_ROW_TRIGGER_CLASS} aria-label={t('...aria')}>…
``` ```
## Radio Row Use `SettingsStackedField` (label above control) inside `SettingsTwoColumn`
cells or when the control is wide; same `info` / `settingsItem` props.
## Boolean
```tsx ```tsx
<div role="radiogroup" aria-label={t(groupLabelKey)}> <SettingsCheckboxRow
<div className="flex items-center gap-2 py-0.5"> checked={value}
<Radio checked={selected} onChange={onSelect} ariaLabel={t(labelKey)} /> onChange={setValue}
<span className={cn('typography-ui-label', selected ? 'text-foreground' : 'text-foreground/50')}> label={t('...label')}
{t(labelKey)} ariaLabel={t('...aria')}
</span> info={t('...explanation')} // optional; see Description Policy
</div> settingsItem="page.some-setting"
</div>
```
## Checkbox Row
```tsx
<div className="flex cursor-pointer items-center gap-2 py-1.5">
<Checkbox checked={value} onChange={setValue} ariaLabel={t(labelKey)} />
<span className="typography-ui-label">{t(labelKey)}</span>
</div>
```
Preserve row click and keyboard behavior when the container is interactive.
## Optional Numeric Override
Empty means “inherit/default.” Provide fallback stepping and explicit clear:
```tsx
<NumberInput
value={temperature}
fallbackValue={0.7}
onValueChange={setTemperature}
onClear={() => setTemperature(undefined)}
min={0}
max={2}
step={0.1}
inputMode="decimal"
emptyLabel="—"
/> />
``` ```
Keep reset adjacent. Prefer an info tooltip over persistent helper text when the explanation is secondary. Row click + keyboard toggling are built in. A visible `description` is only
for text that must stay visible (warnings, dynamic status).
## Inputs And Icon Actions ## Mutually Exclusive Options
```tsx ```tsx
<div className="flex items-center gap-2"> <SettingsRadioGroup aria-label={t('...group')}>
<Input className="h-7" /> <SettingsRadioOption selected={…} onSelect={…} label={t('...')} ariaLabel={t('...')} />
<Button variant="outline" size="icon" aria-label={t(browseLabelKey)}> </SettingsRadioGroup>
<Icon name="folder" className="size-4" />
</Button>
</div>
``` ```
- Prefer compact inputs in dense rows. Skip per-option descriptions when labels are self-explanatory. For short
- Avoid large select triggers in Settings. segmented choices use `SettingsChipGroup` (chips with `aria-pressed`).
- Use shared `Button` and sprite `Icon`, never wrapper buttons or direct Remixicon imports.
## Numeric Value / Override
`NumberInput` inside `SETTINGS_NUMBER_STEPPER_ROW_CLASS`, with
`SETTINGS_NUMBER_UNIT_CLASS` for the unit and an adjacent
`SETTINGS_ICON_BUTTON_CLASS` reset button. Never flex-grow the stepper.
Optional overrides: empty means "inherit"; provide `fallbackValue`,
`onClear`, `emptyLabel="—"`.
## Info Hints
`SettingsInfoHint` is the only info-icon implementation: it opens on hover
AND on click (touch devices have no hover), and closes on outside tap.
Prefer the `info` prop of the enclosing primitive; use the component
directly only next to raw labels/headings. Never build info icons from raw
`<Tooltip>` + `<Icon name="information">` — those don't work on mobile.
## Mobile Constraints ## Mobile Constraints
@@ -89,3 +89,10 @@ Keep reset adjacent. Prefer an info tooltip over persistent helper text when the
- Place icon/color palettes beneath their label. - Place icon/color palettes beneath their label.
- Keep option dimensions and gaps consistent. - Keep option dimensions and gaps consistent.
- Use stable border/ring/background selection; avoid scale transforms that shift layout. - Use stable border/ring/background selection; avoid scale transforms that shift layout.
## Dialogs
Dialogs reuse the same primitives (`SettingsCheckboxRow`,
`SETTINGS_FIELD_LABEL_CLASS`, `SettingsStackedField`) and the same sizes.
Dividers between dialog form groups are acceptable; wizard step
instructions guiding an active flow stay visible (not behind info).
@@ -1,53 +1,67 @@
# Settings Layout # Settings Layout
## Visual Hierarchy All primitives and class constants below live in
`packages/ui/src/components/sections/shared/SettingsSection.tsx` and
`SettingsPageLayout.tsx`. Import them; never re-declare local equivalents.
- Prefer spacing and typography over boxed backgrounds. ## Page Skeleton
- Avoid wrappers that mix unrelated controls.
- Omit redundant headings when page context already names the controls.
- Keep controls compact and row chrome minimal.
- Place checkbox/radio state before its label.
- Dim inactive option labels subtly; do not use transform jumps.
## Typography ```tsx
<SettingsPageLayout
title={t('settings.page.x.title')}
description={t('settings.page.x.description')}
showSaveStatus
>
<SettingsSection title={t('...sectionA')} divider={false}>…</SettingsSection>
<SettingsSection title={t('...sectionB')}>…</SettingsSection>
</SettingsPageLayout>
```
Use classes from `packages/ui/src/lib/typography.ts`: - `SettingsPageLayout` owns scrolling, page padding, the `@container` context, and the quiet save indicator (`showSaveStatus`).
- Sections separate with a top border (`divider`, default true); the first section under the page header passes `divider={false}`.
- Section titles are real headers (L2). Do not nest an umbrella section around a list of `SettingsControlGroup`s when each group deserves its own header — promote groups to sections instead (see the Chat page precedent).
- Page title: `typography-ui-header font-semibold text-foreground` ## Hierarchy Levels
- Section header: `typography-ui-header font-medium text-foreground`
- Control group: `typography-ui-header font-medium` or `font-normal` when needed | Level | Component / class | Use |
- Values/labels: `typography-ui-label text-foreground` |---|---|---|
- Helper/meta: `typography-meta text-muted-foreground` or `typography-small text-muted-foreground` | L1 | `SETTINGS_PAGE_TITLE_CLASS` (via `SettingsPageLayout`) | Page title |
- Numeric values: add `tabular-nums` | L2 | `SettingsSection` title (`SETTINGS_SECTION_TITLE_CLASS`) | Section |
| L3 | `SettingsControlGroup` title (`SETTINGS_GROUP_TITLE_CLASS`) | Sub-cluster inside a section |
| L4 | `SETTINGS_FIELD_LABEL_CLASS` | Field / control labels |
| Helper | `SETTINGS_HELPER_CLASS`, `SETTINGS_DESCRIPTION_CLASS` | Rare visible helper text (most goes behind `info`) |
## Navigation Placement
Sidebar groups (`packages/ui/src/lib/settings/metadata.ts`, order in `SettingsView.tsx`):
- **OpenChamber** (`general` group): General, Appearance, Chat, Notifications, Sessions, Shortcuts, Voice, Usage, About.
- **Workspace** (`projects`): Projects, Remote Instances, External Tunnel, Git.
- **OpenCode** (`opencode`): Providers, Agents, Behavior, Commands, MCP, Plugins.
- **Library** (`content`): Magic Prompts, Snippets, Skills, Skills Catalog.
Placement rules:
- **General** hosts app-level settings that don't belong to a feature page: startup/tray/window, network access + UI password, passkeys, OpenCode CLI binary, terminal shell/navigation, message stream transport, privacy.
- Feature pages (Appearance, Chat, Sessions…) keep only settings about that feature. If a setting reads awkwardly on its page, move it to General rather than inventing a new page.
- New pages need metadata, `pageOrder`, nav icon, `settings.page.<slug>.title/description` in every locale, and mobile whitelist (`MOBILE_SETTINGS_PAGES` in `MobileApp.tsx`) when relevant.
## Responsiveness: Container Queries
The settings pane is far narrower than the viewport (3-pane dialog). All
pane content responds to the pane via container queries — `@xl:` (36rem) and
`@3xl:` (48rem) — never viewport `sm:`/`lg:`. `SettingsPageLayout` provides
the `@container` scope; `SettingsFieldRow`, `SettingsTwoColumn`, and the
trigger-width constants already carry the right variants.
Exception: `SettingsView` navigation chrome (outside the pane) uses viewport
`sm:` to give phones 44px touch rows and plain `bg-background`; keep that
pattern when touching nav.
## Spacing ## Spacing
- Keep section-to-section spacing larger than header-to-content spacing. - Sections own vertical rhythm: divider + `py-8` come from `SettingsSection`.
- Typical flat section: header `mb-1 px-1`, content `pt-0 pb-2 px-2`, outer `mb-8`. - Fields inside a column: `SETTINGS_FIELDS_STACK_CLASS` (`space-y-4`).
- Group related controls with `space-y-3` and modest internal padding such as `p-2`. - Checkbox/radio lists: `SETTINGS_OPTION_STACK_CLASS` (`space-y-1.5`).
- Avoid elevated backgrounds, rounded rows, and hover fills without explicit UX value. - Two-column areas: `SettingsTwoColumn` (`@3xl:grid-cols-2`); use `SettingsStackedField` inside cells (a `SettingsFieldRow` overflows half-width columns).
- No elevated backgrounds, rounded rows, or hover fills without explicit UX value.
## Alignment
For consistent desktop columns:
```tsx
<div className="flex items-center gap-8 py-1.5">
<span className="w-56 shrink-0 typography-ui-label">{t(labelKey)}</span>
<div className="flex w-fit items-center gap-2">...</div>
</div>
```
- Let narrow layouts stack or wrap.
- Compare the complete control footprint, including adjacent actions, when matching widths.
- Disable only the unavailable control; do not dim the entire label row by default.
## Responsive Grids
Use a one-column base and introduce columns at a deliberate breakpoint:
```tsx
<div className="grid grid-cols-1 gap-2 md:grid-cols-[14rem_auto] md:gap-x-8" />
```
Template fields commonly use `grid grid-cols-1 gap-2 md:grid-cols-2 md:gap-3` with flat `p-2` cells.
@@ -5,9 +5,10 @@ Settings search uses an explicit registry; it does not scrape JSX.
## Required Integration ## Required Integration
- Add/update items in `packages/ui/src/lib/settings/search.ts`. - Add/update items in `packages/ui/src/lib/settings/search.ts`.
- Add a matching `data-settings-item="..."` anchor to the rendered setting. - Add a matching `data-settings-item="..."` anchor to the rendered setting — shared primitives take it via their `settingsItem` prop.
- Use localized labels/descriptions from every `packages/ui/src/lib/i18n/messages/*.settings.ts` dictionary. - Use localized labels/descriptions from every `packages/ui/src/lib/i18n/messages/*.settings.ts` dictionary.
- For a new top-level page, add metadata in `packages/ui/src/lib/settings/metadata.ts` and searchable content unless the page is purely navigational. - For a new top-level page, add metadata in `packages/ui/src/lib/settings/metadata.ts` and searchable content unless the page is purely navigational; also extend `pageOrder`/nav icon in `SettingsView.tsx` and `MOBILE_SETTINGS_PAGES` in `MobileApp.tsx` when the page applies to mobile.
- When a control moves between pages (e.g. into General), update the registry item's `page` — item `id`s stay stable even if they carry the old page prefix.
## Registry Rules ## Registry Rules
+1
View File
@@ -10,6 +10,7 @@ description: Use when creating or modifying OpenChamber UI components, styling,
- Use semantic OpenChamber theme tokens; never hardcode hex colors or generic Tailwind palette colors. - Use semantic OpenChamber theme tokens; never hardcode hex colors or generic Tailwind palette colors.
- Use shared UI primitives before introducing feature-local controls. - Use shared UI primitives before introducing feature-local controls.
- Use the shared `Button`; do not create button wrappers such as `ButtonSmall` or `ButtonLarge`. - Use the shared `Button`; do not create button wrappers such as `ButtonSmall` or `ButtonLarge`.
- Every dropdown-style value-picker trigger (shows current value, opens a picker) takes its chrome from `dropdownTriggerVariants` in `packages/ui/src/components/ui/dropdown-trigger.ts` (sizes: `sm` dense h-6, `default` forms h-8; native `SelectTrigger` consumes it). Call sites add layout classes only (width/truncation) — never re-declare border/radius/bg/hover. Deliberately chrome-less pickers (chat composer, headers) are the only exception.
- Use the sprite-based `Icon`; never import icons directly from `@remixicon/react`. - Use the sprite-based `Icon`; never import icons directly from `@remixicon/react`.
- Apply hover tokens only to interactive elements. - Apply hover tokens only to interactive elements.
- Use status colors only for actual status/feedback. - Use status colors only for actual status/feedback.
+1
View File
@@ -73,6 +73,7 @@ import { useEdgeSwipeSessionSwitch } from './useEdgeSwipeSessionSwitch';
import { useNativePushRegistration } from './useNativePushRegistration'; import { useNativePushRegistration } from './useNativePushRegistration';
const MOBILE_SETTINGS_PAGES = [ const MOBILE_SETTINGS_PAGES = [
'general',
'appearance', 'appearance',
'chat', 'chat',
'notifications', '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"/>`, "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"/>`, "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": `<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-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"/>`, "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"/>`, "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 <SelectTrigger
id={id} id={id}
size="lg" size="lg"
className={cn( className={cn('max-w-full', className)}
'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,
)}
> >
<SelectValue placeholder={t('multirun.agentSelector.placeholder')} /> <SelectValue placeholder={t('multirun.agentSelector.placeholder')} />
</SelectTrigger> </SelectTrigger>
@@ -9,6 +9,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { useGitStore, useGitBranches, useGitLoadingBranches, useGitLoadingStatus, useIsGitRepo } from '@/stores/useGitStore'; import { useGitStore, useGitBranches, useGitLoadingBranches, useGitLoadingStatus, useIsGitRepo } from '@/stores/useGitStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { getRootBranch } from '@/lib/worktrees/worktreeStatus'; import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
@@ -171,7 +172,7 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
<SelectTrigger <SelectTrigger
id={id} id={id}
size="lg" 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')} /> <SelectValue placeholder={isLoading ? t('multiRun.branchSelector.status.loadingBranches') : t('multiRun.branchSelector.placeholder.selectSourceBranch')} />
</SelectTrigger> </SelectTrigger>
@@ -1,5 +1,5 @@
import React from 'react'; 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { ProviderLogo } from '@/components/ui/ProviderLogo'; import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { Icon } from "@/components/icon/Icon"; 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"> <div className="flex flex-wrap gap-1.5 items-center">
{/* Add model button (dropdown trigger) */} {/* Add model button (dropdown trigger) */}
<div className={cn('relative', containerClassName)} ref={dropdownRef}> <div className={cn('relative', containerClassName)} ref={dropdownRef}>
<Button <button
ref={triggerRef} ref={triggerRef}
type="button" type="button"
variant="outline" data-popup-open={isOpen ? '' : undefined}
size="sm"
className={cn( className={cn(
CHIP_HEIGHT_CLASS, dropdownTriggerVariants({ size: 'default' }),
'!border-border/80 !bg-[var(--surface-subtle)] hover:!bg-[var(--interactive-hover)]/70', 'w-fit',
addButtonClassName, addButtonClassName,
)} )}
disabled={!canAddModel} 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" />} {triggerIcon ?? <Icon name="add" className="h-3.5 w-3.5 mr-1" />}
{addButtonLabel ?? t('multirun.modelMultiSelect.actions.addModel')} {addButtonLabel ?? t('multirun.modelMultiSelect.actions.addModel')}
</Button> </button>
{isOpen ? ( {isOpen ? (
<div <div
@@ -324,8 +323,8 @@ export const ModelMultiSelect: React.FC<ModelMultiSelectProps> = ({
}} }}
> >
<SelectTrigger <SelectTrigger
size="chip" size="sm"
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" className="gap-1.5"
> >
<Icon name="brain-ai-3" <Icon name="brain-ai-3"
className={cn( className={cn(
@@ -211,7 +211,7 @@ export function MultiRunFusionDialog({
maxModels={1} maxModels={1}
addButtonLabel={selectedModelLabel} addButtonLabel={selectedModelLabel}
showChips={false} 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" dropdownSide="bottom"
dropdownClassName="w-[min(28rem,calc(100vw-8rem))]" dropdownClassName="w-[min(28rem,calc(100vw-8rem))]"
triggerIcon={providerID ? <ProviderLogo providerId={providerID} className="h-3.5 w-3.5 mr-1" /> : undefined} 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 ? ( {variantKeys.length > 0 ? (
<Select value={variant || '__default__'} onValueChange={(value) => setVariant(value === '__default__' ? '' : value)}> <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" /> <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> <SelectValue>{(value) => value === '__default__' ? t('multirun.modelMultiSelect.variant.default') : value}</SelectValue>
</SelectTrigger> </SelectTrigger>
@@ -231,7 +231,7 @@ export function MultiRunFusionDialog({
</Select> </Select>
) : null} ) : 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>
<div className="space-y-2"> <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 { SidebarGroup } from '@/components/sections/shared/SidebarGroup';
import { Icon } from "@/components/icon/Icon"; import { Icon } from "@/components/icon/Icon";
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { SETTINGS_PANEL_TITLE_CLASS } from '@/components/sections/shared/SettingsSection';
interface AgentsSidebarProps { interface AgentsSidebarProps {
onItemSelect?: () => void; onItemSelect?: () => void;
@@ -350,7 +351,7 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
return ( return (
<div className={cn('flex h-full flex-col', bgClass)}> <div className={cn('flex h-full flex-col', bgClass)}>
<div className="border-b px-3 pt-4 pb-3"> <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" /> <SettingsProjectSelector className="mb-3" />
<div className="flex items-center justify-between gap-2"> <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> <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 { useDeviceInfo } from '@/lib/device';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger';
import { useConfigStore } from '@/stores/useConfigStore'; import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { ModelPickerList, type ModelPickerEntry, type ModelPickerProvider } from '@/components/model-picker/ModelPickerList'; import { ModelPickerList, type ModelPickerEntry, type ModelPickerProvider } from '@/components/model-picker/ModelPickerList';
@@ -90,7 +91,15 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
}), [placeholder, t]); }), [placeholder, t]);
const selectedModel = providerId && modelId ? { providerID: providerId, modelID: modelId } : null; 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 = ( const picker = (
<ModelPickerList <ModelPickerList
@@ -123,8 +132,8 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
onClick={isReady ? () => setIsMobilePanelOpen(true) : undefined} onClick={isReady ? () => setIsMobilePanelOpen(true) : undefined}
disabled={!isReady} disabled={!isReady}
className={cn( 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', dropdownTriggerVariants(),
!isReady && 'opacity-60 cursor-not-allowed', 'w-full',
className, className,
)} )}
> >
@@ -158,7 +167,8 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
<DropdownMenu open={isReady && isDropdownOpen} onOpenChange={isReady ? setIsDropdownOpen : undefined}> <DropdownMenu open={isReady && isDropdownOpen} onOpenChange={isReady ? setIsDropdownOpen : undefined}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<div className={cn( <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', !isReady && 'opacity-60 cursor-not-allowed',
className, 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 React from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { toast } from '@/components/ui'; import { toast } from '@/components/ui';
import { useI18n, type I18nKey } from '@/lib/i18n'; import { useI18n, type I18nKey } from '@/lib/i18n';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { reportSettingsSaveState } from '@/lib/persistence';
import { Checkbox } from '@/components/ui/checkbox';
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -13,7 +11,6 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Icon } from "@/components/icon/Icon";
import { import {
getResponseStylePresetInstructions, getResponseStylePresetInstructions,
isResponseStylePreset, isResponseStylePreset,
@@ -22,6 +19,14 @@ import {
} from '@/lib/responseStyle'; } from '@/lib/responseStyle';
import type { DesktopSettings } from '@/lib/desktop'; import type { DesktopSettings } from '@/lib/desktop';
import { runtimeFetch } from '@/lib/runtime-fetch'; 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'; 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 saveBehaviorSetting = async (settings: Partial<DesktopSettings>, fallbackError: string) => {
const response = await runtimeFetch('/api/config/settings', { reportSettingsSaveState('saving');
method: 'PUT', try {
headers: { const response = await runtimeFetch('/api/config/settings', {
'Content-Type': 'application/json', method: 'PUT',
Accept: 'application/json', headers: {
}, 'Content-Type': 'application/json',
body: JSON.stringify(settings), Accept: 'application/json',
}); },
body: JSON.stringify(settings),
});
if (!response.ok) { if (!response.ok) {
throw new Error(await readApiError(response, fallbackError)); 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 ( return (
<ScrollableOverlay outerClassName="h-full" className="w-full"> <SettingsPageLayout
<div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8 space-y-6"> title={t('settings.behavior.page.title')}
<div className="space-y-1"> description={t('settings.page.behavior.description')}
<h2 className="typography-ui-header font-semibold text-foreground"> showSaveStatus
{t('settings.behavior.page.title')} >
</h2> <SettingsSection
</div> title={t('settings.behavior.page.section.systemPrompt')}
info={(
<div data-settings-item="behavior.system-prompt"> <div className="space-y-1">
<div className="mb-1 px-1"> <p className="font-medium text-foreground">
<div className="flex items-center gap-1.5"> {t('settings.behavior.page.warning.title')}
<h3 className="typography-ui-header font-medium text-foreground"> </p>
{t('settings.behavior.page.section.systemPrompt')} <p>
</h3> {t('settings.behavior.page.warning.description', { path: AGENTS_MD_PATH })}
<Tooltip> </p>
<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>
</div> </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"> <SettingsSection
<Textarea title={t('settings.behavior.page.section.responseStyle')}
value={prompt} info={t('settings.behavior.page.responseStyle.tooltip')}
onChange={(e) => setPrompt(e.target.value)} settingsItem="behavior.response-style"
placeholder={t('settings.behavior.page.field.systemPromptPlaceholder')} contentClassName="space-y-3"
rows={12} >
disabled={isLoading} <SettingsCheckboxRow
outerClassName="min-h-[160px] max-h-[70vh]" checked={responseStyleEnabled}
className="w-full font-mono typography-meta bg-transparent" onChange={setResponseStyleEnabled}
/> disabled={isLoading}
<Button label={t('settings.behavior.page.responseStyle.enable')}
onClick={handleSave} ariaLabel={t('settings.behavior.page.responseStyle.enableAria')}
disabled={isSaving || !isPromptDirty || isLoading} />
size="xs"
className="!font-normal"
>
{isSaving ? t('settings.common.actions.saving') : t('settings.common.actions.saveChanges')}
</Button>
</section>
</div>
<div data-settings-item="behavior.response-style"> <SettingsFieldRow
<div className="mb-1 px-1"> label={t('settings.behavior.page.responseStyle.preset')}
<div className="flex items-center gap-1.5"> alignEnd={false}
<h3 className="typography-ui-header font-medium text-foreground"> >
{t('settings.behavior.page.section.responseStyle')} <Select<ResponseStyleValue>
</h3> value={responseStylePreset}
<Tooltip> onValueChange={(value) => setResponseStylePreset(value)}
<TooltipTrigger asChild> disabled={isLoading || !responseStyleEnabled}
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" /> >
</TooltipTrigger> <SelectTrigger size={SETTINGS_SELECT_SIZE} className={SETTINGS_SELECT_ROW_TRIGGER_CLASS}>
<TooltipContent sideOffset={8} className="max-w-xs"> <SelectValue>
{t('settings.behavior.page.responseStyle.tooltip')} {(value) => {
</TooltipContent> if (value === 'custom') return t('settings.behavior.page.responseStyle.option.custom');
</Tooltip> if (isResponseStylePreset(value)) return t(RESPONSE_STYLE_OPTION_LABEL_KEYS[value]);
</div> return null;
</div> }}
</SelectValue>
<section className="px-2 pb-2 pt-0 space-y-3"> </SelectTrigger>
<label className="flex items-center gap-2 typography-ui-label text-foreground"> <SelectContent>
<Checkbox {RESPONSE_STYLE_PRESETS.map((preset) => (
checked={responseStyleEnabled} <SelectItem key={preset} value={preset}>
onChange={setResponseStyleEnabled} {t(RESPONSE_STYLE_OPTION_LABEL_KEYS[preset])}
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')}
</SelectItem> </SelectItem>
</SelectContent> ))}
</Select> <SelectItem value="custom">
{t('settings.behavior.page.responseStyle.option.custom')}
</SelectItem>
</SelectContent>
</Select>
</SettingsFieldRow>
<Textarea <Textarea
value={responseStylePreview} value={responseStylePreview}
onChange={(event) => setResponseStyleCustomInstructions(event.target.value)} onChange={(event) => setResponseStyleCustomInstructions(event.target.value)}
placeholder={t('settings.behavior.page.responseStyle.customPlaceholder')} placeholder={t('settings.behavior.page.responseStyle.customPlaceholder')}
rows={5} rows={5}
disabled={isLoading || !responseStyleEnabled || responseStylePreset !== 'custom'} disabled={isLoading || !responseStyleEnabled || responseStylePreset !== 'custom'}
outerClassName="min-h-[120px]" outerClassName="min-h-[120px]"
className="w-full font-mono typography-meta bg-transparent" className="w-full font-mono typography-meta bg-transparent"
/> />
</section> </SettingsSection>
</div> </SettingsPageLayout>
</div>
</ScrollableOverlay>
); );
}; };
@@ -11,6 +11,7 @@ import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { useDeviceInfo } from '@/lib/device'; import { useDeviceInfo } from '@/lib/device';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { Icon } from "@/components/icon/Icon"; import { Icon } from "@/components/icon/Icon";
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
@@ -132,8 +133,8 @@ export const AgentSelector: React.FC<AgentSelectorProps> = ({
onClick={isReady ? () => setIsMobilePanelOpen(true) : undefined} onClick={isReady ? () => setIsMobilePanelOpen(true) : undefined}
disabled={!isReady} disabled={!isReady}
className={cn( 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', dropdownTriggerVariants(),
!isReady && 'opacity-60 cursor-not-allowed', 'w-full',
className className
)} )}
> >
@@ -156,7 +157,8 @@ export const AgentSelector: React.FC<AgentSelectorProps> = ({
</button> </button>
) : !isReady ? ( ) : !isReady ? (
<div className={cn( <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 className
)}> )}>
<Icon name="loader-4" className="h-3 w-3 animate-spin text-muted-foreground flex-shrink-0" /> <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> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<div className={cn( <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 className
)}> )}>
<Icon name="robot-2" className="h-3 w-3 flex-shrink-0 text-muted-foreground" /> <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')} {agentName || t('settings.commands.agentSelector.notSelected')}
</span> </span>
<Icon name="arrow-down-s" className="h-3 w-3 flex-shrink-0 text-muted-foreground" /> <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 { useShallow } from 'zustand/react/shallow';
import { ModelSelector } from '../agents/ModelSelector'; import { ModelSelector } from '../agents/ModelSelector';
import { AgentSelector } from './AgentSelector'; import { AgentSelector } from './AgentSelector';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -18,6 +17,13 @@ import {
import { Icon } from "@/components/icon/Icon"; import { Icon } from "@/components/icon/Icon";
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { parseModelIdentifier } from '@/lib/modelIdentifier'; 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 = () => { export const CommandsPage: React.FC = () => {
const { t } = useI18n(); const { t } = useI18n();
@@ -189,158 +195,117 @@ export const CommandsPage: React.FC = () => {
} }
return ( return (
<ScrollableOverlay outerClassName="h-full" className="w-full"> <SettingsPageLayout
<div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8"> title={isNewCommand ? t('settings.commands.page.title.new') : `/${selectedCommandName}`}
description={isNewCommand ? t('settings.commands.page.subtitle.new') : t('settings.commands.page.subtitle.edit')}
{/* Header */} showSaveStatus={false}
<div className="mb-4 flex items-center justify-between gap-4"> >
<div className="min-w-0"> <SettingsSection
<h2 className="typography-ui-header font-semibold text-foreground truncate"> title={t('settings.commands.page.section.identity')}
{isNewCommand ? t('settings.commands.page.title.new') : `/${selectedCommandName}`} divider={false}
</h2> contentClassName="space-y-0"
<p className="typography-meta text-muted-foreground truncate"> >
{isNewCommand ? t('settings.commands.page.subtitle.new') : t('settings.commands.page.subtitle.edit')} {isNewCommand && (
</p> <SettingsFieldRow
</div> settingsItem="commands.name"
</div> label={t('settings.commands.page.field.commandName')}
>
{/* Identity */} <div className="flex items-center">
<div className="mb-8"> <span className="typography-ui-label text-muted-foreground mr-1">/</span>
<div className="mb-1 px-1"> <Input
<h3 className="typography-ui-header font-medium text-foreground"> value={draftName}
{t('settings.commands.page.section.identity')} onChange={(e) => setDraftName(e.target.value)}
</h3> placeholder={t('settings.commands.page.field.commandNamePlaceholder')}
</div> className="h-7 w-40 px-2"
/>
<section className="px-2 pb-2 pt-0 space-y-0"> </div>
<Select value={draftScope} onValueChange={(v) => setDraftScope(v as CommandScope)}>
{isNewCommand && ( <SelectTrigger size={SETTINGS_SELECT_SIZE} className="w-fit min-w-[100px]">
<div data-settings-item="commands.name" className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8"> <SelectValue placeholder={t('settings.agents.page.field.scopePlaceholder')} />
<div className="flex min-w-0 flex-col sm:w-56 shrink-0"> </SelectTrigger>
<span className="typography-ui-label text-foreground">{t('settings.commands.page.field.commandName')}</span> <SelectContent align="end">
</div> <SelectItem value="user">
<div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial"> <div className="flex items-center gap-2">
<div className="flex items-center"> <Icon name="user-3" className="h-3.5 w-3.5" />
<span className="typography-ui-label text-muted-foreground mr-1">/</span> <span>{t('settings.common.scope.global')}</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> </div>
<Select value={draftScope} onValueChange={(v) => setDraftScope(v as CommandScope)}> </SelectItem>
<SelectTrigger className="w-fit min-w-[100px]"> <SelectItem value="project">
<SelectValue placeholder={t('settings.agents.page.field.scopePlaceholder')} /> <div className="flex items-center gap-2">
</SelectTrigger> <Icon name="folder" className="h-3.5 w-3.5" />
<SelectContent align="end"> <span>{t('settings.common.scope.project')}</span>
<SelectItem value="user"> </div>
<div className="flex items-center gap-2"> </SelectItem>
<Icon name="user-3" className="h-3.5 w-3.5" /> </SelectContent>
<span>{t('settings.common.scope.global')}</span> </Select>
</div> </SettingsFieldRow>
</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>
)}
<div className="py-1.5"> <div className="py-1.5">
<span className="typography-ui-label text-foreground">{t('settings.common.field.description')}</span> <span className="typography-ui-label text-foreground">{t('settings.common.field.description')}</span>
<div className="mt-1.5"> <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">
<Textarea <Textarea
value={template} value={description}
onChange={(e) => setTemplate(e.target.value)} onChange={(e) => setDescription(e.target.value)}
placeholder={t('settings.commands.page.field.templatePlaceholder')} placeholder={t('settings.commands.page.field.descriptionPlaceholder')}
rows={12} rows={2}
className="w-full font-mono typography-meta min-h-[160px] max-h-[60vh] bg-transparent resize-y" 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>
</div> </div>
</SettingsSection>
{/* Save action */} <SettingsSection
<div className="px-2 py-1"> 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 <Button
onClick={handleSave} onClick={handleSave}
disabled={isSaving || !isDirty} disabled={isSaving || !isDirty}
@@ -350,8 +315,7 @@ export const CommandsPage: React.FC = () => {
{isSaving ? t('settings.common.actions.saving') : t('settings.common.actions.saveChanges')} {isSaving ? t('settings.common.actions.saving') : t('settings.common.actions.saveChanges')}
</Button> </Button>
</div> </div>
</SettingsSection>
</div> </SettingsPageLayout>
</ScrollableOverlay>
); );
}; };
@@ -26,6 +26,7 @@ import { cn } from '@/lib/utils';
import { SettingsProjectSelector } from '@/components/sections/shared/SettingsProjectSelector'; import { SettingsProjectSelector } from '@/components/sections/shared/SettingsProjectSelector';
import { Icon } from "@/components/icon/Icon"; import { Icon } from "@/components/icon/Icon";
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { SETTINGS_PANEL_TITLE_CLASS } from '@/components/sections/shared/SettingsSection';
interface CommandsSidebarProps { interface CommandsSidebarProps {
onItemSelect?: () => void; onItemSelect?: () => void;
@@ -227,7 +228,7 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
return ( return (
<div className={cn('flex h-full flex-col', bgClass)}> <div className={cn('flex h-full flex-col', bgClass)}>
<div className="border-b px-3 pt-4 pb-3"> <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" /> <SettingsProjectSelector className="mb-3" />
<div className="flex items-center justify-between gap-2"> <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> <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 React from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input'; 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 { toast } from '@/components/ui';
import { import {
Dialog, Dialog,
@@ -11,7 +12,6 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Icon } from "@/components/icon/Icon"; import { Icon } from "@/components/icon/Icon";
import type { IconName } from "@/components/icon/icons"; import type { IconName } from "@/components/icon/icons";
import { useGitIdentitiesStore, type GitIdentityProfile, type GitIdentityAuthType } from '@/stores/useGitIdentitiesStore'; import { useGitIdentitiesStore, type GitIdentityProfile, type GitIdentityAuthType } from '@/stores/useGitIdentitiesStore';
@@ -235,7 +235,7 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
{!isGlobalProfile && ( {!isGlobalProfile && (
<div className="space-y-3"> <div className="space-y-3">
<div> <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 <Input
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
@@ -302,16 +302,11 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<div className="flex items-center gap-1.5 mb-1.5"> <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>} {!isGlobalProfile && <span className="text-[var(--status-error)] text-xs">*</span>}
<Tooltip> <SettingsInfoHint contentClassName="max-w-xs">
<TooltipTrigger asChild> {t('settings.gitIdentities.editor.field.userNameTooltip')}
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" /> </SettingsInfoHint>
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
{t('settings.gitIdentities.editor.field.userNameTooltip')}
</TooltipContent>
</Tooltip>
</div> </div>
<Input <Input
value={userName} value={userName}
@@ -326,16 +321,11 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
<div> <div>
<div className="flex items-center gap-1.5 mb-1.5"> <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>} {!isGlobalProfile && <span className="text-[var(--status-error)] text-xs">*</span>}
<Tooltip> <SettingsInfoHint contentClassName="max-w-xs">
<TooltipTrigger asChild> {t('settings.gitIdentities.editor.field.emailAddressTooltip')}
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" /> </SettingsInfoHint>
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
{t('settings.gitIdentities.editor.field.emailAddressTooltip')}
</TooltipContent>
</Tooltip>
</div> </div>
<Input <Input
type="email" type="email"
@@ -380,15 +370,10 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
{authType === 'ssh' && ( {authType === 'ssh' && (
<div> <div>
<div className="flex items-center gap-1.5 mb-1.5"> <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> <label className={SETTINGS_FIELD_LABEL_CLASS}>{t('settings.gitIdentities.editor.field.sshKeyPath')}</label>
<Tooltip> <SettingsInfoHint contentClassName="max-w-xs">
<TooltipTrigger asChild> {t('settings.gitIdentities.editor.field.sshKeyPathTooltip')}
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" /> </SettingsInfoHint>
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
{t('settings.gitIdentities.editor.field.sshKeyPathTooltip')}
</TooltipContent>
</Tooltip>
</div> </div>
<Input <Input
value={sshKey} 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="border-t border-border/40 pt-3 space-y-3">
<div className="flex items-start gap-2"> <SettingsCheckboxRow
<Checkbox checked={signCommits}
checked={signCommits} onChange={setSignCommits}
onChange={setSignCommits} label={t('settings.gitIdentities.editor.field.signCommits')}
ariaLabel={t('settings.gitIdentities.editor.field.signCommits')} info={t('settings.gitIdentities.editor.section.commitSigning')}
/> 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>
<div> <div>
<div className="flex items-center gap-1.5 mb-1.5"> <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')} {t('settings.gitIdentities.editor.field.signingKey')}
</label> </label>
</div> </div>
@@ -435,16 +412,11 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
{authType === 'token' && ( {authType === 'token' && (
<div> <div>
<div className="flex items-center gap-1.5 mb-1.5"> <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> <span className="text-[var(--status-error)] text-xs">*</span>
<Tooltip> <SettingsInfoHint contentClassName="max-w-xs">
<TooltipTrigger asChild> {t('settings.gitIdentities.editor.field.hostTooltip')}
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" /> </SettingsInfoHint>
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
{t('settings.gitIdentities.editor.field.hostTooltip')}
</TooltipContent>
</Tooltip>
</div> </div>
<Input <Input
value={host} value={host}
@@ -21,11 +21,12 @@ import { useShallow } from 'zustand/react/shallow';
import { GitSettings } from '@/components/sections/openchamber/GitSettings'; import { GitSettings } from '@/components/sections/openchamber/GitSettings';
import { GitHubSettings } from '@/components/sections/openchamber/GitHubSettings'; import { GitHubSettings } from '@/components/sections/openchamber/GitHubSettings';
import { GitIdentityEditorDialog } from './GitIdentityEditorDialog'; import { GitIdentityEditorDialog } from './GitIdentityEditorDialog';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Icon } from "@/components/icon/Icon"; import { Icon } from "@/components/icon/Icon";
import type { IconName } from "@/components/icon/icons"; import type { IconName } from "@/components/icon/icons";
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n'; 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> = { const ICON_MAP: Record<string, IconName> = {
branch: 'git-branch', branch: 'git-branch',
@@ -116,82 +117,79 @@ export const GitPage: React.FC = () => {
return ( return (
<> <>
<ScrollableOverlay outerClassName="h-full" className="w-full bg-background"> <SettingsPageLayout
<div className="mx-auto w-full max-w-3xl space-y-6 p-3 sm:p-6 sm:pt-8"> title={t('settings.page.git.title')}
<div data-settings-item="git.github-account"> showSaveStatus
<GitHubSettings /> >
</div> <GitHubSettings />
{/* Identities Section */} <SettingsSection
<div data-settings-item="git.identities" className="border-t border-border/40 pt-6"> title={t('settings.gitIdentities.page.section.title')}
<div className="mb-3 px-1 flex items-start justify-between gap-4"> headerAction={(
<div className="flex items-center gap-2"> <Button size="sm" variant="outline" onClick={() => openEditor('new')}>
<h3 className="typography-ui-header font-semibold text-foreground">{t('settings.gitIdentities.page.section.title')}</h3> <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> </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"> {/* Discovered credentials */}
{/* Global identity */} {unimportedCredentials.length > 0 && (
{globalIdentity && ( <>
<IdentityRow <div className="px-4 py-2 border-t border-[var(--surface-subtle)]">
profile={globalIdentity} <span className="typography-micro text-muted-foreground">
isDefault={defaultGitIdentityId === 'global'} {t('settings.gitIdentities.page.discoveredCredentials.title')}
onEdit={() => openEditor('global')} </span>
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> </div>
)} {unimportedCredentials.map((cred, i) => (
<DiscoveredRow
{/* Discovered credentials */} key={`${cred.host}-${cred.username}`}
{unimportedCredentials.length > 0 && ( credential={cred}
<> onImport={() => openEditor('new', { host: cred.host, username: cred.username })}
<div className="px-4 py-2 border-t border-[var(--surface-subtle)]"> hasBorder={i < unimportedCredentials.length - 1}
<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>
</div> </div>
</SettingsSection>
<GitSettings /> <GitSettings />
</div> </SettingsPageLayout>
</ScrollableOverlay>
{/* Editor dialog */} {/* Editor dialog */}
<GitIdentityEditorDialog <GitIdentityEditorDialog
@@ -2,8 +2,6 @@ import React from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { toast } from '@/components/ui'; import { toast } from '@/components/ui';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Icon } from "@/components/icon/Icon";
import { import {
fetchMagicPromptOverrides, fetchMagicPromptOverrides,
getDefaultMagicPromptTemplate, getDefaultMagicPromptTemplate,
@@ -15,6 +13,9 @@ import {
} from '@/lib/magicPrompts'; } from '@/lib/magicPrompts';
import { useMagicPromptsStore } from '@/stores/useMagicPromptsStore'; import { useMagicPromptsStore } from '@/stores/useMagicPromptsStore';
import { useI18n } from '@/lib/i18n'; 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 = { type PromptBlock = {
id: MagicPromptId; id: MagicPromptId;
@@ -317,114 +318,92 @@ export const MagicPromptsPage: React.FC = () => {
} }
return ( return (
<div className="h-full overflow-auto"> <SettingsPageLayout
<div className="mx-auto w-full max-w-4xl px-6 py-6 space-y-4"> title={tUnsafe(pageConfig.titleKey)}
<div className="flex flex-wrap items-start justify-between gap-3"> titleAccessory={(
<div className="space-y-1"> <SettingsInfoHint contentClassName="max-w-xs">{tUnsafe(pageConfig.descriptionKey)}</SettingsInfoHint>
<div className="flex items-center gap-2"> )}
<h2 className="typography-ui-header font-semibold text-foreground">{tUnsafe(pageConfig.titleKey)}</h2> headerEnd={(
<Tooltip> <Button
<TooltipTrigger asChild> data-settings-item="magic-prompts.reset-overrides"
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" /> variant="outline"
</TooltipTrigger> size="sm"
<TooltipContent sideOffset={8} className="max-w-xs"> onClick={() => {
{tUnsafe(pageConfig.descriptionKey)} void handleResetAll();
</TooltipContent> }}
</Tooltip> disabled={resettingAll || Object.keys(overrides).length === 0}
</div> >
</div> {resettingAll ? t('settings.magicPrompts.page.actions.resetting') : t('settings.magicPrompts.page.actions.resetAllOverrides')}
<Button </Button>
data-settings-item="magic-prompts.reset-overrides" )}
variant="outline" showSaveStatus={false}
size="sm" >
onClick={() => { {pageConfig.blocks.map((block, index) => {
void handleResetAll(); const definition = getMagicPromptDefinition(block.id);
}} const baseline = getBaseline(block.id);
disabled={resettingAll || Object.keys(overrides).length === 0} 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')} <Textarea
</Button> value={draft}
</div> 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) => { <div className="flex items-center justify-between gap-2">
const definition = getMagicPromptDefinition(block.id); <span className="typography-micro text-muted-foreground">
const baseline = getBaseline(block.id); {isDirty
const draft = getDraft(block.id); ? t('settings.magicPrompts.page.status.unsavedChanges')
const isOverridden = hasOwn(overrides, block.id); : isOverridden
const isDirty = draft !== baseline; ? t('settings.magicPrompts.page.status.usingSavedOverride')
const isInvalidEmptyVisiblePrompt = isVisiblePromptId(block.id) && draft.trim().length === 0; : t('settings.magicPrompts.page.status.usingBuiltinDefault')}
const saving = savingIds[block.id] === true; </span>
const resetting = resettingIds[block.id] === true; <div className="flex items-center gap-2">
<Button
return ( variant="outline"
<section size="sm"
key={block.id} onClick={() => {
data-settings-item={isVisiblePromptId(block.id) ? 'magic-prompts.visible-prompt' : 'magic-prompts.instructions'} void resetPrompt(block.id);
className={index > 0 ? 'space-y-3 pt-5 border-t border-border' : 'space-y-3'} }}
> disabled={!isOverridden || saving || resetting}
<div className="space-y-1"> >
<div className="flex flex-wrap items-center gap-2"> {resetting ? t('settings.magicPrompts.page.actions.resetting') : t('settings.magicPrompts.page.actions.resetToDefault')}
<h3 className="typography-ui-label text-foreground">{tUnsafe(block.titleKey)}</h3> </Button>
<Tooltip> <Button
<TooltipTrigger asChild> size="sm"
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" /> onClick={() => {
</TooltipTrigger> void savePrompt(block.id);
<TooltipContent sideOffset={8} className="max-w-xs"> }}
{definition.description} disabled={!isDirty || saving || resetting || isInvalidEmptyVisiblePrompt}
</TooltipContent> >
</Tooltip> {saving ? t('settings.common.actions.saving') : t('settings.magicPrompts.page.actions.save')}
</div> </Button>
{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> </div>
</div>
<Textarea </SettingsSection>
value={draft} );
onChange={(event) => setDraft(block.id, event.target.value)} })}
className="min-h-[220px] font-mono text-sm" </SettingsPageLayout>
/>
{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>
); );
}; };
@@ -3,6 +3,7 @@ import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { useMagicPromptsStore } from '@/stores/useMagicPromptsStore'; import { useMagicPromptsStore } from '@/stores/useMagicPromptsStore';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { SETTINGS_PANEL_TITLE_CLASS } from '@/components/sections/shared/SettingsSection';
interface MagicPromptsSidebarProps { interface MagicPromptsSidebarProps {
onItemSelect?: () => void; onItemSelect?: () => void;
@@ -62,7 +63,7 @@ export const MagicPromptsSidebar: React.FC<MagicPromptsSidebarProps> = ({ onItem
return ( return (
<div className="flex h-full flex-col bg-background"> <div className="flex h-full flex-col bg-background">
<div className="border-b px-3 pt-4 pb-3"> <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> <p className="typography-meta mt-1 text-muted-foreground">{t('settings.magicPrompts.sidebar.description')}</p>
</div> </div>
@@ -3,6 +3,8 @@ import { Button } from '@/components/ui/button';
import { useMcpStore } from '@/stores/useMcpStore'; import { useMcpStore } from '@/stores/useMcpStore';
import { parseMcpOAuthCallbackContext, parseMcpOAuthCallbackStateKey } from '@/components/sections/mcp/mcpOAuth'; import { parseMcpOAuthCallbackContext, parseMcpOAuthCallbackStateKey } from '@/components/sections/mcp/mcpOAuth';
import { runtimeFetch } from '@/lib/runtime-fetch'; 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 parseQueryParam = (params: URLSearchParams, key: string): string | null => {
const value = params.get(key); 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="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="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="space-y-3 text-center">
<div <h1 className={SETTINGS_PAGE_TITLE_CLASS}>
className={status === 'error' ? 'text-[var(--status-error)]' : status === 'success' ? 'text-[var(--status-success)]' : 'text-[var(--status-info)]'} {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"> {message}
{status === 'working' ? 'Completing Authorization' : status === 'success' ? 'Authorization Complete' : 'Authorization Failed'} </p>
</h1>
</div>
<p className="typography-body text-muted-foreground">{message}</p>
</div> </div>
{status !== 'working' && ( {status !== 'working' && (
@@ -1,6 +1,5 @@
import React from 'react'; import React from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { toast } from '@/components/ui'; import { toast } from '@/components/ui';
@@ -23,7 +22,18 @@ import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { runtimeFetch } from '@/lib/runtime-fetch'; import { runtimeFetch } from '@/lib/runtime-fetch';
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch'; import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
import { cn } from '@/lib/utils'; 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 { MCP_OAUTH_CALLBACK_PATH, parseMcpOAuthCallbackContext, parseMcpOAuthCallbackStateKey } from '@/components/sections/mcp/mcpOAuth';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { import {
@@ -1323,88 +1333,78 @@ export const McpPage: React.FC = () => {
}; };
return ( 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={isNewServer ? t('settings.mcp.page.header.newServer') : selectedMcpName}
{/* Header */} titleAccessory={!isNewServer ? (
<div className="mb-4"> <StatusBadge status={effectiveRuntimeStatus?.status} enabled={enabled} getStatusLabel={getStatusLabel} variant="pill" />
<div className="min-w-0"> ) : undefined}
{isNewServer ? ( description={isNewServer
<h2 className="typography-ui-header font-semibold text-foreground truncate">{t('settings.mcp.page.header.newServer')}</h2> ? 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') })}
<div className="flex items-center gap-2 min-w-0"> headerEnd={!isNewServer ? (
<h2 className="typography-ui-header font-semibold text-foreground truncate">{selectedMcpName}</h2> <div className="flex flex-wrap items-center gap-2">
<StatusBadge status={effectiveRuntimeStatus?.status} enabled={enabled} getStatusLabel={getStatusLabel} variant="pill" /> <Button
</div> variant={isConnected ? 'outline' : 'default'}
)} size="xs"
<div className="flex items-center gap-2 mt-0.5"> className="!font-normal"
<p className="typography-meta text-muted-foreground truncate"> onClick={handleToggleConnect}
{isNewServer disabled={isConnecting || !enabled}
? 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') })} {isConnecting ? t('settings.mcp.page.actions.working') : isConnected ? t('settings.mcp.page.actions.disconnect') : t('settings.mcp.page.actions.connect')}
</p> </Button>
{!isNewServer && ( {mcpType === 'remote' && (
<> <>
<Button <Button
variant={isConnected ? 'outline' : 'default'} variant={needsAuthorization ? 'default' : 'outline'}
size="xs" size="xs"
className="!font-normal" className="!font-normal"
onClick={handleToggleConnect} onClick={() => void handleStartAuthorization()}
disabled={isConnecting || !enabled} disabled={isAuthorizing || !enabled}
> >
{isConnecting ? t('settings.mcp.page.actions.working') : isConnected ? t('settings.mcp.page.actions.disconnect') : t('settings.mcp.page.actions.connect')} {isAuthorizing
</Button> ? t('settings.mcp.page.actions.starting')
{mcpType === 'remote' && ( : needsAuthorization
<> ? t('settings.mcp.page.actions.authorize')
<Button : t('settings.mcp.page.actions.reauthorize')}
variant={needsAuthorization ? 'default' : 'outline'} </Button>
size="xs" <Button
className="!font-normal" variant="ghost"
onClick={() => void handleStartAuthorization()} size="xs"
disabled={isAuthorizing || !enabled} className="!font-normal gap-1 text-muted-foreground"
> onClick={() => void handleClearAuthorization()}
{isAuthorizing disabled={isClearingAuth || !enabled}
? t('settings.mcp.page.actions.starting') >
: needsAuthorization {isClearingAuth ? t('settings.mcp.page.actions.clearing') : t('settings.mcp.page.actions.clearAuth')}
? t('settings.mcp.page.actions.authorize') </Button>
: t('settings.mcp.page.actions.reauthorize')} </>
</Button> )}
<Button {isConnected && (
variant="ghost" <Button
size="xs" variant="ghost"
className="!font-normal gap-1 text-muted-foreground" size="xs"
onClick={() => void handleClearAuthorization()} className="!font-normal gap-1 text-muted-foreground"
disabled={isClearingAuth || !enabled} onClick={() => void handleTestConnection()}
> disabled={isTestingConnection || !enabled}
{isClearingAuth ? t('settings.mcp.page.actions.clearing') : t('settings.mcp.page.actions.clearAuth')} >
</Button> {isTestingConnection ? t('settings.mcp.page.actions.testing') : t('settings.mcp.page.actions.test')}
</> </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>
</div> </div>
) : undefined}
showSaveStatus={false}
>
{/* Runtime Status - Simplified for connected, expanded for errors */} {/* Runtime Status - Simplified for connected, expanded for errors */}
{!isNewServer && shouldShowFullStatusCard(effectiveRuntimeStatus?.status, authUrl, needsAuthorization, isAuthPolling) && ( {!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={cn('rounded-lg border p-3', statusCardClass(effectiveRuntimeStatus?.status))}>
<div className="space-y-4"> <div className="space-y-4">
<div className="min-w-0 space-y-1"> <div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-x-2 gap-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} /> <StatusBadge status={effectiveRuntimeStatus?.status} enabled={enabled} getStatusLabel={getStatusLabel} />
</div> </div>
<p className="typography-meta text-muted-foreground">{runtimeDescription}</p> <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="rounded-md border border-[var(--interactive-border)] bg-[var(--surface-background)] px-3 py-3">
<div className="space-y-2"> <div className="space-y-2">
<div> <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"> <p className="mt-1 typography-micro text-muted-foreground">
{t('settings.mcp.page.auth.manualFallbackDescription')} {t('settings.mcp.page.auth.manualFallbackDescription')}
</p> </p>
@@ -1489,23 +1489,18 @@ export const McpPage: React.FC = () => {
</p> </p>
)} )}
</div> </div>
</div> </SettingsSection>
)} )}
{/* Server Identity */} <SettingsSection
<div data-settings-item="mcp.server" className="mb-6"> title={t('settings.mcp.page.server.title')}
<div className="mb-1 px-1"> divider={false}
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.mcp.page.server.title')}</h3> settingsItem="mcp.server"
</div> contentClassName="space-y-0"
>
<section className="px-2 pb-2 pt-0 space-y-0">
{isNewServer && ( {isNewServer && (
<div className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8"> <SettingsFieldRow label={t('settings.mcp.page.server.name')}>
<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">
<Input <Input
value={draftName} value={draftName}
onChange={(e) => setDraftName(e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, '-'))} onChange={(e) => setDraftName(e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, '-'))}
@@ -1514,7 +1509,7 @@ export const McpPage: React.FC = () => {
autoFocus autoFocus
/> />
<Select value={draftScope} onValueChange={(value) => setDraftScope(value as McpScope)}> <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" />} {draftScope === 'user' ? <Icon name="user-3" className="h-3.5 w-3.5" /> : <Icon name="folder" className="h-3.5 w-3.5" />}
</SelectTrigger> </SelectTrigger>
<SelectContent align="end"> <SelectContent align="end">
@@ -1532,8 +1527,7 @@ export const McpPage: React.FC = () => {
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </SettingsFieldRow>
</div>
)} )}
{/* Import JSON - prominent placement for new servers */} {/* Import JSON - prominent placement for new servers */}
@@ -1553,65 +1547,31 @@ export const McpPage: React.FC = () => {
</div> </div>
)} )}
<div <SettingsCheckboxRow
className="group flex cursor-pointer items-center gap-2 py-1.5" checked={enabled}
role="button" onChange={setEnabled}
tabIndex={0} label={t('settings.mcp.page.server.enable')}
aria-pressed={enabled} ariaLabel={t('settings.mcp.page.server.enableAria')}
onClick={() => setEnabled(!enabled)} />
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') { <SettingsStackedField label={t('settings.mcp.page.server.transportMode')}>
event.preventDefault(); <SettingsChipGroup
setEnabled(!enabled); aria-label={t('settings.mcp.page.server.transportMode')}
} value={mcpType}
}} onChange={setMcpType}
> options={[
<Checkbox { value: 'local', label: t('settings.mcp.page.transport.local') },
checked={enabled} { value: 'remote', label: t('settings.mcp.page.transport.remote') },
onChange={setEnabled} ]}
ariaLabel={t('settings.mcp.page.server.enableAria')}
/> />
<span className="typography-ui-label text-foreground">{t('settings.mcp.page.server.enable')}</span> </SettingsStackedField>
</div>
<div className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8"> </SettingsSection>
<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>
</section> <SettingsSection
</div> title={mcpType === 'local' ? t('settings.mcp.page.connection.command') : t('settings.mcp.page.connection.serverUrl')}
settingsItem="mcp.command"
{/* 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">
{mcpType === 'local' ? ( {mcpType === 'local' ? (
<CommandTextarea <CommandTextarea
value={command} value={command}
@@ -1630,16 +1590,13 @@ export const McpPage: React.FC = () => {
className="font-mono typography-meta" className="font-mono typography-meta"
/> />
)} )}
</section> </SettingsSection>
</div>
{mcpType === 'remote' && ( {mcpType === 'remote' && (
<div data-settings-item="mcp.advanced" className="mb-6"> <SettingsSection
<div className="mb-1 px-1"> title={t('settings.mcp.page.advanced.title')}
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.mcp.page.advanced.title')}</h3> settingsItem="mcp.advanced"
</div> >
<section className="px-2 pb-2 pt-0">
<Collapsible <Collapsible
open={isAdvancedRemoteOptionsOpen} open={isAdvancedRemoteOptionsOpen}
onOpenChange={setIsAdvancedRemoteOptionsOpen} onOpenChange={setIsAdvancedRemoteOptionsOpen}
@@ -1660,9 +1617,10 @@ export const McpPage: React.FC = () => {
<CollapsibleContent className="pt-2"> <CollapsibleContent className="pt-2">
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-8"> <div className="flex flex-col gap-2 @xl:flex-row @xl:items-center @xl:gap-8">
<div className="flex min-w-0 flex-col sm:w-56 shrink-0"> <div className="flex min-w-0 flex-row items-center gap-1 @xl:w-56 shrink-0">
<span className="typography-ui-label text-foreground">{t('settings.mcp.page.advanced.timeoutMs')}</span> <span className={SETTINGS_FIELD_LABEL_CLASS}>{t('settings.mcp.page.advanced.timeoutMs')}</span>
<SettingsInfoHint>{t('settings.mcp.page.advanced.timeoutHint')}</SettingsInfoHint>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Input <Input
@@ -1678,18 +1636,15 @@ export const McpPage: React.FC = () => {
/> />
</div> </div>
</div> </div>
<p className="typography-micro text-muted-foreground sm:pl-64">
{t('settings.mcp.page.advanced.timeoutHint')}
</p>
</div> </div>
<div> <div>
<div className="mb-2 typography-ui-label text-foreground"> <SettingsGroupTitle as="div" className="mb-2">
{t('settings.mcp.page.advanced.requestHeaders')} {t('settings.mcp.page.advanced.requestHeaders')}
{headerEntries.length > 0 && ( {headerEntries.length > 0 && (
<span className="ml-1.5 typography-micro text-muted-foreground font-normal">({headerEntries.length})</span> <span className="ml-1.5 typography-micro text-muted-foreground font-normal">({headerEntries.length})</span>
)} )}
</div> </SettingsGroupTitle>
<EnvEditor <EnvEditor
value={headerEntries} value={headerEntries}
onChange={setHeaderEntries} onChange={setHeaderEntries}
@@ -1713,24 +1668,15 @@ export const McpPage: React.FC = () => {
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<div <SettingsCheckboxRow
className="group flex cursor-pointer items-center gap-2 py-1.5" checked={oauthEnabled}
role="button" onChange={setOauthEnabled}
tabIndex={0} label={t('settings.mcp.page.advanced.oauthAutoDetection')}
aria-pressed={oauthEnabled} ariaLabel={t('settings.mcp.page.advanced.oauthAutoDetectionAria')}
onClick={() => setOauthEnabled(!oauthEnabled)} info={t('settings.mcp.page.advanced.oauthHint')}
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>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 @xl:grid-cols-2">
<Input <Input
value={oauthClientId} value={oauthClientId}
onChange={(e) => setOauthClientId(e.target.value)} onChange={(e) => setOauthClientId(e.target.value)}
@@ -1769,9 +1715,6 @@ export const McpPage: React.FC = () => {
/> />
</div> </div>
<p className="typography-micro text-muted-foreground">
{t('settings.mcp.page.advanced.oauthHint')}
</p>
{suggestedRedirectUri && ( {suggestedRedirectUri && (
<p className="typography-micro text-muted-foreground"> <p className="typography-micro text-muted-foreground">
{t('settings.mcp.page.advanced.oauthCallbackHint')} {t('settings.mcp.page.advanced.oauthCallbackHint')}
@@ -1782,24 +1725,20 @@ export const McpPage: React.FC = () => {
</div> </div>
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
</section> </SettingsSection>
</div>
)} )}
{/* Environment Variables */} <SettingsSection
<div data-settings-item="mcp.environment" className="mb-2"> title={t('settings.mcp.page.env.title')}
<div className="mb-1 px-1"> titleAccessory={
<h3 className="typography-ui-header font-medium text-foreground"> envEntries.length > 0 ? (
{t('settings.mcp.page.env.title')} <span className="typography-micro text-muted-foreground font-normal">
{envEntries.length > 0 && ( ({envEntries.length})
<span className="ml-1.5 typography-micro text-muted-foreground font-normal"> </span>
({envEntries.length}) ) : null
</span> }
)} settingsItem="mcp.environment"
</h3> >
</div>
<section className="px-2 pb-2 pt-0">
{envEntries.length === 0 ? ( {envEntries.length === 0 ? (
<Button <Button
variant="outline" variant="outline"
@@ -1830,11 +1769,9 @@ export const McpPage: React.FC = () => {
removeVariableAria={t('settings.mcp.page.env.removeVariableAria')} removeVariableAria={t('settings.mcp.page.env.removeVariableAria')}
/> />
)} )}
</section> </SettingsSection>
</div>
{/* Actions */} <div className="flex items-center gap-2 pb-8">
<div className="flex items-center gap-2 px-2 py-1">
<Button <Button
onClick={handleSave} onClick={handleSave}
disabled={isSaving || (!isDirty && !isNewServer)} disabled={isSaving || (!isDirty && !isNewServer)}
@@ -1853,8 +1790,8 @@ export const McpPage: React.FC = () => {
{t('settings.common.actions.delete')} {t('settings.common.actions.delete')}
</Button> </Button>
)} )}
</div> </div> </SettingsPageLayout>
</div>
{/* Import JSON dialog */} {/* Import JSON dialog */}
<Dialog <Dialog
@@ -1962,6 +1899,7 @@ export const McpPage: React.FC = () => {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</ScrollableOverlay>
</>
); );
}; };
@@ -25,6 +25,7 @@ import {
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { SETTINGS_PANEL_TITLE_CLASS } from '@/components/sections/shared/SettingsSection';
interface McpSidebarProps { interface McpSidebarProps {
onItemSelect?: () => void; onItemSelect?: () => void;
@@ -183,7 +184,7 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
<div className={cn('flex h-full flex-col', bgClass)}> <div className={cn('flex h-full flex-col', bgClass)}>
<div className="border-b px-3 pt-4 pb-3"> <div className="border-b px-3 pt-4 pb-3">
<div className="mb-3 flex items-center justify-between gap-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 <button
type="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" 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 { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch'; 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 GITHUB_URL = 'https://github.com/openchamber/openchamber';
const DISCORD_URL = 'https://discord.gg/ZYRSdnwwKA'; 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="w-full space-y-6 pb-2">
<div className="flex flex-col items-center text-center"> <div className="flex flex-col items-center text-center">
<OpenChamberLogo width={72} height={72} /> <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"> <div className="mt-2 space-y-1 typography-ui text-muted-foreground">
<p>{t('aboutDialog.openChamberVersionLabel', { version: currentVersion })}</p> <p>{t('aboutDialog.openChamberVersionLabel', { version: currentVersion })}</p>
<p>{t('aboutDialog.openCodeVersionLabel', { version: openCodeVersion || t('settings.openchamber.about.state.unknown') })}</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 ( return (
<div className="mb-8"> <SettingsSection divider={false}>
<div className="mb-3 px-1">
<h3 className="typography-ui-header font-semibold text-foreground">
{t('settings.openchamber.about.title')}
</h3>
</div>
<div className="rounded-lg bg-[var(--surface-elevated)]/70 overflow-hidden flex flex-col"> <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"> <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> <span className="typography-meta text-muted-foreground font-mono">{currentVersion}</span>
</div> </div>
<div className="flex min-w-0 flex-col"> <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> <span className="typography-meta text-muted-foreground font-mono">{openCodeVersion || t('settings.openchamber.about.state.unknown')}</span>
</div> </div>
@@ -274,7 +273,7 @@ export const AboutSettings: React.FC<AboutSettingsProps> = ({ initialUpdateDialo
</div> </div>
{updateStore.error && ( {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> <p className="typography-meta text-[var(--status-error)]">{updateStore.error}</p>
</div> </div>
)} )}
@@ -314,6 +313,6 @@ export const AboutSettings: React.FC<AboutSettingsProps> = ({ initialUpdateDialo
onRestart={updateStore.restartToUpdate} onRestart={updateStore.restartToUpdate}
runtimeType={updateStore.runtimeType} runtimeType={updateStore.runtimeType}
/> />
</div> </SettingsSection>
); );
}; };
@@ -2,12 +2,22 @@ import React from 'react';
import { ModelSelector } from '@/components/sections/agents/ModelSelector'; import { ModelSelector } from '@/components/sections/agents/ModelSelector';
import { AgentSelector } from '@/components/sections/commands/AgentSelector'; import { AgentSelector } from '@/components/sections/commands/AgentSelector';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; 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 { updateDesktopSettings } from '@/lib/persistence';
import { useConfigStore } from '@/stores/useConfigStore'; import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { parseModelIdentifier } from '@/lib/modelIdentifier'; import { parseModelIdentifier } from '@/lib/modelIdentifier';
import { runtimeFetch } from '@/lib/runtime-fetch'; import { runtimeFetch } from '@/lib/runtime-fetch';
@@ -277,143 +287,118 @@ export const DefaultsSettings: React.FC = () => {
} }
return ( return (
<div className="mb-6"> <>
<div className="mb-0.5 px-1"> <SettingsSection title={t('settings.openchamber.defaults.title')} divider={false}>
<div className="flex items-center gap-2"> <div className="space-y-0">
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.defaults.title')}</h3> <div className="mt-0 mb-1 typography-meta text-muted-foreground">
</div> {t('settings.openchamber.defaults.summaryPrefix')}
</div> {' '}
{parsedModel.providerId ? (
<section className="px-2 pb-2 pt-0 space-y-0"> <span className="text-foreground">
<div className="mt-0 mb-1 typography-meta text-muted-foreground"> {parsedModel.providerId}/{parsedModel.modelId}
{t('settings.openchamber.defaults.summaryPrefix')} {supportsVariants ? ` (${defaultVariant ?? t('settings.openchamber.defaults.option.defaultLowercase')})` : ''}
{' '} </span>
{parsedModel.providerId ? ( ) : (
<span className="text-foreground"> <span className="text-foreground">{t('settings.openchamber.defaults.summaryOpenCodeDefault')}</span>
{parsedModel.providerId}/{parsedModel.modelId} )}
{supportsVariants ? ` (${defaultVariant ?? t('settings.openchamber.defaults.option.defaultLowercase')})` : ''} {defaultAgent && (
</span> <>
) : ( {' / '}
<span className="text-foreground">{t('settings.openchamber.defaults.summaryOpenCodeDefault')}</span> <span className="text-foreground">{defaultAgent}</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>
</div> </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>
<div className="flex min-w-0 flex-col sm:w-56 shrink-0"> <SettingsFieldRow
<span className="typography-ui-label text-foreground">{t('settings.openchamber.defaults.field.defaultThinking')}</span> settingsItem="sessions.default-model"
</div> label={t('settings.openchamber.defaults.field.defaultModel')}
<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">
<ModelSelector <ModelSelector
providerId={parsedSmallModel.providerId} providerId={parsedModel.providerId}
modelId={parsedSmallModel.modelId} modelId={parsedModel.modelId}
onChange={handleSmallModelOverrideChange} onChange={handleModelChange}
allowedProviderIds={smallModelProviders} 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> </div>
) : null}
</section> <SettingsInset className={SETTINGS_OPTION_STACK_CLASS}>
</div> <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 * as React from 'react';
import { Button } from '@/components/ui/button'; 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 { Input } from '@/components/ui/input';
import { import {
getDesktopLanAddress, getDesktopLanAddress,
@@ -22,7 +22,15 @@ import { updateDesktopSettings } from '@/lib/persistence';
import { runtimeFetch } from '@/lib/runtime-fetch'; import { runtimeFetch } from '@/lib/runtime-fetch';
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch'; import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
import { useUIStore } from '@/stores/useUIStore'; 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 }> = [ const WINDOW_CONTROLS_POSITION_OPTIONS: Array<{ id: DesktopWindowControlsPosition; labelKey: string }> = [
{ id: 'auto', labelKey: 'settings.openchamber.desktopNetwork.option.windowControlsAuto' }, { id: 'auto', labelKey: 'settings.openchamber.desktopNetwork.option.windowControlsAuto' },
@@ -41,6 +49,7 @@ export const DesktopNetworkSettings: React.FC = () => {
const [draftValue, setDraftValue] = React.useState(false); const [draftValue, setDraftValue] = React.useState(false);
const [savedPassword, setSavedPassword] = React.useState(''); const [savedPassword, setSavedPassword] = React.useState('');
const [draftPassword, setDraftPassword] = React.useState(''); const [draftPassword, setDraftPassword] = React.useState('');
const [showPassword, setShowPassword] = React.useState(false);
const [lanAccessActive, setLanAccessActive] = React.useState(false); const [lanAccessActive, setLanAccessActive] = React.useState(false);
const [lanAccessBlockedReason, setLanAccessBlockedReason] = React.useState<string | null>(null); const [lanAccessBlockedReason, setLanAccessBlockedReason] = React.useState<string | null>(null);
const [isLoading, setIsLoading] = React.useState(true); const [isLoading, setIsLoading] = React.useState(true);
@@ -215,10 +224,6 @@ export const DesktopNetworkSettings: React.FC = () => {
const lanBlockedByMissingPassword = savedValue && !lanAccessActive && lanAccessBlockedReason === 'missing-password'; const lanBlockedByMissingPassword = savedValue && !lanAccessActive && lanAccessBlockedReason === 'missing-password';
const saveDisabled = isLoading || isSaving || !isDirty || lanRequiresPassword; const saveDisabled = isLoading || isSaving || !isDirty || lanRequiresPassword;
const handleToggle = React.useCallback(() => {
setDraftValue((current) => !current);
}, []);
const handlePasswordChange = React.useCallback((value: string) => { const handlePasswordChange = React.useCallback((value: string) => {
setDraftPassword(value); setDraftPassword(value);
if (!value.trim()) { if (!value.trim()) {
@@ -349,223 +354,164 @@ export const DesktopNetworkSettings: React.FC = () => {
} }
return ( return (
<div className="mb-8"> <>
{showWindowControlsPosition ? ( {showWindowControlsPosition ? (
<> <SettingsSection title={t('settings.openchamber.desktopNetwork.field.windowControlsPosition')}>
<div className="mb-1 px-1"> <SettingsFieldRow
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.desktopNetwork.field.windowControlsPosition')}</h3> settingsItem="sessions.desktop-window-controls-position"
</div> label={t('settings.openchamber.desktopNetwork.field.windowControlsPositionDescription')}
<section className="space-y-2 px-2 pb-2 pt-0"> alignEnd={false}
<div data-settings-item="sessions.desktop-window-controls-position" className="space-y-1 py-1.5"> controlClassName="flex-col items-stretch"
<div className="typography-micro text-muted-foreground/70"> >
{t('settings.openchamber.desktopNetwork.field.windowControlsPositionDescription')} <SettingsChipGroup
</div> value={desktopWindowControlsPosition}
<div options={WINDOW_CONTROLS_POSITION_OPTIONS.map((option) => ({
className="mt-1 flex flex-wrap items-center gap-1" value: option.id,
role="group" label: tUnsafe(option.labelKey),
aria-label={t('settings.openchamber.desktopNetwork.field.windowControlsPositionAria')} }))}
> onChange={handleWindowControlsPositionChange}
{WINDOW_CONTROLS_POSITION_OPTIONS.map((option) => { aria-label={t('settings.openchamber.desktopNetwork.field.windowControlsPositionAria')}
const selected = desktopWindowControlsPosition === option.id; />
return ( </SettingsFieldRow>
<Button </SettingsSection>
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>
</>
) : null} ) : null}
{!isLocalDesktop ? null : ( {isLocalDesktop ? (
<> <SettingsSection title={t('settings.openchamber.desktopNetwork.title')}>
<div className="mb-1 px-1"> <div className="space-y-3">
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.desktopNetwork.title')}</h3> {(launchAtLoginSupported || minimizeToTraySupported || keepAwakeSupported) ? (
</div> <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"> {minimizeToTraySupported ? (
{launchAtLoginSupported ? ( <SettingsCheckboxRow
<div settingsItem="sessions.desktop-minimize-to-tray"
data-settings-item="sessions.desktop-launch-at-login" checked={minimizeToTrayEnabled}
className="group flex cursor-pointer items-start gap-2 py-1.5" onChange={(checked) => {
role="button" if (checked === minimizeToTrayEnabled) return;
tabIndex={0} void handleMinimizeToTrayToggle();
onClick={handleLaunchAtLoginToggle} }}
onKeyDown={(event) => { disabled={isSavingMinimizeToTray}
if (event.key === 'Enter' || event.key === ' ') { label={t('settings.openchamber.desktopNetwork.field.minimizeToTray')}
event.preventDefault(); info={t('settings.openchamber.desktopNetwork.field.minimizeToTrayDescription')}
handleLaunchAtLoginToggle(); ariaLabel={t('settings.openchamber.desktopNetwork.field.minimizeToTrayAria')}
} />
}} ) : null}
>
<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 ? ( {keepAwakeSupported ? (
<div <SettingsCheckboxRow
data-settings-item="sessions.desktop-minimize-to-tray" settingsItem="sessions.desktop-keep-awake"
className="group flex cursor-pointer items-start gap-2 py-1.5" checked={keepAwakeEnabled}
role="button" onChange={(checked) => {
tabIndex={0} if (checked === keepAwakeEnabled) return;
onClick={handleMinimizeToTrayToggle} void handleKeepAwakeToggle();
onKeyDown={(event) => { }}
if (event.key === 'Enter' || event.key === ' ') { disabled={isSavingKeepAwake}
event.preventDefault(); label={t('settings.openchamber.desktopNetwork.field.keepAwake')}
handleMinimizeToTrayToggle(); info={t('settings.openchamber.desktopNetwork.field.keepAwakeDescription')}
} ariaLabel={t('settings.openchamber.desktopNetwork.field.keepAwakeAria')}
}} />
> ) : null}
<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')}
</div> </div>
) : null} ) : 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>
</div> </SettingsSection>
) : null}
{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>
); );
}; };
@@ -8,9 +8,9 @@ import { useDeviceInfo } from '@/lib/device';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { openExternalUrl } from '@/lib/url'; import { openExternalUrl } from '@/lib/url';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { runtimeFetch } from '@/lib/runtime-fetch'; import { runtimeFetch } from '@/lib/runtime-fetch';
import { Icon } from "@/components/icon/Icon"; import { Icon } from "@/components/icon/Icon";
import { SettingsSection, SettingsGroupTitle } from '@/components/sections/shared/SettingsSection';
type GitHubUser = { type GitHubUser = {
login: string; login: string;
@@ -270,21 +270,13 @@ export const GitHubSettings: React.FC = () => {
: t('settings.github.page.accountSource.oauth'); : t('settings.github.page.accountSource.oauth');
return ( return (
<div className="mb-8"> <>
<div className="mb-3 px-1 flex items-start justify-between gap-4"> <SettingsSection
<div className="flex items-center gap-2"> title={t('settings.github.page.oauth.title')}
<h3 className="typography-ui-header font-semibold text-foreground">{t('settings.github.page.oauth.title')}</h3> divider={false}
<Tooltip> settingsItem="git.github-account"
<TooltipTrigger asChild> info={t('settings.github.page.tooltip.connectAccount')}
<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>
<div className="rounded-lg bg-[var(--surface-elevated)]/70 overflow-hidden flex flex-col"> <div className="rounded-lg bg-[var(--surface-elevated)]/70 overflow-hidden flex flex-col">
{connected ? ( {connected ? (
<div className={cn("px-4 py-3", isMobile ? "flex flex-col gap-3" : "flex items-center justify-between gap-4")}> <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 && ( {flow && (
<div className="mt-4 rounded-lg bg-[var(--surface-elevated)]/70 p-4 border border-[var(--interactive-border)]"> <div className="mt-4 rounded-lg bg-[var(--surface-elevated)]/70 p-4 border border-[var(--interactive-border)]">
<div className="space-y-1"> <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"> <p className="typography-meta text-muted-foreground">
{t('settings.github.page.flow.description')} {t('settings.github.page.flow.description')}
</p> </p>
@@ -453,11 +445,10 @@ export const GitHubSettings: React.FC = () => {
</div> </div>
)} )}
</SettingsSection>
{ghCli?.available && !ghCli?.active && (!ghCli.user || ghCli.disabled) && ( {ghCli?.available && !ghCli?.active && (!ghCli.user || ghCli.disabled) && (
<div className="mt-6"> <SettingsSection title={t('settings.github.page.ghCli.title')}>
<h3 className="typography-ui-header font-semibold text-foreground mb-3 px-1">
{t('settings.github.page.ghCli.title')}
</h3>
<div className="rounded-lg bg-[var(--surface-elevated)]/70 overflow-hidden"> <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("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)}> <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> </Button>
</div> </div>
</div> </div>
</div> </SettingsSection>
)} )}
</div> </>
); );
}; };
@@ -1,6 +1,4 @@
import React from 'react'; import React from 'react';
import { Checkbox } from '@/components/ui/checkbox';
import { Radio } from '@/components/ui/radio';
import { updateDesktopSettings } from '@/lib/persistence'; import { updateDesktopSettings } from '@/lib/persistence';
import { useConfigStore } from '@/stores/useConfigStore'; import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
@@ -8,6 +6,14 @@ import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { setFilesViewShowGitignored, useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored'; import { setFilesViewShowGitignored, useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch'; 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 = () => { export const GitSettings: React.FC = () => {
const { t } = useI18n(); const { t } = useI18n();
@@ -116,94 +122,45 @@ export const GitSettings: React.FC = () => {
} }
return ( return (
<div className="mb-8"> <SettingsSection title={t('settings.openchamber.git.title')}>
<div className="mb-1 px-1"> <div className={SETTINGS_OPTION_STACK_CLASS}>
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.git.title')}</h3> <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> </div>
</SettingsSection>
<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>
); );
}; };
@@ -1,8 +1,10 @@
import React from 'react'; import React from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import {
import { Icon } from "@/components/icon/Icon"; SettingsSection,
SettingsFieldRow,
} from '@/components/sections/shared/SettingsSection';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { updateDesktopSettings } from '@/lib/persistence'; import { updateDesktopSettings } from '@/lib/persistence';
@@ -157,41 +159,34 @@ export const KeyboardShortcutsSettings: React.FC = () => {
}, [clearShortcutOverride, persistShortcutOverrides, shortcutOverrides]); }, [clearShortcutOverride, persistShortcutOverrides, shortcutOverrides]);
return ( return (
<div data-settings-item="shortcuts.keyboard-shortcuts" className="mb-8"> <SettingsSection
<div className="mb-1 px-1"> settingsItem="shortcuts.keyboard-shortcuts"
<div className="flex items-center gap-2"> title={t('settings.openchamber.keyboardShortcuts.title')}
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.keyboardShortcuts.title')}</h3> divider={false}
<Button info={t('settings.openchamber.keyboardShortcuts.tooltip')}
type="button" headerAction={(
variant="outline" <Button
size="xs" type="button"
className="!font-normal" variant="outline"
onClick={() => { size="xs"
resetAllShortcutOverrides(); className="!font-normal"
persistShortcutOverrides({}); onClick={() => {
setDraftByAction({}); resetAllShortcutOverrides();
setPendingOverwrite(null); persistShortcutOverrides({});
setErrorText(''); setDraftByAction({});
setWarningText(''); setPendingOverwrite(null);
}} setErrorText('');
> setWarningText('');
{t('settings.openchamber.keyboardShortcuts.actions.resetAll')} }}
</Button> >
<Tooltip> {t('settings.openchamber.keyboardShortcuts.actions.resetAll')}
<TooltipTrigger asChild> </Button>
<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>
{(errorText || warningText || pendingOverwrite) && ( {(errorText || warningText || pendingOverwrite) && (
<div className="mb-2 space-y-2 px-1"> <div className="mb-2 space-y-2">
{pendingOverwrite && ( {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"> <span className="typography-meta text-foreground">
{t('settings.openchamber.keyboardShortcuts.overwritePrompt')} {t('settings.openchamber.keyboardShortcuts.overwritePrompt')}
</span> </span>
@@ -214,7 +209,7 @@ export const KeyboardShortcutsSettings: React.FC = () => {
</div> </div>
)} )}
<section className="px-2 pb-2 pt-0 space-y-0.5"> <div>
{actions.map((action, index) => { {actions.map((action, index) => {
const effective = getEffectiveShortcutCombo(action.id, shortcutOverrides); const effective = getEffectiveShortcutCombo(action.id, shortcutOverrides);
const draft = draftByAction[action.id]; const draft = draftByAction[action.id];
@@ -222,11 +217,11 @@ export const KeyboardShortcutsSettings: React.FC = () => {
const hasDraft = typeof draft === 'string' && normalizeCombo(draft) !== normalizeCombo(effective); const hasDraft = typeof draft === 'string' && normalizeCombo(draft) !== normalizeCombo(effective);
return ( 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 key={action.id} className={cn("py-1.5", index > 0 && "border-t border-border/40")}>
<div className="flex min-w-0 flex-col sm:w-56 shrink-0"> <SettingsFieldRow
<span className="typography-ui-label text-foreground">{actionLabel(action.id, action.label)}</span> label={actionLabel(action.id, action.label)}
</div> alignEnd={false}
<div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial"> >
<Input <Input
readOnly readOnly
value={capturingActionId === action.id ? t('settings.openchamber.keyboardShortcuts.field.pressKeys') : formatShortcutForDisplay(displayCombo)} 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)}> <Button type="button" size="xs" className="!font-normal" variant="ghost" onClick={() => resetOne(action.id)}>
{t('settings.common.actions.reset')} {t('settings.common.actions.reset')}
</Button> </Button>
</div> </SettingsFieldRow>
</div> </div>
); );
})} })}
</section> </div>
</div> </SettingsSection>
); );
}; };
@@ -1,14 +1,19 @@
import React from 'react'; import React from 'react';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { isDesktopShell, isVSCodeRuntime } from '@/lib/desktop'; import { isDesktopShell, isVSCodeRuntime } from '@/lib/desktop';
import { Checkbox } from '@/components/ui/checkbox';
import { toast } from '@/components/ui'; import { toast } from '@/components/ui';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { getClientPlatform } from '@/lib/platform'; import { getClientPlatform } from '@/lib/platform';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import {
SettingsSection,
SettingsTwoColumn,
SettingsCheckboxRow,
SettingsGroupTitle,
SETTINGS_OPTION_STACK_CLASS,
} from '@/components/sections/shared/SettingsSection';
const DEFAULT_NOTIFICATION_TEMPLATES = { const DEFAULT_NOTIFICATION_TEMPLATES = {
completion: { completion: {
@@ -448,67 +453,40 @@ export const NotificationSettings: React.FC = () => {
}; };
return ( return (
<div className="space-y-8"> <>
<SettingsSection
{/* --- Global Delivery Settings --- */} settingsItem="notifications.delivery"
<div data-settings-item="notifications.delivery" className="mb-8"> title={t('settings.notifications.page.delivery.title')}
<div className="mb-1 px-1"> divider={false}
<h3 className="typography-ui-header font-medium text-foreground"> >
{t('settings.notifications.page.delivery.title')} <div className={SETTINGS_OPTION_STACK_CLASS}>
</h3> <SettingsCheckboxRow
</div> checked={nativeNotificationsEnabled && canShowNotifications}
onChange={(checked) => {
<section className="px-2 pb-2 pt-0 space-y-0.5"> void handleToggleChange(checked);
<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));
}} }}
onKeyDown={(event) => { label={t('settings.notifications.page.delivery.enableLabel')}
if (event.key === ' ' || event.key === 'Enter') { info={
event.preventDefault(); isBrowser
void handleToggleChange(!(nativeNotificationsEnabled && canShowNotifications)); ? t('settings.notifications.page.delivery.browserPermissionHint')
} : isVSCode
}} ? t('settings.notifications.page.delivery.vscodeHint')
> : undefined
<Checkbox }
checked={nativeNotificationsEnabled && canShowNotifications} ariaLabel={t('settings.notifications.page.delivery.enableAria')}
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>
{/* The native Capacitor app never notifies while focused (hard rule) and uses {/* The native Capacitor app never notifies while focused (hard rule) and uses
generic, non-customizable text, so the "notify while focused" toggle and the generic, non-customizable text, so the "notify while focused" toggle and the
test button are hidden there. */} test button are hidden there. */}
{nativeNotificationsEnabled && canShowNotifications && !isNativeApp && ( {nativeNotificationsEnabled && canShowNotifications && !isNativeApp && (
<> <>
<div <SettingsCheckboxRow
className="group flex cursor-pointer items-center gap-2 py-1.5" checked={notificationMode === 'always'}
role="button" onChange={(checked) => setNotificationMode(checked ? 'always' : 'hidden-only')}
tabIndex={0} label={t('settings.notifications.page.delivery.focusedLabel')}
aria-pressed={notificationMode === 'always'} ariaLabel={t('settings.notifications.page.delivery.focusedAria')}
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>
<div className="py-2"> <div className="py-2">
<Button <Button
@@ -522,13 +500,10 @@ export const NotificationSettings: React.FC = () => {
</div> </div>
</> </>
)} )}
</section> </div>
{isBrowser && ( {isBrowser && (
<div className="mt-1 px-2"> <div className="mt-1">
<p className="typography-meta text-muted-foreground/70">
{t('settings.notifications.page.delivery.browserPermissionHint')}
</p>
{notificationPermission === 'denied' && ( {notificationPermission === 'denied' && (
<p className="typography-meta text-[var(--status-error)] mt-1"> <p className="typography-meta text-[var(--status-error)] mt-1">
{t('settings.notifications.page.delivery.permissionDenied')} {t('settings.notifications.page.delivery.permissionDenied')}
@@ -541,104 +516,50 @@ export const NotificationSettings: React.FC = () => {
)} )}
</div> </div>
)} )}
{isVSCode && ( </SettingsSection>
<div className="mt-1 px-2">
<p className="typography-meta text-muted-foreground/70">
{t('settings.notifications.page.delivery.vscodeHint')}
</p>
</div>
)}
</div>
{nativeNotificationsEnabled && canShowNotifications && ( {nativeNotificationsEnabled && canShowNotifications && (
<> <>
{/* --- Events --- */} <SettingsSection
<div data-settings-item="notifications.events" className="mb-8"> settingsItem="notifications.events"
<div className="mb-1 px-1"> title={t('settings.notifications.page.events.title')}
<h3 className="typography-ui-header font-medium text-foreground"> >
{t('settings.notifications.page.events.title')} <div className={SETTINGS_OPTION_STACK_CLASS}>
</h3> <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> </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 && ( {!isNativeApp && (
<div className="mb-8"> <SettingsSection
<div className="mb-1 px-1"> title={t('settings.notifications.page.template.title')}
<h3 className="typography-ui-header font-medium text-foreground"> description={(
{t('settings.notifications.page.template.title')} <>
</h3>
<p className="typography-meta text-muted-foreground mt-0.5">
{t('settings.notifications.page.template.variablesLabel')}{' '} {t('settings.notifications.page.template.variablesLabel')}{' '}
<code className="text-[var(--primary-base)]">{'{project_name}'}</code>{' '} <code className="text-[var(--primary-base)]">{'{project_name}'}</code>{' '}
<code className="text-[var(--primary-base)]">{'{worktree}'}</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)]">{'{agent_name}'}</code>{' '}
<code className="text-[var(--primary-base)]">{'{model_name}'}</code>{' '} <code className="text-[var(--primary-base)]">{'{model_name}'}</code>{' '}
<code className="text-[var(--primary-base)]">{'{last_message}'}</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) => ( {(['completion', 'subtask', 'error', 'question'] as const).map((event: NotificationTemplateEvent) => (
<section key={event} className="p-2"> <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])} {t(TEMPLATE_EVENT_LABEL_KEYS[event])}
</span> </SettingsGroupTitle>
<div className="mt-1.5 space-y-2"> <div className="mt-1.5 space-y-2">
<div> <div>
<label className="typography-micro text-muted-foreground block mb-1">{t('settings.notifications.page.template.field.title')}</label> <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> </div>
</section> </section>
))} ))}
</div> </SettingsTwoColumn>
</div> </SettingsSection>
)} )}
</> </>
)} )}
{/* --- Background Push Notifications --- */}
{isBrowser && ( {isBrowser && (
<div data-settings-item="notifications.push" className="mb-8"> <SettingsSection
<div className="mb-1 px-1"> settingsItem="notifications.push"
<h3 className="typography-ui-header font-medium text-foreground"> title={t('settings.notifications.page.push.title')}
{t('settings.notifications.page.push.title')} >
</h3> <SettingsCheckboxRow
</div> checked={pushSupported ? pushSubscribed : false}
disabled={!pushSupported || pushBusy}
<section className="px-2 pb-2 pt-0"> onChange={(checked) => {
<div className="flex items-start gap-2 py-1.5"> if (checked) {
<Checkbox void handleEnableBackgroundNotifications();
checked={pushSupported ? pushSubscribed : false} } else {
disabled={!pushSupported || pushBusy} void handleDisableBackgroundNotifications();
onChange={(checked: boolean) => { }
if (checked) { }}
void handleEnableBackgroundNotifications(); label={t('settings.notifications.page.push.enableLabel')}
} else { description={!pushSupported ? t('settings.notifications.page.push.unsupportedHint') : undefined}
void handleDisableBackgroundNotifications(); info={pushSupported ? t('settings.notifications.page.push.supportedHint') : undefined}
} ariaLabel={t('settings.notifications.page.push.enableAria')}
}} labelAccessory={
ariaLabel={t('settings.notifications.page.push.enableAria')} 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')} />
<div className="flex min-w-0 flex-col"> ) : null
<span className={cn("typography-ui-label", !pushSupported ? "text-muted-foreground" : "text-foreground")}> }
{t('settings.notifications.page.push.enableLabel')} />
</span> </SettingsSection>
<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>
)} )}
</>
</div>
); );
}; };
@@ -12,9 +12,11 @@ import { TunnelSettings } from './TunnelSettings';
import { OpenCodeCliSettings } from './OpenCodeCliSettings'; import { OpenCodeCliSettings } from './OpenCodeCliSettings';
import { DesktopNetworkSettings } from './DesktopNetworkSettings'; import { DesktopNetworkSettings } from './DesktopNetworkSettings';
import { KeyboardShortcutsSettings } from './KeyboardShortcutsSettings'; import { KeyboardShortcutsSettings } from './KeyboardShortcutsSettings';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
import { useDeviceInfo } from '@/lib/device'; import { useDeviceInfo } from '@/lib/device';
import { isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime, isWebRuntime, usesFramelessElectronChrome } from '@/lib/desktop'; 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 { subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
import type { OpenChamberSection } from './types'; import type { OpenChamberSection } from './types';
@@ -34,6 +36,7 @@ interface OpenChamberPageProps {
} }
export const OpenChamberPage: React.FC<OpenChamberPageProps> = ({ section }) => { export const OpenChamberPage: React.FC<OpenChamberPageProps> = ({ section }) => {
const { t } = useI18n();
const { isMobile } = useDeviceInfo(); const { isMobile } = useDeviceInfo();
const runtimeEndpointEpoch = useRuntimeEndpointEpoch(); const runtimeEndpointEpoch = useRuntimeEndpointEpoch();
const showAbout = isMobile && isWebRuntime(); 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 no section specified, show all (mobile/legacy behavior)
if (!section) { if (!section) {
return ( return (
<ScrollableOverlay <SettingsPageLayout showSaveStatus className="openchamber-page-body space-y-3 sm:space-y-6">
outerClassName="h-full" <OpenChamberVisualSettings />
className="w-full" <DefaultsSettings />
> {showDesktopNetworkSettings && <DesktopNetworkSettings />}
<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"> {!isVSCode && <OpenCodeCliSettings />}
<OpenChamberVisualSettings /> <SessionRetentionSettings />
<div className="border-t border-border/40 pt-6"> {isWebRuntime() && !isDesktopShell() && !isVSCode && !isCapacitorApp() && <PasskeySettings />}
<DefaultsSettings /> {showAbout && <AboutSettings />}
</div> </SettingsPageLayout>
{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>
); );
} }
// Show specific section content // Show specific section content
const renderSectionContent = () => { const renderSectionContent = () => {
switch (section) { switch (section) {
case 'general':
return <GeneralSectionContent />;
case 'visual': case 'visual':
return <VisualSectionContent />; return <VisualSectionContent />;
case 'chat': 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 ( return (
<ScrollableOverlay <SettingsPageLayout
outerClassName="h-full" title={pageTitle}
className="w-full" 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()}
{renderSectionContent()} </SettingsPageLayout>
</div>
</ScrollableOverlay>
); );
}; };
@@ -121,6 +129,34 @@ const ShortcutsSectionContent: React.FC = () => {
return <KeyboardShortcutsSettings />; 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 // Visual section: Theme Mode, Font Size, Spacing, Input Bar Offset (mobile), Nav Rail
const VisualSectionContent: React.FC = () => { const VisualSectionContent: React.FC = () => {
const isVSCode = isVSCodeRuntime(); const isVSCode = isVSCodeRuntime();
@@ -134,14 +170,8 @@ const VisualSectionContent: React.FC = () => {
'fontSize', 'fontSize',
'terminalFontSize', 'terminalFontSize',
'editorFontSize', 'editorFontSize',
'fileEditorKeymap',
'spacing', 'spacing',
'inputBarOffset', 'inputBarOffset',
'expandedEditorToolbar',
...(!isVSCode ? ['terminalQuickKeys' as const] : []),
...(!isVSCode ? ['terminalShell' as const] : []),
...(!isVSCode ? ['terminalLoginShell' as const] : []),
'reportUsage',
]} />; ]} />;
}; };
@@ -154,7 +184,6 @@ const ChatSectionContent: React.FC = () => {
'sessionGoal', 'sessionGoal',
'sessionAssist', 'sessionAssist',
'chatRenderMode', 'chatRenderMode',
'messageTransport',
'activityRenderMode', 'activityRenderMode',
'userMessageRendering', 'userMessageRendering',
'mermaidRendering', 'mermaidRendering',
@@ -182,40 +211,17 @@ const ChatSectionContent: React.FC = () => {
// Sessions section: Default model & agent, Session retention // Sessions section: Default model & agent, Session retention
const SessionsSectionContent: React.FC = () => { const SessionsSectionContent: React.FC = () => {
const isVSCode = isVSCodeRuntime();
const runtimeEndpointEpoch = useRuntimeEndpointEpoch();
void runtimeEndpointEpoch;
const showDesktopNetworkSettings = isDesktopShell() && (isDesktopLocalOriginActive() || usesFramelessElectronChrome());
return ( return (
<div className="space-y-6"> <>
<DefaultsSettings /> <DefaultsSettings />
{showDesktopNetworkSettings && ( <SessionRetentionSettings />
<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>
); );
}; };
// Git section: Commit message model, Worktree settings // Git section: Commit message model, Worktree settings
const GitSectionContent: React.FC = () => { const GitSectionContent: React.FC = () => {
return ( return <GitSettings />;
<div className="space-y-6">
<GitSettings />
</div>
);
}; };
// GitHub section: Connect account for PR/issue workflows // 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 * as React from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Icon } from "@/components/icon/Icon"; 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 { isDesktopShell, requestFileAccess } from '@/lib/desktop';
import { updateDesktopSettings } from '@/lib/persistence'; import { updateDesktopSettings } from '@/lib/persistence';
import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore'; import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
@@ -96,90 +102,69 @@ export const OpenCodeCliSettings: React.FC = () => {
}, [setShowOpenCodeUpdateNotifications]); }, [setShowOpenCodeUpdateNotifications]);
return ( return (
<div className="mb-8"> <SettingsSection title={t('settings.openchamber.opencodeCli.title')}>
<div className="mb-1 px-1"> <div className="space-y-0.5">
<div className="flex items-center gap-2"> <SettingsFieldRow
<h3 className="typography-ui-header font-medium text-foreground"> settingsItem="sessions.opencode-binary"
{t('settings.openchamber.opencodeCli.title')} label={t('settings.openchamber.opencodeCli.field.binaryPath')}
</h3> info={(
<Tooltip> <>
<TooltipTrigger asChild> {t('settings.openchamber.opencodeCli.tipPrefix')}
<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')}
{' '} {' '}
<code className="font-mono text-xs">opencode</code> <span className="font-mono">OPENCODE_BINARY</span>
{t('settings.openchamber.opencodeCli.tooltipSuffix')} {' '}
</TooltipContent> {t('settings.openchamber.opencodeCli.tipMiddle')}
</Tooltip> {' '}
</div> <span className="font-mono">~/.config/openchamber/settings.json</span>
</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"> alignEnd={false}
<div className="flex min-w-0 flex-col shrink-0"> controlClassName="@xl:w-[20rem]"
<span className="typography-ui-label text-foreground">{t('settings.openchamber.opencodeCli.field.binaryPath')}</span> >
</div> <Input
<div className="flex min-w-0 items-center gap-2 sm:w-[20rem]"> value={value}
<Input onChange={(e) => setValue(e.target.value)}
value={value} placeholder={t('settings.openchamber.opencodeCli.field.binaryPathPlaceholder')}
onChange={(e) => setValue(e.target.value)} disabled={isLoading || isSaving}
placeholder={t('settings.openchamber.opencodeCli.field.binaryPathPlaceholder')} className="h-8 min-w-0 flex-1 font-mono text-xs"
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="typography-ui-label text-foreground">
{t('settings.openchamber.opencodeCli.field.showUpdateNotifications')}
</span>
</label>
<div className="flex justify-start py-1.5">
<Button <Button
type="button" type="button"
variant="outline"
size="xs" size="xs"
onClick={handleSaveAndReload} onClick={handleBrowse}
disabled={isLoading || isSaving} disabled={isLoading || isSaving || !isDesktopShell()}
className="shrink-0 !font-normal" 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> </Button>
</div> </SettingsFieldRow>
</section>
</div> <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 PasskeyStatus,
type StoredPasskey, type StoredPasskey,
} from '@/lib/passkeys'; } from '@/lib/passkeys';
import { SettingsSection, SettingsFieldRow } from '@/components/sections/shared/SettingsSection';
import { getCurrentIntlLocale, useI18n } from '@/lib/i18n'; import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
import { useUIStore, type TimeFormatPreference } from '@/stores/useUIStore'; import { useUIStore, type TimeFormatPreference } from '@/stores/useUIStore';
@@ -172,39 +173,30 @@ export const PasskeySettings: React.FC = () => {
}, [t]); }, [t]);
return ( return (
<div className="mb-8"> <SettingsSection title={t('settings.openchamber.passkeys.title')}>
<div className="mb-1 px-1"> <div className="space-y-2">
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.passkeys.title')}</h3> <SettingsFieldRow label={t('settings.openchamber.passkeys.field.currentDevice')}>
</div> <Button
type="button"
<section className="px-2 pb-2 pt-0 space-y-2"> variant={isRegistering ? 'secondary' : 'outline'}
<div className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8"> size="xs"
<div className="flex min-w-0 flex-col sm:w-56 shrink-0"> onClick={() => void handleRegisterPasskey()}
<span className="typography-ui-label text-foreground">{t('settings.openchamber.passkeys.field.currentDevice')}</span> disabled={isLoading || isResetting}
</div> className="!font-normal"
<div className="flex items-center gap-2 sm:w-fit"> >
<Button {isRegistering ? t('settings.openchamber.passkeys.actions.cancelSetup') : t('settings.openchamber.passkeys.actions.add')}
type="button" </Button>
variant={isRegistering ? 'secondary' : 'outline'} <Button
size="xs" type="button"
onClick={() => void handleRegisterPasskey()} variant="ghost"
disabled={isLoading || isResetting} size="xs"
className="!font-normal" onClick={() => void handleResetAllAuth()}
> disabled={isLoading || isRegistering || isResetting}
{isRegistering ? t('settings.openchamber.passkeys.actions.cancelSetup') : t('settings.openchamber.passkeys.actions.add')} className="!font-normal text-muted-foreground hover:text-foreground"
</Button> >
<Button {isResetting ? t('settings.openchamber.passkeys.actions.signingOut') : t('settings.openchamber.passkeys.actions.signOutEverywhere')}
type="button" </Button>
variant="ghost" </SettingsFieldRow>
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>
{!status.enabled && ( {!status.enabled && (
<p className="typography-meta text-muted-foreground"> <p className="typography-meta text-muted-foreground">
@@ -225,42 +217,42 @@ export const PasskeySettings: React.FC = () => {
) : ( ) : (
<div className="space-y-1 pt-1"> <div className="space-y-1 pt-1">
{passkeys.map((passkey) => ( {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"> <SettingsFieldRow
<div className="flex min-w-0 flex-col sm:w-56 shrink-0"> key={passkey.id}
<span className="typography-ui-label text-foreground truncate">{passkey.label}</span> label={<span className="truncate">{passkey.label}</span>}
</div> alignEnd={false}
<div className="flex min-w-0 flex-1 items-center justify-between gap-3"> controlClassName="justify-between sm:flex-1"
<span className="typography-meta text-muted-foreground truncate"> >
{passkey.lastUsedAt <span className="typography-meta text-muted-foreground truncate">
? t('settings.openchamber.passkeys.item.lastUsed', { {passkey.lastUsedAt
time: formatTimestamp(passkey.lastUsedAt, t('settings.openchamber.passkeys.time.neverUsed'), timeFormatPreference), ? 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), : t('settings.openchamber.passkeys.item.added', {
})} time: formatTimestamp(passkey.createdAt, t('settings.openchamber.passkeys.time.neverUsed'), timeFormatPreference),
</span> })}
<Button </span>
type="button" <Button
variant="ghost" type="button"
size="xs" variant="ghost"
onClick={() => void handleRevokePasskey(passkey.id)} size="xs"
disabled={revokingId === passkey.id} onClick={() => void handleRevokePasskey(passkey.id)}
className="!font-normal text-muted-foreground hover:text-foreground" 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> {revokingId === passkey.id ? t('settings.openchamber.passkeys.actions.removing') : t('settings.common.actions.delete')}
</div> </Button>
</div> </SettingsFieldRow>
))} ))}
</div> </div>
)} )}
</section> </div>
{errorMessage && ( {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> <p className="typography-meta text-[var(--status-error)]">{errorMessage}</p>
</div> </div>
)} )}
</div> </SettingsSection>
); );
}; };
@@ -1,21 +1,27 @@
import React from 'react'; import React from 'react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { toast } from '@/components/ui'; import { toast } from '@/components/ui';
import { NumberInput } from '@/components/ui/number-input'; import { NumberInput } from '@/components/ui/number-input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Icon } from "@/components/icon/Icon"; 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 { useUIStore } from '@/stores/useUIStore';
import { useSessionAutoCleanup } from '@/hooks/useSessionAutoCleanup'; import { useSessionAutoCleanup } from '@/hooks/useSessionAutoCleanup';
import { useI18n } from '@/lib/i18n'; import { useI18n, type I18nKey } from '@/lib/i18n';
const MIN_DAYS = 1; const MIN_DAYS = 1;
const MAX_DAYS = 365; const MAX_DAYS = 365;
const DEFAULT_RETENTION_DAYS = 30; 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: 'archive', labelKey: 'settings.openchamber.sessionRetention.action.archive' },
{ value: 'delete', labelKey: 'settings.openchamber.sessionRetention.action.delete' }, { value: 'delete', labelKey: 'settings.openchamber.sessionRetention.action.delete' },
] as const; ];
export const SessionRetentionSettings: React.FC = () => { export const SessionRetentionSettings: React.FC = () => {
const { t } = useI18n(); const { t } = useI18n();
@@ -57,121 +63,83 @@ export const SessionRetentionSettings: React.FC = () => {
}, [runCleanup, t]); }, [runCleanup, t]);
return ( return (
<div className="mb-8"> <SettingsSection
<div className="mb-1 px-1"> title={t('settings.openchamber.sessionRetention.title')}
<div className="flex items-center gap-2"> info={t('settings.openchamber.sessionRetention.tooltip')}
<h3 className="typography-ui-header font-medium text-foreground"> >
{t('settings.openchamber.sessionRetention.title')} <SettingsCheckboxRow
</h3> settingsItem="sessions.auto-cleanup"
<Tooltip> checked={autoDeleteEnabled}
<TooltipTrigger asChild> onChange={setAutoDeleteEnabled}
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" /> label={t('settings.openchamber.sessionRetention.field.enableAutoCleanup')}
</TooltipTrigger> ariaLabel={t('settings.openchamber.sessionRetention.field.enableAutoCleanupAria')}
<TooltipContent sideOffset={8} className="max-w-xs"> />
{t('settings.openchamber.sessionRetention.tooltip')}
</TooltipContent>
</Tooltip>
</div>
</div>
<section className="px-2 pb-2 pt-0 space-y-0.5"> <SettingsInset className="space-y-0">
<div <SettingsFieldRow
data-settings-item="sessions.auto-cleanup" settingsItem="sessions.retention-period"
className="group flex cursor-pointer items-center gap-2 py-1.5" label={t('settings.openchamber.sessionRetention.field.retentionPeriod')}
role="button"
tabIndex={0}
aria-pressed={autoDeleteEnabled}
onClick={() => setAutoDeleteEnabled(!autoDeleteEnabled)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
setAutoDeleteEnabled(!autoDeleteEnabled);
}
}}
> >
<Checkbox <NumberInput
checked={autoDeleteEnabled} value={autoDeleteAfterDays}
onChange={setAutoDeleteEnabled} onValueChange={setAutoDeleteAfterDays}
ariaLabel={t('settings.openchamber.sessionRetention.field.enableAutoCleanupAria')} 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> <span className="typography-ui-label text-muted-foreground">{t('settings.openchamber.sessionRetention.field.days')}</span>
</div> <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"> <SettingsFieldRow
<div className="flex min-w-0 flex-col sm:w-56 shrink-0"> settingsItem="sessions.retention-action"
<span className="typography-ui-label text-foreground">{t('settings.openchamber.sessionRetention.field.retentionPeriod')}</span> label={t('settings.openchamber.sessionRetention.field.whenSessionsExpire')}
</div> >
<div className="flex items-center gap-2 sm:w-fit"> <SettingsChipGroup
<NumberInput value={sessionRetentionAction}
value={autoDeleteAfterDays} onChange={setSessionRetentionAction}
onValueChange={setAutoDeleteAfterDays} options={RETENTION_ACTION_OPTIONS.map((option) => ({
min={MIN_DAYS} value: option.value,
max={MAX_DAYS} label: t(option.labelKey),
step={1} }))}
aria-label={t('settings.openchamber.sessionRetention.field.retentionPeriodAria')} />
className="w-20 tabular-nums" </SettingsFieldRow>
/> </SettingsInset>
<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>
<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="mt-1 py-1.5 space-y-1">
<div className="flex min-w-0 flex-col sm:w-56 shrink-0"> <SettingsFieldRow
<span className="typography-ui-label text-foreground">{t('settings.openchamber.sessionRetention.field.whenSessionsExpire')}</span> label={t('settings.openchamber.sessionRetention.manualCleanup.title')}
</div> >
<div className="flex flex-wrap items-center gap-1 sm:w-fit"> <Button
{RETENTION_ACTION_OPTIONS.map((option) => ( type="button"
<Button variant="outline"
key={option.value} size="xs"
type="button" onClick={handleRunCleanup}
variant="chip" disabled={isRunning}
size="xs" className="!font-normal"
aria-pressed={sessionRetentionAction === option.value} >
className="!font-normal" {isRunning ? t('settings.openchamber.sessionRetention.actions.cleaningUp') : t('settings.openchamber.sessionRetention.actions.runCleanupNow')}
onClick={() => setSessionRetentionAction(option.value)} </Button>
> </SettingsFieldRow>
{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>
<p className="typography-meta text-muted-foreground"> <p className="typography-meta text-muted-foreground">
{action === 'archive' {action === 'archive'
? t('settings.openchamber.sessionRetention.manualCleanup.eligibleArchiveNow', { count: pendingCount }) ? t('settings.openchamber.sessionRetention.manualCleanup.eligibleArchiveNow', { count: pendingCount })
: t('settings.openchamber.sessionRetention.manualCleanup.eligibleDeleteNow', { count: pendingCount })} : t('settings.openchamber.sessionRetention.manualCleanup.eligibleDeleteNow', { count: pendingCount })}
</p> </p>
</div> </div>
</div> </SettingsSection>
); );
}; };
@@ -16,6 +16,8 @@ import { openExternalUrl } from '@/lib/url';
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch'; import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
import { formatTimeForPreference } from '@/lib/timeFormat'; import { formatTimeForPreference } from '@/lib/timeFormat';
import { useUIStore, type TimeFormatPreference } from '@/stores/useUIStore'; 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 = type TunnelState =
| 'checking' | 'checking'
@@ -1236,26 +1238,24 @@ export const TunnelSettings: React.FC = () => {
} }
return ( return (
<div className="space-y-6"> <SettingsSection
<div> title={t('settings.openchamber.tunnel.title')}
<h3 className="typography-ui-header font-semibold text-foreground">{t('settings.openchamber.tunnel.title')}</h3> info={(
<p className="typography-meta mt-0 text-muted-foreground/70"> <div className="space-y-1">
{t('settings.openchamber.tunnel.description')} <p>{t('settings.openchamber.tunnel.description')}</p>
</p> <p>{t('settings.openchamber.tunnel.note.serverSideEnforced')}</p>
<p className="typography-meta mt-0 text-muted-foreground/60"> <p>{t('settings.openchamber.tunnel.note.connectLinksOneTime')}</p>
{t('settings.openchamber.tunnel.note.serverSideEnforced')} </div>
</p> )}
<p className="typography-meta mt-0 text-muted-foreground/60"> divider={false}
{t('settings.openchamber.tunnel.note.connectLinksOneTime')} >
</p> <div className="space-y-6">
</div>
{renderedSessionRecords.length > 0 && ( {renderedSessionRecords.length > 0 && (
<section className="space-y-2 px-2 pb-2 pt-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="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"> <div className="mb-2 flex items-center gap-2">
<Icon name="information" className="size-4 text-[var(--status-info)]" /> <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>
<div className="space-y-1"> <div className="space-y-1">
{renderedSessionRecords.map((record) => { {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"> <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)]" /> <Icon name="error-warning" className="mt-0.5 size-4 shrink-0 text-[var(--status-warning)]" />
<div className="space-y-1"> <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 })} {t('settings.openchamber.tunnel.notAvailable.dependencyNotFound', { dependency: displayedDependencyInstallInfo.dependency })}
</p> </p>
<p className="typography-meta text-muted-foreground/70">{t('settings.openchamber.tunnel.notAvailable.installHint')}</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"> <section className="space-y-4 px-2 pb-2 pt-0">
<div className="space-y-3"> <div className="space-y-3">
<div data-settings-item="tunnel.provider" className="space-y-1.5"> <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 <Select
value={tunnelProvider} value={tunnelProvider}
onValueChange={(value) => { onValueChange={(value) => {
@@ -1331,7 +1331,7 @@ export const TunnelSettings: React.FC = () => {
}} }}
disabled={isSavingMode || state === 'starting' || state === 'stopping'} 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')}> <SelectValue placeholder={t('settings.openchamber.tunnel.field.providerPlaceholder')}>
{getProviderLabel(tunnelProvider)} {getProviderLabel(tunnelProvider)}
</SelectValue> </SelectValue>
@@ -1354,7 +1354,7 @@ export const TunnelSettings: React.FC = () => {
</div> </div>
<div data-settings-item="tunnel.type" className="space-y-1.5"> <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"> <div className="flex flex-wrap items-center gap-1">
{tunnelModeOptions.map((option) => ( {tunnelModeOptions.map((option) => (
<Tooltip key={option.value}> <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 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"> <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 <Select
value={ttlOptionValue(BOOTSTRAP_TTL_OPTIONS, bootstrapTtlMs, '1800000')} value={ttlOptionValue(BOOTSTRAP_TTL_OPTIONS, bootstrapTtlMs, '1800000')}
onValueChange={(value) => { onValueChange={(value) => {
@@ -1391,7 +1391,7 @@ export const TunnelSettings: React.FC = () => {
}} }}
disabled={isSavingTtl || isSavingMode || state === 'starting' || state === 'stopping'} 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"> <SelectValue className="truncate">
{ttlOptionLabel(BOOTSTRAP_TTL_OPTIONS, bootstrapTtlMs, '1800000')} {ttlOptionLabel(BOOTSTRAP_TTL_OPTIONS, bootstrapTtlMs, '1800000')}
</SelectValue> </SelectValue>
@@ -1405,7 +1405,7 @@ export const TunnelSettings: React.FC = () => {
</div> </div>
<div className="flex min-w-0 items-center gap-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.tunnelSessionTtl')}</span> <span className={cn(SETTINGS_FIELD_LABEL_CLASS, 'shrink-0')}>{t('settings.openchamber.tunnel.field.tunnelSessionTtl')}</span>
<Select <Select
value={ttlOptionValue(SESSION_TTL_OPTIONS, sessionTtlMs, '28800000')} value={ttlOptionValue(SESSION_TTL_OPTIONS, sessionTtlMs, '28800000')}
onValueChange={(value) => { onValueChange={(value) => {
@@ -1413,7 +1413,7 @@ export const TunnelSettings: React.FC = () => {
}} }}
disabled={isSavingTtl || isSavingMode || state === 'starting' || state === 'stopping'} 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"> <SelectValue className="truncate">
{ttlOptionLabel(SESSION_TTL_OPTIONS, sessionTtlMs, '28800000')} {ttlOptionLabel(SESSION_TTL_OPTIONS, sessionTtlMs, '28800000')}
</SelectValue> </SelectValue>
@@ -1456,7 +1456,7 @@ export const TunnelSettings: React.FC = () => {
)} )}
<div className="mb-1 flex items-center justify-between gap-3"> <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 <Button
variant="ghost" variant="ghost"
size="xs" size="xs"
@@ -1634,20 +1634,9 @@ export const TunnelSettings: React.FC = () => {
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<p className="typography-meta text-muted-foreground/80">{t('settings.openchamber.tunnel.note.tokensSavedPerTunnel')}</p> <p className="typography-meta text-muted-foreground/80">{t('settings.openchamber.tunnel.note.tokensSavedPerTunnel')}</p>
<Tooltip> <SettingsInfoHint>
<TooltipTrigger asChild> {t('settings.openchamber.tunnel.tooltip.tokensSavedPath')}
<button </SettingsInfoHint>
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>
</div> </div>
{!selectedPreset && managedRemoteValidationError && ( {!selectedPreset && managedRemoteValidationError && (
@@ -1659,7 +1648,7 @@ export const TunnelSettings: React.FC = () => {
{tunnelMode === 'managed-local' && ( {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 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"> <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 <input
ref={managedLocalConfigFileInputRef} ref={managedLocalConfigFileInputRef}
type="file" type="file"
@@ -1772,7 +1761,7 @@ export const TunnelSettings: React.FC = () => {
{tunnelMode === 'managed-remote' && ( {tunnelMode === 'managed-remote' && (
<div className="space-y-1.5"> <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 <Select
value={selectedPresetId || (managedRemoteTunnelPresets[0]?.id ?? '')} value={selectedPresetId || (managedRemoteTunnelPresets[0]?.id ?? '')}
onValueChange={(presetId) => { onValueChange={(presetId) => {
@@ -1785,7 +1774,7 @@ export const TunnelSettings: React.FC = () => {
|| managedRemoteTunnelPresets.length <= 1 || managedRemoteTunnelPresets.length <= 1
} }
> >
<SelectTrigger> <SelectTrigger size={SETTINGS_SELECT_SIZE}>
<SelectValue placeholder={t('settings.openchamber.tunnel.field.selectSavedTunnelPlaceholder')}> <SelectValue placeholder={t('settings.openchamber.tunnel.field.selectSavedTunnelPlaceholder')}>
{selectedPreset?.name} {selectedPreset?.name}
</SelectValue> </SelectValue>
@@ -1909,6 +1898,7 @@ export const TunnelSettings: React.FC = () => {
<Button size="sm" variant="ghost" onClick={handleStart}>{t('settings.openchamber.tunnel.actions.retry')}</Button> <Button size="sm" variant="ghost" onClick={handleStart}>{t('settings.openchamber.tunnel.actions.retry')}</Button>
</section> </section>
)} )}
</div> </div>
</SettingsSection>
); );
}; };
@@ -9,12 +9,23 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { Radio } from '@/components/ui/radio'; import { Radio } from '@/components/ui/radio';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { NumberInput } from '@/components/ui/number-input'; import { NumberInput } from '@/components/ui/number-input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Icon } from "@/components/icon/Icon"; 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 { browserVoiceService } from '@/lib/voice/browserVoiceService';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { runtimeFetch } from '@/lib/runtime-fetch'; import { runtimeFetch } from '@/lib/runtime-fetch';
@@ -153,92 +164,104 @@ const LocalModelPicker = ({
}; };
return ( 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) => { {LOCAL_STT_MODELS.map((entry) => {
const selected = selectedModelId === entry.id; const selected = selectedModelId === entry.id;
const state = models.get(entry.id) ?? null; const state = models.get(entry.id) ?? null;
return ( return (
<div key={entry.id} className="flex w-full items-start gap-2 py-1.5"> <div
<div className="pt-0.5"> key={entry.id}
<Radio className={cn(
checked={selected} 'rounded-lg border border-[var(--interactive-border)] p-3',
onChange={() => onSelect(entry.id)} selected && 'border-[var(--primary-base)] bg-[var(--primary-base)]/5',
ariaLabel={tUnsafe(entry.labelKey)} )}
/> >
</div> <div className="flex items-start justify-between gap-2">
<div <div
className="min-w-0 flex-1 cursor-pointer" className="flex min-w-0 flex-1 cursor-pointer items-start gap-2"
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={() => onSelect(entry.id)} onClick={() => onSelect(entry.id)}
onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); onSelect(entry.id); } }} onKeyDown={(e) => {
> if (e.key === ' ' || e.key === 'Enter') {
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5"> e.preventDefault();
<span className={cn('typography-ui-label font-normal', selected ? 'text-foreground' : 'text-foreground/50')}> onSelect(entry.id);
{tUnsafe(entry.labelKey)} }
</span> }}
{entry.badgeKey ? ( >
<span <div className="pt-0.5">
className="rounded px-1 text-[9px] font-medium uppercase leading-[14px] tracking-wide" <Radio
style={{ checked={selected}
backgroundColor: 'var(--status-success-background)', onChange={() => onSelect(entry.id)}
color: 'var(--status-success)', ariaLabel={tUnsafe(entry.labelKey)}
border: '1px solid var(--status-success-border)', />
}} </div>
> <div className="min-w-0 flex-1 space-y-2">
{tUnsafe(entry.badgeKey)} <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> </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 <Button
variant="ghost" variant="ghost"
size="xs" 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} disabled={requestingId === entry.id}
onClick={() => { void handleDelete(entry.id); }} onClick={() => { void handleDownload(entry.id); }}
title={t('settings.voice.page.stt.modelDelete')} title={t('settings.voice.page.stt.modelDownload')}
aria-label={t('settings.voice.page.stt.modelDelete')} 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> </Button>
<Icon )}
name="checkbox-circle" </div>
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> </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"; 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 ( 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) */} {showMessageTTSButtons && (
<div data-settings-item="voice.playback" className="mb-8"> <>
<div className="mb-1 px-1"> <SettingsControlGroup
<h3 className="typography-ui-header font-medium text-foreground"> title={t('settings.voice.page.field.provider')}
{t('settings.voice.page.section.playbackAndSummary')} info={(
</h3> <ul className="space-y-1">
</div> <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>
<section className="px-2 pb-2 pt-0 space-y-0"> <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>
<div <li><strong>{t('settings.voice.page.provider.say')}</strong> {t('settings.voice.page.tooltip.say')}</li>
className="group flex cursor-pointer items-center gap-2 py-1.5" </ul>
role="button" )}
tabIndex={0} >
aria-pressed={showMessageTTSButtons} <SettingsChipGroup
onClick={() => setShowMessageTTSButtons(!showMessageTTSButtons)} value={voiceProvider}
onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); setShowMessageTTSButtons(!showMessageTTSButtons); } }} onChange={setVoiceProvider}
> aria-label={t('settings.voice.page.field.provider')}
<Checkbox checked={showMessageTTSButtons} onChange={setShowMessageTTSButtons} ariaLabel={t('settings.voice.page.field.messageReadAloudButtonAria')} /> className="w-full gap-1.5 sm:gap-2"
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.messageReadAloudButton')}</span> options={[
</div> { value: 'browser', label: t('settings.voice.page.provider.browser') },
{ value: 'local', label: t('settings.voice.page.provider.local') },
{showMessageTTSButtons && ( { value: 'openai', label: t('settings.voice.page.provider.openai') },
<> { value: 'openai-compatible', label: t('settings.voice.page.provider.custom') },
<div className="pb-1.5 pt-0.5"> ...(isSayAvailable
<div className="flex min-w-0 flex-col gap-1.5"> ? [{
<div className="flex items-center gap-1.5"> value: 'say' as const,
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.provider')}</span> label: (
<Tooltip> <>
<TooltipTrigger asChild> <Icon name="apple" className="mr-0.5 h-3.5 w-3.5" />
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" /> {t('settings.voice.page.provider.say')}
</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> </SettingsControlGroup>
<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>
{/* OpenAI API Key */} {/* OpenAI API Key */}
{voiceProvider === 'openai' && ( {voiceProvider === 'openai' && (
<div className="py-1.5"> <div className="space-y-1.5">
<span className={cn("typography-ui-label text-foreground", !isOpenAIAvailable && "text-[var(--status-error)]")}> <span className={cn(SETTINGS_FIELD_LABEL_CLASS, !isOpenAIAvailable && "text-[var(--status-error)]")}>
{t('settings.voice.page.field.apiKey')} {t('settings.voice.page.field.apiKey')}
</span> </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 {isOpenAIAvailable && !openaiApiKey
? t('settings.voice.page.field.apiKeyHintUsingConfig') ? t('settings.voice.page.field.apiKeyHintUsingConfig')
: !isOpenAIAvailable : !isOpenAIAvailable
? t('settings.voice.page.field.apiKeyHintRequired') ? t('settings.voice.page.field.apiKeyHintRequired')
: t('settings.voice.page.field.apiKeyHintProvide')} : t('settings.voice.page.field.apiKeyHintProvide')}
</span> </span>
<div className="relative mt-1.5 max-w-xs"> <div className={cn('relative', SETTINGS_CONTROL_CLUSTER_CLASS)}>
<input <input
type="password" type="password"
value={openaiApiKey} value={openaiApiKey}
@@ -893,15 +871,15 @@ export const VoiceSettings: React.FC = () => {
{/* OpenAI-compatible custom server */} {/* OpenAI-compatible custom server */}
{voiceProvider === 'openai-compatible' && ( {voiceProvider === 'openai-compatible' && (
<div className="py-1.5 space-y-2"> <div className="space-y-3">
<div> <div className="space-y-1.5">
<span className={cn("typography-ui-label text-foreground", !openaiCompatibleUrl.trim() && "text-[var(--status-error)]")}> <span className="flex items-center gap-1.5">
{t('settings.voice.page.field.serverUrl')} <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>
<span className="typography-meta ml-2 text-muted-foreground"> <div className={cn('relative', SETTINGS_CONTROL_CLUSTER_CLASS)}>
{t('settings.voice.page.field.serverUrlHint')}
</span>
<div className="relative mt-1.5 max-w-xs">
<input <input
type="text" type="text"
value={openaiCompatibleUrl} value={openaiCompatibleUrl}
@@ -920,12 +898,12 @@ export const VoiceSettings: React.FC = () => {
)} )}
</div> </div>
</div> </div>
<div> <div className="space-y-1.5">
<span className="typography-ui-label text-foreground">API Key</span> <span className={SETTINGS_FIELD_LABEL_CLASS}>API Key</span>
<span className="typography-meta ml-2 text-muted-foreground"> <span className={SETTINGS_HELPER_CLASS}>
Optional Optional
</span> </span>
<div className="relative mt-1.5 max-w-xs"> <div className={cn('relative', SETTINGS_CONTROL_CLUSTER_CLASS)}>
<input <input
type="password" type="password"
value={openaiCompatibleApiKey} value={openaiCompatibleApiKey}
@@ -944,9 +922,9 @@ export const VoiceSettings: React.FC = () => {
)} )}
</div> </div>
</div> </div>
<div> <div className="space-y-1.5">
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.model')}</span> <span className={SETTINGS_FIELD_LABEL_CLASS}>{t('settings.voice.page.field.model')}</span>
<div className="relative mt-1.5 max-w-xs"> <div className={cn('relative', SETTINGS_CONTROL_CLUSTER_CLASS)}>
<input <input
type="text" type="text"
value={openaiCompatibleTtsModel} value={openaiCompatibleTtsModel}
@@ -956,13 +934,13 @@ export const VoiceSettings: React.FC = () => {
/> />
</div> </div>
</div> </div>
<div> <div className="space-y-1.5">
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.voice')}</span> <span className="flex items-center gap-1.5">
<span className="typography-meta ml-2 text-muted-foreground"> <span className={SETTINGS_FIELD_LABEL_CLASS}>{t('settings.voice.page.field.voice')}</span>
{t('settings.voice.page.field.voiceIdentifierHint')} <SettingsInfoHint>{t('settings.voice.page.field.voiceIdentifierHint')}</SettingsInfoHint>
</span> </span>
<div className="flex items-center gap-2 mt-1.5"> <div className={cn('flex items-center gap-2', SETTINGS_CONTROL_CLUSTER_CLASS)}>
<div className="relative max-w-xs flex-1"> <div className="relative min-w-0 flex-1">
<input <input
type="text" type="text"
value={openaiCompatibleVoice} value={openaiCompatibleVoice}
@@ -983,16 +961,14 @@ export const VoiceSettings: React.FC = () => {
{voiceProvider === 'local' && <LocalTtsModelStatus />} {voiceProvider === 'local' && <LocalTtsModelStatus />}
{/* Voice Selection */} {/* Voice Selection */}
<div className="flex items-center gap-8 py-1.5"> <SettingsFieldRow label={t('settings.voice.page.field.voice')}>
<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">
{voiceProvider === 'local' && ( {voiceProvider === 'local' && (
<> <>
<Select <Select
value={String(localTtsVoiceId)} value={String(localTtsVoiceId)}
onValueChange={(value) => setLocalTtsVoiceId(Number.parseInt(value, 10) || 0)} 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')}> <SelectValue placeholder={t('settings.voice.page.field.selectVoicePlaceholder')}>
{(value) => KOKORO_VOICE_OPTIONS.find((v) => String(v.id) === value)?.label ?? value} {(value) => KOKORO_VOICE_OPTIONS.find((v) => String(v.id) === value)?.label ?? value}
</SelectValue> </SelectValue>
@@ -1015,7 +991,7 @@ export const VoiceSettings: React.FC = () => {
{voiceProvider === 'openai' && isOpenAIAvailable && ( {voiceProvider === 'openai' && isOpenAIAvailable && (
<> <>
<Select value={openaiVoice} onValueChange={setOpenaiVoice}> <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')} /> <SelectValue placeholder={t('settings.voice.page.field.selectVoicePlaceholder')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -1031,13 +1007,13 @@ export const VoiceSettings: React.FC = () => {
)} )}
{voiceProvider === 'openai-compatible' && ( {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 && ( {voiceProvider === 'say' && isSayAvailable && sayVoices.length > 0 && (
<> <>
<Select value={sayVoice} onValueChange={setSayVoice}> <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')} /> <SelectValue placeholder={t('settings.voice.page.field.selectVoicePlaceholder')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -1055,7 +1031,7 @@ export const VoiceSettings: React.FC = () => {
{voiceProvider === 'browser' && filteredBrowserVoices.length > 0 && ( {voiceProvider === 'browser' && filteredBrowserVoices.length > 0 && (
<> <>
<Select value={browserVoice || '$auto'} onValueChange={(value) => setBrowserVoice(value === '$auto' ? '' : value)}> <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')} /> <SelectValue placeholder={t('settings.voice.page.field.auto')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -1070,31 +1046,22 @@ export const VoiceSettings: React.FC = () => {
</Button> </Button>
</> </>
)} )}
</div> </SettingsFieldRow>
</div>
{/* Speech Rate */} {/* Speech Rate */}
<div className="flex items-center gap-8 py-1.5"> <SettingsFieldRow label={t('settings.voice.page.field.speechRate')}>
<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">
{!isMobile && <input type="range" min={0.5} max={2} step={0.1} value={speechRate} onChange={(e) => setSpeechRate(Number(e.target.value))} className={sliderClass} />} {!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" /> <NumberInput value={speechRate} onValueChange={setSpeechRate} min={0.5} max={2} step={0.1} className="w-16 tabular-nums" />
</div> </SettingsFieldRow>
</div>
{/* Speech Pitch */} {/* Speech Pitch */}
<div className="flex items-center gap-8 py-1.5"> <SettingsFieldRow label={t('settings.voice.page.field.speechPitch')}>
<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">
{!isMobile && <input type="range" min={0.5} max={2} step={0.1} value={speechPitch} onChange={(e) => setSpeechPitch(Number(e.target.value))} className={sliderClass} />} {!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" /> <NumberInput value={speechPitch} onValueChange={setSpeechPitch} min={0.5} max={2} step={0.1} className="w-16 tabular-nums" />
</div> </SettingsFieldRow>
</div>
{/* Speech Volume */} {/* Speech Volume */}
<div className="flex items-center gap-8 py-1.5"> <SettingsFieldRow label={t('settings.voice.page.field.speechVolume')}>
<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">
{!isMobile && <input type="range" min={0} max={1} step={0.1} value={speechVolume} onChange={(e) => setSpeechVolume(Number(e.target.value))} className={sliderClass} />} {!isMobile && <input type="range" min={0} max={1} step={0.1} value={speechVolume} onChange={(e) => setSpeechVolume(Number(e.target.value))} className={sliderClass} />}
{isMobile ? ( {isMobile ? (
<NumberInput value={Math.round(speechVolume * 100)} onValueChange={(v) => setSpeechVolume(v / 100)} min={0} max={100} step={10} className="w-16 tabular-nums" /> <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)}% {Math.round(speechVolume * 100)}%
</span> </span>
)} )}
</div> </SettingsFieldRow>
</div>
{/* TTS input mode */} <SettingsControlGroup title={t('settings.voice.page.field.ttsInputMode')}>
<div className="pb-1.5 pt-0.5"> <SettingsChipGroup
<div className="flex min-w-0 flex-col gap-1.5"> value={ttsInputMode}
<div className="flex items-center gap-1.5"> onChange={setTtsInputMode}
<span className="typography-ui-label text-foreground"> aria-label={t('settings.voice.page.field.ttsInputMode')}
{t('settings.voice.page.field.ttsInputMode')} className="w-full gap-1.5 sm:gap-2"
</span> options={[
</div> { value: 'sanitized', label: t('settings.voice.page.field.ttsInputModeSanitized') },
<div className="flex flex-wrap items-center gap-1"> { value: 'raw', label: t('settings.voice.page.field.ttsInputModeRaw') },
<Button { value: 'summarized', label: t('settings.voice.page.field.ttsInputModeSummarized') },
variant="chip" ]}
size="xs" />
aria-pressed={ttsInputMode === 'sanitized'} </SettingsControlGroup>
onClick={() => setTtsInputMode('sanitized')} </>
className="!font-normal" )}
> </SettingsSection>
{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>
{/* Speech Recognition */} <SettingsSection
<div data-settings-item="voice.speech-recognition" className="mb-8"> settingsItem="voice.speech-recognition"
<div className="mb-1 px-1"> title={t('settings.voice.page.section.speechRecognition')}
<h3 className="typography-ui-header font-medium text-foreground"> contentClassName="space-y-4"
{t('settings.voice.page.section.speechRecognition')} >
</h3> <SettingsCheckboxRow
</div> 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"> {dictationEnabled && (
<div <>
className="group flex cursor-pointer items-center gap-2 py-1.5" <SettingsControlGroup
role="button" title={t('settings.voice.page.field.provider')}
tabIndex={0} info={(
aria-pressed={dictationEnabled} <ul className="space-y-1">
onClick={() => setDictationEnabled(!dictationEnabled)} <li><strong>{t('settings.voice.page.provider.local')}</strong> {t('settings.voice.page.tooltip.sttLocal')}</li>
onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); setDictationEnabled(!dictationEnabled); } }} <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')} /> <SettingsChipGroup
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.enableVoiceInput')}</span> value={sttProvider}
</div> onChange={setSttProvider}
aria-label={t('settings.voice.page.field.provider')}
{dictationEnabled && (<> className="w-full gap-1.5 sm:gap-2"
<div className="pb-1.5 pt-0.5"> options={[
<div className="flex min-w-0 flex-col gap-1.5"> { value: 'local', label: t('settings.voice.page.provider.local') },
<div className="flex items-center gap-1.5"> { value: 'openai-compatible', label: t('settings.voice.page.provider.server') },
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.provider')}</span> ]}
<Tooltip> />
<TooltipTrigger asChild> </SettingsControlGroup>
<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>
{sttProvider === 'local' && ( {sttProvider === 'local' && (
<div className="py-1.5"> <div className="space-y-1.5">
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.model')}</span> <span className={SETTINGS_FIELD_LABEL_CLASS}>{t('settings.voice.page.field.model')}</span>
<LocalModelPicker selectedModelId={sttLocalModel} onSelect={setSttLocalModel} /> <LocalModelPicker selectedModelId={sttLocalModel} onSelect={setSttLocalModel} />
</div> </div>
)} )}
{sttProvider === 'openai-compatible' && ( {sttProvider === 'openai-compatible' && (
<div className="py-1.5 space-y-2"> <div className="space-y-3">
<div> <div className="space-y-1.5">
<span className={cn("typography-ui-label text-foreground", !sttServerUrl.trim() && "text-[var(--status-error)]")}> <span className="flex items-center gap-1.5">
{t('settings.voice.page.field.serverUrl')} <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>
<span className="typography-meta ml-2 text-muted-foreground"> <div className={cn('relative', SETTINGS_CONTROL_CLUSTER_CLASS)}>
{t('settings.voice.page.field.sttServerUrlHint')}
</span>
<div className="relative mt-1.5 max-w-xs">
<input <input
type="text" type="text"
value={sttServerUrl} value={sttServerUrl}
@@ -1246,12 +1159,12 @@ export const VoiceSettings: React.FC = () => {
)} )}
</div> </div>
</div> </div>
<div> <div className="space-y-1.5">
<span className="typography-ui-label text-foreground">API Key</span> <span className={SETTINGS_FIELD_LABEL_CLASS}>API Key</span>
<span className="typography-meta ml-2 text-muted-foreground"> <span className={SETTINGS_HELPER_CLASS}>
Optional Optional
</span> </span>
<div className="relative mt-1.5 max-w-xs"> <div className={cn('relative', SETTINGS_CONTROL_CLUSTER_CLASS)}>
<input <input
type="password" type="password"
value={sttApiKey} value={sttApiKey}
@@ -1270,9 +1183,9 @@ export const VoiceSettings: React.FC = () => {
)} )}
</div> </div>
</div> </div>
<div> <div className="space-y-1.5">
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.model')}</span> <span className={SETTINGS_FIELD_LABEL_CLASS}>{t('settings.voice.page.field.model')}</span>
<div className="relative mt-1.5 max-w-xs"> <div className={cn('relative', SETTINGS_CONTROL_CLUSTER_CLASS)}>
<input <input
type="text" type="text"
value={sttModel} value={sttModel}
@@ -1282,12 +1195,12 @@ export const VoiceSettings: React.FC = () => {
/> />
</div> </div>
</div> </div>
<div> <div className="space-y-1.5">
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.language')}</span> <span className="flex items-center gap-1.5">
<span className="typography-meta ml-2 text-muted-foreground"> <span className={SETTINGS_FIELD_LABEL_CLASS}>{t('settings.voice.page.field.language')}</span>
{t('settings.voice.page.field.sttLanguageHint')} <SettingsInfoHint>{t('settings.voice.page.field.sttLanguageHint')}</SettingsInfoHint>
</span> </span>
<div className="relative mt-1.5 max-w-[8rem]"> <div className="relative max-w-[8rem]">
<input <input
type="text" type="text"
value={sttLanguage} value={sttLanguage}
@@ -1299,12 +1212,9 @@ export const VoiceSettings: React.FC = () => {
</div> </div>
</div> </div>
)} )}
</>
</>)} )}
</section> </SettingsSection>
</div> </>
</div>
); );
}; };
@@ -2,8 +2,8 @@ import React from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { toast } from '@/components/ui'; import { toast } from '@/components/ui';
import { SettingsInfoHint } from '@/components/sections/shared/SettingsInfoHint';
import { Icon } from "@/components/icon/Icon"; import { Icon } from "@/components/icon/Icon";
import type { Session } from '@opencode-ai/sdk/v2'; import type { Session } from '@opencode-ai/sdk/v2';
import { useProjectsStore } from '@/stores/useProjectsStore'; import { useProjectsStore } from '@/stores/useProjectsStore';
@@ -326,29 +326,19 @@ export const WorktreeSectionContent: React.FC<WorktreeSectionContentProps> = ({
}, [sessionsKey, isGitRepoLocal, projectPath, refreshWorktrees]); }, [sessionsKey, isGitRepoLocal, projectPath, refreshWorktrees]);
const setupTooltip = ( const setupTooltip = (
<Tooltip> <SettingsInfoHint>
<TooltipTrigger asChild> {t('settings.openchamber.worktrees.setup.tooltipPrefix')}
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" /> {' '}
</TooltipTrigger> <code className="font-mono text-xs bg-sidebar-accent/50 px-1 rounded">$ROOT_PROJECT_PATH</code>
<TooltipContent sideOffset={8} className="max-w-xs"> {' '}
{t('settings.openchamber.worktrees.setup.tooltipPrefix')} {t('settings.openchamber.worktrees.setup.tooltipSuffix')}
{' '} </SettingsInfoHint>
<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>
); );
const listTooltip = ( const listTooltip = (
<Tooltip> <SettingsInfoHint>
<TooltipTrigger asChild> {t('settings.openchamber.worktrees.list.tooltip')}
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" /> </SettingsInfoHint>
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
{t('settings.openchamber.worktrees.list.tooltip')}
</TooltipContent>
</Tooltip>
); );
if (!projectPath) { if (!projectPath) {
@@ -397,14 +387,16 @@ export const WorktreeSectionContent: React.FC<WorktreeSectionContentProps> = ({
placeholder={t('settings.openchamber.worktrees.setup.commandPlaceholder')} placeholder={t('settings.openchamber.worktrees.setup.commandPlaceholder')}
className="h-7 min-w-0 flex-1 font-mono text-xs" className="h-7 min-w-0 flex-1 font-mono text-xs"
/> />
<button <Button
type="button" type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveCommand(index)} 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')} aria-label={t('settings.openchamber.worktrees.setup.removeCommandAria')}
> >
<Icon name="close" className="h-4 w-4" /> <Icon name="close" className="h-4 w-4" />
</button> </Button>
</div> </div>
))} ))}
<Button <Button
@@ -1,4 +1,5 @@
export type OpenChamberSection = export type OpenChamberSection =
| 'general'
| 'visual' | 'visual'
| 'chat' | 'chat'
| 'shortcuts' | 'shortcuts'
@@ -7,6 +7,7 @@ import { Icon } from '@/components/icon/Icon';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout'; import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
import { SettingsSection } from '@/components/sections/shared/SettingsSection';
import { RegistryBanner } from './RegistryBanner'; import { RegistryBanner } from './RegistryBanner';
import { import {
usePluginsStore, usePluginsStore,
@@ -194,29 +195,28 @@ export const PluginsPage: React.FC = () => {
}; };
return ( return (
<SettingsPageLayout> <SettingsPageLayout
<div> title={t('settings.plugins.page.header.entry')}
<div className="flex items-center gap-2 min-w-0"> titleAccessory={(
<h2 className="typography-ui-header font-semibold text-foreground truncate"> <ScopeBadge
{t('settings.plugins.page.header.entry')} scope={selectedEntry.scope}
</h2> label={
<ScopeBadge selectedEntry.scope === 'project'
scope={selectedEntry.scope} ? t('settings.plugins.sidebar.group.projectEntries')
label={ : t('settings.plugins.sidebar.group.userEntries')
selectedEntry.scope === 'project' }
? t('settings.plugins.sidebar.group.projectEntries') />
: t('settings.plugins.sidebar.group.userEntries') )}
} showSaveStatus={false}
/> >
</div> <SettingsSection divider={false}>
</div> <RegistryBanner entryId={selectedEntry.id} spec={selectedEntry.spec} />
</SettingsSection>
<RegistryBanner entryId={selectedEntry.id} spec={selectedEntry.spec} /> <SettingsSection
title={t('settings.plugins.page.field.spec')}
<div data-settings-item="plugins.spec" className="space-y-1.5"> settingsItem="plugins.spec"
<label className="typography-meta text-muted-foreground"> >
{t('settings.plugins.page.field.spec')}
</label>
<Input <Input
value={draft.spec} value={draft.spec}
onChange={(e) => onChange={(e) =>
@@ -226,12 +226,12 @@ export const PluginsPage: React.FC = () => {
className="font-mono typography-meta" className="font-mono typography-meta"
spellCheck={false} spellCheck={false}
/> />
</div> </SettingsSection>
<div data-settings-item="plugins.options" className="space-y-1.5"> <SettingsSection
<label className="typography-meta text-muted-foreground"> title={t('settings.plugins.page.field.options')}
{t('settings.plugins.page.field.options')} settingsItem="plugins.options"
</label> >
<Textarea <Textarea
value={draft.optionsJson} value={draft.optionsJson}
onChange={(e) => onChange={(e) =>
@@ -250,26 +250,25 @@ export const PluginsPage: React.FC = () => {
{t('settings.plugins.page.field.options.invalidJson')} {t('settings.plugins.page.field.options.invalidJson')}
</p> </p>
)} )}
</div> <div className="flex items-center gap-2 pt-3">
<Button
<div className="flex items-center gap-2"> variant="default"
<Button size="sm"
variant="default" onClick={() => void handleEntrySave()}
size="sm" disabled={!isDirty || !optionsValid || isSaving}
onClick={() => void handleEntrySave()} >
disabled={!isDirty || !optionsValid || isSaving} {t('settings.plugins.page.action.save')}
> </Button>
{t('settings.plugins.page.action.save')} <Button
</Button> variant="outline"
<Button size="sm"
variant="outline" onClick={handleEntryDiscard}
size="sm" disabled={!isDirty || isSaving}
onClick={handleEntryDiscard} >
disabled={!isDirty || isSaving} {t('settings.plugins.page.action.discard')}
> </Button>
{t('settings.plugins.page.action.discard')} </div>
</Button> </SettingsSection>
</div>
</SettingsPageLayout> </SettingsPageLayout>
); );
} }
@@ -312,12 +311,10 @@ export const PluginsPage: React.FC = () => {
}; };
return ( return (
<SettingsPageLayout> <SettingsPageLayout
<div> title={t('settings.plugins.page.header.file')}
<div className="flex items-center gap-2 min-w-0 flex-wrap"> titleAccessory={(
<h2 className="typography-ui-header font-semibold text-foreground truncate"> <>
{t('settings.plugins.page.header.file')}
</h2>
<ScopeBadge <ScopeBadge
scope={selectedFile.scope} scope={selectedFile.scope}
label={ label={
@@ -335,13 +332,15 @@ export const PluginsPage: React.FC = () => {
> >
{selectedFile.fileName} {selectedFile.fileName}
</span> </span>
</div> </>
</div> )}
showSaveStatus={false}
<div data-settings-item="plugins.content" className="space-y-1.5"> >
<label className="typography-meta text-muted-foreground"> <SettingsSection
{t('settings.plugins.page.field.content')} title={t('settings.plugins.page.field.content')}
</label> divider={false}
settingsItem="plugins.content"
>
<Textarea <Textarea
value={draft.content} value={draft.content}
onChange={(e) => onChange={(e) =>
@@ -352,26 +351,25 @@ export const PluginsPage: React.FC = () => {
spellCheck={false} spellCheck={false}
disabled={isLoadingFile} disabled={isLoadingFile}
/> />
</div> <div className="flex items-center gap-2 pt-3">
<Button
<div className="flex items-center gap-2"> variant="default"
<Button size="sm"
variant="default" onClick={() => void handleFileSave()}
size="sm" disabled={!isDirty || isSaving || isLoadingFile}
onClick={() => void handleFileSave()} >
disabled={!isDirty || isSaving || isLoadingFile} {t('settings.plugins.page.action.save')}
> </Button>
{t('settings.plugins.page.action.save')} <Button
</Button> variant="outline"
<Button size="sm"
variant="outline" onClick={handleFileDiscard}
size="sm" disabled={isSaving || isLoadingFile}
onClick={handleFileDiscard} >
disabled={isSaving || isLoadingFile} {t('settings.plugins.page.action.discard')}
> </Button>
{t('settings.plugins.page.action.discard')} </div>
</Button> </SettingsSection>
</div>
</SettingsPageLayout> </SettingsPageLayout>
); );
} }
@@ -22,6 +22,7 @@ import {
type PluginEntry, type PluginEntry,
type PluginFile, type PluginFile,
} from '@/stores/usePluginsStore'; } from '@/stores/usePluginsStore';
import { SETTINGS_PANEL_TITLE_CLASS } from '@/components/sections/shared/SettingsSection';
interface PluginsSidebarProps { interface PluginsSidebarProps {
onItemSelect?: () => void; onItemSelect?: () => void;
@@ -277,7 +278,7 @@ export const PluginsSidebar: React.FC<PluginsSidebarProps> = ({
header={ header={
<div className="border-b px-3 pt-4 pb-3"> <div className="border-b px-3 pt-4 pb-3">
<div className="mb-3 flex items-center justify-between gap-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')} {t('settings.plugins.sidebar.title')}
</h2> </h2>
</div> </div>
@@ -20,7 +20,6 @@ import {
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { toast } from '@/components/ui'; import { toast } from '@/components/ui';
import { Icon } from "@/components/icon/Icon"; import { Icon } from "@/components/icon/Icon";
import { useDesktopSshStore } from '@/stores/useDesktopSshStore'; import { useDesktopSshStore } from '@/stores/useDesktopSshStore';
@@ -41,6 +40,8 @@ import {
PROJECT_SETTINGS_CONTROL_WIDTH, PROJECT_SETTINGS_CONTROL_WIDTH,
ProjectSettingsSubsection, ProjectSettingsSubsection,
} from '@/components/sections/projects/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 { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -228,7 +229,7 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
return ( return (
<ProjectSettingsSubsection <ProjectSettingsSubsection
title={t('settings.projects.actions.title')} title={t('settings.projects.actions.title')}
description={t('settings.projects.actions.description')} info={t('settings.projects.actions.description')}
settingsItem="projects.actions" settingsItem="projects.actions"
headerAction={( headerAction={(
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={handleAddAction}> <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')} placeholder={t('settings.projects.actions.field.overrideUrlPlaceholder')}
className="h-7 w-full max-w-[24rem]" className="h-7 w-full max-w-[24rem]"
/> />
<Tooltip> <SettingsInfoHint contentClassName="max-w-xs">
<TooltipTrigger asChild> {t('settings.projects.actions.field.overrideUrlTooltip')}
<Icon name="information" className="h-3.5 w-3.5 shrink-0 text-muted-foreground/60 cursor-help" /> </SettingsInfoHint>
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
{t('settings.projects.actions.field.overrideUrlTooltip')}
</TooltipContent>
</Tooltip>
</div> </div>
{isDesktopShellApp ? ( {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')} /> <SelectValue placeholder={t('settings.projects.actions.field.useOutputManualUrl')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -64,25 +64,26 @@ export const ProjectIdentityFields: React.FC<ProjectIdentityFieldsProps> = ({ fo
<ProjectSettingsSubsection <ProjectSettingsSubsection
title={t('settings.projects.page.field.projectName')} title={t('settings.projects.page.field.projectName')}
settingsItem="projects.name" settingsItem="projects.name"
divider={false}
> >
<Input <Input
value={name} value={name}
onChange={(event) => setName(event.target.value)} onChange={(event) => setName(event.target.value)}
placeholder={t('settings.projects.page.field.projectNamePlaceholder')} 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>
<ProjectSettingsSubsection <ProjectSettingsSubsection
title={t('settings.projects.page.field.defaultModel')} 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" settingsItem="projects.default-model"
> >
<ModelSelector <ModelSelector
providerId={parsedDefaultModel.providerId} providerId={parsedDefaultModel.providerId}
modelId={parsedDefaultModel.modelId} modelId={parsedDefaultModel.modelId}
onChange={handleDefaultModelChange} 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> </ProjectSettingsSubsection>
@@ -125,6 +126,7 @@ export const ProjectIdentityFields: React.FC<ProjectIdentityFieldsProps> = ({ fo
<ProjectSettingsSubsection <ProjectSettingsSubsection
title={t('settings.projects.page.field.projectIcon')} title={t('settings.projects.page.field.projectIcon')}
settingsItem="projects.icon" settingsItem="projects.icon"
contentClassName="space-y-3"
> >
<input <input
ref={fileInputRef} ref={fileInputRef}
@@ -176,7 +178,7 @@ export const ProjectIdentityFields: React.FC<ProjectIdentityFieldsProps> = ({ fo
})} })}
</div> </div>
{effectiveHasImageIcon && showImagePreview && ( {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="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 className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-border/60 bg-[var(--surface-elevated)] p-1">
<span <span
@@ -8,7 +8,6 @@ import {
} from '@/components/sections/projects/useProjectIdentityForm'; } from '@/components/sections/projects/useProjectIdentityForm';
import { useProjectIdentityAutoSave } from '@/components/sections/projects/useProjectIdentityAutoSave'; import { useProjectIdentityAutoSave } from '@/components/sections/projects/useProjectIdentityAutoSave';
import type { ProjectEntry } from '@/lib/api/types'; import type { ProjectEntry } from '@/lib/api/types';
import { useI18n } from '@/lib/i18n';
type ProjectSettingsPanelProps = { type ProjectSettingsPanelProps = {
project: ProjectEntry | null; project: ProjectEntry | null;
@@ -19,7 +18,6 @@ export const ProjectSettingsPanel: React.FC<ProjectSettingsPanelProps> = ({
project, project,
onIdentitySave, onIdentitySave,
}) => { }) => {
const { t } = useI18n();
const form = useProjectIdentityForm(project); const form = useProjectIdentityForm(project);
const projectRef = React.useMemo(() => { const projectRef = React.useMemo(() => {
@@ -39,19 +37,8 @@ export const ProjectSettingsPanel: React.FC<ProjectSettingsPanelProps> = ({
return null; return null;
} }
const headerLabel = project.label ?? t('settings.projects.page.title.default');
return ( return (
<div className="space-y-0"> <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} /> <ProjectIdentityFields form={form} />
<ProjectActionsSection projectRef={projectRef} /> <ProjectActionsSection projectRef={projectRef} />
<WorktreeSectionContent projectRef={projectRef} /> <WorktreeSectionContent projectRef={projectRef} />
@@ -1,47 +1,48 @@
import React from 'react'; 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 = { type ProjectSettingsSubsectionProps = {
title: string; title: string;
description?: string; description?: string;
/** Helper text hidden behind an info icon next to the title. */
info?: React.ReactNode;
settingsItem?: string; settingsItem?: string;
titleAccessory?: React.ReactNode; titleAccessory?: React.ReactNode;
headerAction?: React.ReactNode; headerAction?: React.ReactNode;
children: React.ReactNode; children: React.ReactNode;
className?: string; className?: string;
contentClassName?: string; contentClassName?: string;
/** Use false for the first subsection under a page header. @default true */
divider?: boolean;
}; };
export const ProjectSettingsSubsection: React.FC<ProjectSettingsSubsectionProps> = ({ export const ProjectSettingsSubsection: React.FC<ProjectSettingsSubsectionProps> = ({
title, title,
description, description,
info,
settingsItem, settingsItem,
titleAccessory, titleAccessory,
headerAction, headerAction,
children, children,
className, className,
contentClassName, contentClassName,
divider = true,
}) => { }) => {
return ( return (
<section <SettingsSection
data-settings-item={settingsItem} title={title}
className={cn('border-b border-border/50 py-5 first:pt-0 last:border-b-0', className)} 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"> {children}
<div className="min-w-0 flex-1"> </SettingsSection>
<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>
); );
}; };
@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
import { useProjectsStore } from '@/stores/useProjectsStore'; import { useProjectsStore } from '@/stores/useProjectsStore';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { ProjectSettingsPanel } from '@/components/sections/projects/ProjectSettingsPanel'; import { ProjectSettingsPanel } from '@/components/sections/projects/ProjectSettingsPanel';
@@ -42,19 +42,22 @@ export const ProjectsPage: React.FC = () => {
if (!selectedProject) { if (!selectedProject) {
return ( return (
<ScrollableOverlay outerClassName="h-full" className="w-full"> <SettingsPageLayout title={t('settings.page.projects.title')} showSaveStatus>
<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>
<p className="typography-meta text-muted-foreground">{t('settings.projects.page.empty.noProjects')}</p> </SettingsPageLayout>
</div>
</ScrollableOverlay>
); );
} }
const headerLabel = selectedProject.label ?? t('settings.projects.page.title.default');
return ( return (
<ScrollableOverlay outerClassName="h-full" className="w-full bg-background"> <SettingsPageLayout
<div className="mx-auto w-full max-w-4xl p-3 sm:p-6 sm:pt-8"> title={headerLabel}
<ProjectSettingsPanel project={selectedProject} onIdentitySave={handleIdentitySave} /> description={selectedProject.path}
</div> showSaveStatus
</ScrollableOverlay> outerClassName="bg-background"
>
<ProjectSettingsPanel project={selectedProject} onIdentitySave={handleIdentitySave} />
</SettingsPageLayout>
); );
}; };
@@ -11,6 +11,7 @@ import { isVSCodeRuntime } from '@/lib/desktop';
import { sessionEvents } from '@/lib/sessionEvents'; import { sessionEvents } from '@/lib/sessionEvents';
import { useThemeSystem } from '@/contexts/useThemeSystem'; import { useThemeSystem } from '@/contexts/useThemeSystem';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { SETTINGS_PANEL_TITLE_CLASS } from '@/components/sections/shared/SettingsSection';
export const ProjectsSidebar: React.FC<{ onItemSelect?: () => void }> = ({ onItemSelect }) => { export const ProjectsSidebar: React.FC<{ onItemSelect?: () => void }> = ({ onItemSelect }) => {
const { t } = useI18n(); const { t } = useI18n();
@@ -43,7 +44,7 @@ export const ProjectsSidebar: React.FC<{ onItemSelect?: () => void }> = ({ onIte
variant="background" variant="background"
header={ header={
<div className={cn('border-b px-3', 'pt-4 pb-3')}> <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"> <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> <span className="typography-meta text-muted-foreground">{t('settings.projects.sidebar.total', { count: projects.length })}</span>
{!isVSCode && ( {!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 React from 'react';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; 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 { ProviderLogo } from '@/components/ui/ProviderLogo';
import { useConfigStore } from '@/stores/useConfigStore'; import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
@@ -12,7 +15,6 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { toast } from '@/components/ui'; import { toast } from '@/components/ui';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Icon } from "@/components/icon/Icon"; import { Icon } from "@/components/icon/Icon";
import type { IconName } from "@/components/icon/icons"; import type { IconName } from "@/components/icon/icons";
import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore'; import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
@@ -24,7 +26,6 @@ import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch'; import { runtimeFetch } from '@/lib/runtime-fetch';
import { opencodeClient } from '@/lib/opencode/client'; import { opencodeClient } from '@/lib/opencode/client';
import { shouldLoadAvailableProviders } from './providerAvailability'; import { shouldLoadAvailableProviders } from './providerAvailability';
import { QuotaCredentials } from './QuotaCredentials';
const formatCompactNumber = (value: number) => new Intl.NumberFormat(getCurrentIntlLocale(), { const formatCompactNumber = (value: number) => new Intl.NumberFormat(getCurrentIntlLocale(), {
notation: 'compact', notation: 'compact',
@@ -512,18 +513,15 @@ export const ProvidersPage: React.FC = () => {
if (isAddMode) { if (isAddMode) {
return ( return (
<ScrollableOverlay outerClassName="h-full" className="w-full"> <SettingsPageLayout
<div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8"> title={t('settings.providers.page.connect.title')}
<div data-settings-item="providers.connect" className="mb-4"> showSaveStatus={false}
<h1 className="typography-ui-header font-semibold text-foreground">{t('settings.providers.page.connect.title')}</h1> >
</div> <SettingsSection
title={t('settings.providers.page.connect.selectProviderTitle')}
<div className="mb-8"> divider={false}
<div className="mb-1 px-1"> settingsItem="providers.connect"
<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">
<div className="flex flex-wrap items-center gap-2 py-1.5"> <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> <span className="typography-ui-label text-foreground">{t('settings.providers.page.connect.providerField')}</span>
{availableLoading ? ( {availableLoading ? (
@@ -540,9 +538,7 @@ export const ProvidersPage: React.FC = () => {
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <button
type="button" type="button"
className={cn( className={SETTINGS_CUSTOM_TRIGGER_CLASS}
"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",
)}
> >
<span className="flex items-center gap-2 min-w-0"> <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} {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> </DropdownMenu>
)} )}
</div> </div>
</section> </SettingsSection>
</div>
{candidateProviderId && ( {candidateProviderId && (
<div data-settings-item="providers.auth" className="mb-8"> <SettingsSection
<div className="mb-1 px-1"> title={t('settings.providers.page.auth.title')}
<h2 className="typography-ui-header font-medium text-foreground">{t('settings.providers.page.auth.title')}</h2> settingsItem="providers.auth"
</div> contentClassName="space-y-4"
>
{authLoading ? ( {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"> <div className="py-1.5">
<label className="typography-ui-label text-foreground flex items-center gap-1.5"> <label className="typography-ui-label text-foreground flex items-center gap-1.5">
{t('settings.providers.page.auth.apiKeyLabel')} {t('settings.providers.page.auth.apiKeyLabel')}
<Tooltip> <SettingsInfoHint>{t('settings.providers.page.auth.apiKeyTooltip')}</SettingsInfoHint>
<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>
</label> </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 <Input
type="password" type="password"
value={apiKeyInputs[candidateProviderId] ?? ''} value={apiKeyInputs[candidateProviderId] ?? ''}
@@ -750,12 +738,11 @@ export const ProvidersPage: React.FC = () => {
</div> </div>
); );
})()} })()}
</section> </>
)} )}
</div> </SettingsSection>
)} )}
</div> </SettingsPageLayout>
</ScrollableOverlay>
); );
} }
@@ -784,42 +771,32 @@ export const ProvidersPage: React.FC = () => {
}); });
return ( return (
<ScrollableOverlay outerClassName="h-full" className="w-full"> <SettingsPageLayout
<div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8"> title={selectedProvider.name || selectedProvider.id}
titleLeading={<ProviderLogo providerId={selectedProvider.id} className="h-5 w-5 shrink-0" />}
{/* Header */} description={<span className="font-mono typography-settings-description text-muted-foreground">{selectedProvider.id}</span>}
<div className="mb-4 flex items-center gap-3"> showSaveStatus={false}
<ProviderLogo providerId={selectedProvider.id} className="h-5 w-5 shrink-0" /> >
<div className="min-w-0"> <SettingsSection
<h2 className="typography-ui-header font-semibold text-foreground truncate"> title={t('settings.providers.page.auth.title')}
{selectedProvider.name || selectedProvider.id} divider={false}
</h2> headerAction={(
<p className="typography-meta text-muted-foreground truncate"> <Button
<span className="font-mono">{selectedProvider.id}</span> variant="outline"
</p> size="xs"
</div> className="!font-normal"
</div> onClick={() => setShowAuthPanel((prev) => !prev)}
>
{/* Authentication */} {showAuthPanel ? t('settings.providers.page.actions.hide') : t('settings.providers.page.actions.reconnect')}
<div data-settings-item="providers.auth" className="mb-8"> </Button>
<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> settingsItem="providers.auth"
<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">
{!showAuthPanel ? ( {!showAuthPanel ? (
<div className="flex items-center gap-1.5 py-1.5"> <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" /> <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-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> </div>
) : authLoading ? ( ) : authLoading ? (
<div className="py-1.5 typography-meta text-muted-foreground">{t('settings.providers.page.auth.loadingMethods')}</div> <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"> <div className="py-1.5">
<label className="typography-ui-label text-foreground flex items-center gap-1.5"> <label className="typography-ui-label text-foreground flex items-center gap-1.5">
{t('settings.providers.page.auth.apiKeyLabel')} {t('settings.providers.page.auth.apiKeyLabel')}
<Tooltip> <SettingsInfoHint>{t('settings.providers.page.auth.apiKeyTooltip')}</SettingsInfoHint>
<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>
</label> </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 <Input
type="password" type="password"
value={apiKeyInputs[selectedProvider.id] ?? ''} value={apiKeyInputs[selectedProvider.id] ?? ''}
@@ -944,21 +914,14 @@ export const ProvidersPage: React.FC = () => {
)} )}
</div> </div>
)} )}
</section> </SettingsSection>
</div>
{(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 */} <SettingsSection
<div data-settings-item="providers.connection-details" className="mb-8"> title={t('settings.providers.page.connectionDetails.title')}
<div className="mb-1 px-1"> settingsItem="providers.connection-details"
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.providers.page.connectionDetails.title')}</h3> >
</div> <div className="flex flex-col gap-2 py-1.5 @xl:flex-row @xl:items-center @xl:justify-between @xl:gap-8">
<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">
<div className="flex min-w-0 flex-col"> <div className="flex min-w-0 flex-col">
{selectedSources && (selectedSources.auth.exists || selectedSources.user.exists || selectedSources.project.exists || selectedSources.custom?.exists) ? ( {selectedSources && (selectedSources.auth.exists || selectedSources.user.exists || selectedSources.project.exists || selectedSources.custom?.exists) ? (
<span className="typography-meta text-muted-foreground"> <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')} {authBusyKey === `disconnect:${selectedProvider.id}` ? t('settings.providers.page.actions.disconnecting') : t('settings.providers.page.actions.disconnect')}
</Button> </Button>
</div> </div>
</section> </SettingsSection>
</div>
{/* Models */} <SettingsSection
<div data-settings-item="providers.models" className="mb-8"> title={t('settings.providers.page.models.title')}
<div className="mb-1 px-1 flex items-center justify-between gap-2"> titleAccessory={
<h3 className="typography-ui-header font-medium text-foreground"> providerModels.length > 0 ? (
{t('settings.providers.page.models.title')} <span className="typography-micro text-muted-foreground font-normal">
{providerModels.length > 0 && ( ({providerModels.length})
<span className="ml-1.5 typography-micro text-muted-foreground font-normal"> </span>
({providerModels.length}) ) : null
</span> }
)} headerAction={(
</h3> <div className="flex items-center gap-1">
<div className="flex items-center gap-1"> <Button
<Button variant="outline"
variant="outline" size="xs"
size="xs" className="!font-normal"
className="!font-normal" onClick={() => {
onClick={() => { const allIds = providerModels
const allIds = providerModels .map((model) => (typeof model?.id === 'string' ? model.id : ''))
.map((model) => (typeof model?.id === 'string' ? model.id : '')) .filter((id) => id.length > 0);
.filter((id) => id.length > 0); hideAllModels(selectedProvider.id, allIds);
hideAllModels(selectedProvider.id, allIds); }}
}} >
> {t('settings.providers.page.actions.hideAll')}
{t('settings.providers.page.actions.hideAll')} </Button>
</Button> <Button
<Button variant="outline"
variant="outline" size="xs"
size="xs" className="!font-normal"
className="!font-normal" onClick={() => showAllModels(selectedProvider.id)}
onClick={() => showAllModels(selectedProvider.id)} >
> {t('settings.providers.page.actions.showAll')}
{t('settings.providers.page.actions.showAll')} </Button>
</Button>
</div>
</div> </div>
)}
<section className="px-2 pb-2 pt-0"> settingsItem="providers.models"
>
<div className="relative mb-2"> <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" /> <Icon name="search" className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input <Input
@@ -1104,9 +1065,7 @@ export const ProvidersPage: React.FC = () => {
})} })}
</div> </div>
)} )}
</section> </SettingsSection>
</div> </SettingsPageLayout>
</div>
</ScrollableOverlay>
); );
}; };
@@ -10,6 +10,7 @@ import { Icon } from "@/components/icon/Icon";
import { opencodeClient } from '@/lib/opencode/client'; import { opencodeClient } from '@/lib/opencode/client';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch'; import { runtimeFetch } from '@/lib/runtime-fetch';
import { SETTINGS_PANEL_TITLE_CLASS } from '@/components/sections/shared/SettingsSection';
const ADD_PROVIDER_ID = '__add_provider__'; const ADD_PROVIDER_ID = '__add_provider__';
@@ -111,7 +112,7 @@ export const ProvidersSidebar: React.FC<ProvidersSidebarProps> = ({ onItemSelect
return ( return (
<div className={cn('flex h-full flex-col', bgClass)}> <div className={cn('flex h-full flex-col', bgClass)}>
<div className="border-b px-3 pt-4 pb-3"> <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" /> <SettingsProjectSelector className="mb-3" />
<div className="flex items-center justify-between gap-2"> <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> <span className="typography-meta text-muted-foreground">{t('settings.providers.sidebar.total', { count: providers.length })}</span>
@@ -18,9 +18,16 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout'; 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 { useDesktopSshStore } from '@/stores/useDesktopSshStore';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { toast } from '@/components/ui'; import { toast } from '@/components/ui';
@@ -28,6 +35,7 @@ import { Checkbox } from '@/components/ui/checkbox';
import { Radio } from '@/components/ui/radio'; import { Radio } from '@/components/ui/radio';
import { Icon } from "@/components/icon/Icon"; import { Icon } from "@/components/icon/Icon";
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { formatDateTimeForPreference } from '@/lib/timeFormat';
import { copyTextToClipboard } from '@/lib/clipboard'; import { copyTextToClipboard } from '@/lib/clipboard';
import { openExternalUrl } from '@/lib/url'; import { openExternalUrl } from '@/lib/url';
import { useI18n, type I18nKey } from '@/lib/i18n'; 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 }) => { const HintLabel: React.FC<{ label: string; hint: React.ReactNode }> = ({ label, hint }) => {
return ( 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> <span>{label}</span>
<Tooltip> <SettingsInfoHint contentClassName="max-w-xs">{hint}</SettingsInfoHint>
<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>
</span> </span>
); );
}; };
@@ -380,6 +381,7 @@ const normalizeForSave = (instance: DesktopSshInstance): DesktopSshInstance => {
export const RemoteInstancesPage: React.FC = () => { export const RemoteInstancesPage: React.FC = () => {
const { t } = useI18n(); const { t } = useI18n();
const timeFormatPreference = useUIStore((state) => state.timeFormatPreference);
const { clientAuth } = useRuntimeAPIs(); const { clientAuth } = useRuntimeAPIs();
const showInstanceManagement = isDesktopShell(); const showInstanceManagement = isDesktopShell();
const instances = useDesktopSshStore((state) => state.instances); const instances = useDesktopSshStore((state) => state.instances);
@@ -1384,21 +1386,22 @@ export const RemoteInstancesPage: React.FC = () => {
if (!draft) { if (!draft) {
return ( return (
<SettingsPageLayout> <SettingsPageLayout title={t('settings.page.remoteInstances.title')}>
{clientAuth ? ( {clientAuth ? (
<div data-settings-item="remote-instances.client-auth" className="mb-8"> <SettingsSection
<div className="mb-1 px-1 space-y-0.5"> title={t('settings.remoteInstances.clientAuth.title')}
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.clientAuth.title')}</h3> info={t('settings.remoteInstances.clientAuth.description')}
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.clientAuth.description')}</p> divider={false}
</div> settingsItem="remote-instances.client-auth"
<section className="px-2 pb-2 pt-0 space-y-3"> contentClassName="space-y-3"
>
<div> <div>
<Button type="button" size="xs" className="!font-normal" onClick={() => void openAddDevice()}> <Button type="button" size="xs" className="!font-normal" onClick={() => void openAddDevice()}>
<Icon name="add" className="h-3.5 w-3.5" /> <Icon name="add" className="h-3.5 w-3.5" />
{t('settings.remoteInstances.clientAuth.actions.addDevice')} {t('settings.remoteInstances.clientAuth.actions.addDevice')}
</Button> </Button>
</div> </div>
<div className="space-y-1"> <div className="space-y-2.5">
{revokedClientCount > 0 ? ( {revokedClientCount > 0 ? (
<div className="flex justify-end"> <div className="flex justify-end">
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => void purgeRevokedRemoteClients()}> <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) => ( {pendingPairings.map((pending) => (
<div key={`pending-${pending.id}`} className="flex items-center justify-between gap-3 py-1.5"> <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"> <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" /> <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> <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 ? (client.lastTransport === 'relay' && !isLocalDesktopClient
? t('settings.remoteInstances.clientAuth.state.connectedRelay') ? t('settings.remoteInstances.clientAuth.state.connectedRelay')
: t('settings.remoteInstances.clientAuth.state.connectedDirect')) : t('settings.remoteInstances.clientAuth.state.connectedDirect'))
: client.lastUsedAt : Number.isFinite(lastUsedMs)
? t('settings.remoteInstances.clientAuth.lastUsed', { date: client.lastUsedAt }) ? t('settings.remoteInstances.clientAuth.lastUsed', {
date: formatDateTimeForPreference(lastUsedMs, timeFormatPreference, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
}),
})
: t('settings.remoteInstances.clientAuth.neverUsed'); : t('settings.remoteInstances.clientAuth.neverUsed');
return ( return (
<div key={client.id} className="flex items-center justify-between gap-3 py-1.5"> <div key={client.id} className="flex items-center justify-between gap-3 py-1.5">
<div className="min-w-0"> <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( <span className={cn(
'h-2 w-2 shrink-0 rounded-full', 'h-2 w-2 shrink-0 rounded-full',
client.revokedAt ? 'bg-muted-foreground/20' : isOnline ? 'bg-[var(--status-success)]' : 'bg-muted-foreground/30', 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')} {t('settings.remoteInstances.clientAuth.state.thisDevice')}
</span> </span>
) : null} ) : null}
<span className={cn('typography-micro truncate', isOnline && !client.revokedAt ? 'text-[var(--status-success)]' : 'text-muted-foreground')}>{statusText}</span>
</div> </div>
<p className={cn('typography-micro truncate', isOnline && !client.revokedAt ? 'text-[var(--status-success)]' : 'text-muted-foreground')}>{statusText}</p>
</div> </div>
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => void revokeRemoteClient(client)} disabled={Boolean(client.revokedAt)}> <Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => void revokeRemoteClient(client)} disabled={Boolean(client.revokedAt)}>
{t('settings.remoteInstances.clientAuth.actions.revoke')} {t('settings.remoteInstances.clientAuth.actions.revoke')}
@@ -1479,20 +1489,19 @@ export const RemoteInstancesPage: React.FC = () => {
)} )}
</div> </div>
{remoteClientError ? <p className="typography-meta text-[var(--status-error)]">{remoteClientError}</p> : null} {remoteClientError ? <p className="typography-meta text-[var(--status-error)]">{remoteClientError}</p> : null}
</section> </SettingsSection>
</div>
) : null} ) : null}
{showInstanceManagement ? <div data-settings-item="remote-instances.direct-hosts" className="mb-8 border-t border-[var(--surface-subtle)] pt-8"> {showInstanceManagement ? <SettingsSection
<div className="mb-1 flex items-start justify-between gap-3 px-1"> title={t('settings.remoteInstances.direct.title')}
<div className="min-w-0 space-y-0.5"> info={t('settings.remoteInstances.direct.description')}
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.direct.title')}</h3> settingsItem="remote-instances.direct-hosts"
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.direct.description')}</p> contentClassName="space-y-4"
</div> headerAction={(
{/* Importing a pairing link is the flagship path; add-by-address is /* Importing a pairing link is the flagship path; add-by-address is
the manual fallback. The token-storage note lives in the add the manual fallback. The token-storage note lives in the add
dialog next to the token field it describes. */} dialog next to the token field it describes. */
<div className="flex shrink-0 items-center gap-2 pt-0.5"> <div className="flex shrink-0 items-center gap-2">
<Button type="button" size="xs" className="!font-normal" onClick={() => setDirectImportDialogOpen(true)} disabled={directSaving}> <Button type="button" size="xs" className="!font-normal" onClick={() => setDirectImportDialogOpen(true)} disabled={directSaving}>
{t('settings.remoteInstances.direct.import.action')} {t('settings.remoteInstances.direct.import.action')}
</Button> </Button>
@@ -1501,9 +1510,9 @@ export const RemoteInstancesPage: React.FC = () => {
{t('settings.remoteInstances.direct.actions.add')} {t('settings.remoteInstances.direct.actions.add')}
</Button> </Button>
</div> </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 ? ( {directLoading ? (
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.direct.state.loading')}</p> <p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.direct.state.loading')}</p>
) : directHosts.length === 0 ? ( ) : directHosts.length === 0 ? (
@@ -1527,7 +1536,7 @@ export const RemoteInstancesPage: React.FC = () => {
return ( return (
<div key={host.id} className="py-1.5"> <div key={host.id} className="py-1.5">
<div className="flex items-center justify-between gap-3"> <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"> <div className="flex min-w-0 items-center gap-2">
<span className={cn( <span className={cn(
'h-2 w-2 shrink-0 rounded-full', 'h-2 w-2 shrink-0 rounded-full',
@@ -1572,8 +1581,7 @@ export const RemoteInstancesPage: React.FC = () => {
</div> </div>
{directError ? <p className="typography-meta text-[var(--status-error)]">{directError}</p> : null} {directError ? <p className="typography-meta text-[var(--status-error)]">{directError}</p> : null}
</section> </SettingsSection> : null}
</div> : null}
{showInstanceManagement ? <Dialog open={directAddDialogOpen} onOpenChange={setDirectAddDialogOpen}> {showInstanceManagement ? <Dialog open={directAddDialogOpen} onOpenChange={setDirectAddDialogOpen}>
<DialogContent className="sm:max-w-lg"> <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> <p className="px-1 typography-micro text-muted-foreground">{t('settings.remoteInstances.direct.note')}</p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<div> <div className="flex items-center gap-1.5">
<p className="typography-ui-label text-foreground">{t('settings.remoteInstances.direct.headers.title')}</p> <SettingsGroupTitle>{t('settings.remoteInstances.direct.headers.title')}</SettingsGroupTitle>
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.direct.headers.description')}</p> <SettingsInfoHint>{t('settings.remoteInstances.direct.headers.description')}</SettingsInfoHint>
</div> </div>
{directHeaders.map((header) => ( {directHeaders.map((header) => (
<div key={header.id} className="flex w-full gap-2"> <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={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} /> <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 className="space-y-2">
<div> <div className="flex items-center gap-1.5">
<p className="typography-ui-label text-foreground">{t('settings.remoteInstances.direct.headers.title')}</p> <SettingsGroupTitle>{t('settings.remoteInstances.direct.headers.title')}</SettingsGroupTitle>
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.direct.headers.description')}</p> <SettingsInfoHint>{t('settings.remoteInstances.direct.headers.description')}</SettingsInfoHint>
</div> </div>
{directEditHeaders.map((header) => ( {directEditHeaders.map((header) => (
<div key={header.id} className="flex w-full gap-2"> <div key={header.id} className="flex w-full gap-2">
@@ -1762,20 +1770,17 @@ export const RemoteInstancesPage: React.FC = () => {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{showInstanceManagement ? <div className="mb-8 border-t border-[var(--surface-subtle)] pt-8"> {showInstanceManagement ? <SettingsSection
<div className="mb-1 px-1 space-y-0.5"> title={t('settings.remoteInstances.sidebar.title')}
<div className="flex items-center justify-between gap-3"> description={t('settings.remoteInstances.sidebar.total', { count: instances.length })}
<div className="min-w-0"> headerAction={(
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.sidebar.title')}</h3> <Button type="button" size="xs" className="!font-normal" onClick={() => setSshAddDialogOpen(true)}>
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.sidebar.total', { count: instances.length })}</p> <Icon name="add" className="h-3.5 w-3.5" />
</div> {t('settings.remoteInstances.sidebar.actions.addSshInstance')}
<Button type="button" size="xs" className="!font-normal" onClick={() => setSshAddDialogOpen(true)}> </Button>
<Icon name="add" className="h-3.5 w-3.5" /> )}
{t('settings.remoteInstances.sidebar.actions.addSshInstance')} contentClassName="space-y-2.5"
</Button> >
</div>
</div>
<section className="px-2 pb-2 pt-0 space-y-1">
{isLoading ? ( {isLoading ? (
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.import.loading')}</p> <p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.import.loading')}</p>
) : instances.length === 0 ? ( ) : instances.length === 0 ? (
@@ -1787,7 +1792,7 @@ export const RemoteInstancesPage: React.FC = () => {
const ready = phase === 'ready'; const ready = phase === 'ready';
return ( return (
<div key={instance.id} className="flex items-center justify-between gap-3 py-1.5"> <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"> <div className="flex min-w-0 items-center gap-2">
<span className={`h-2 w-2 rounded-full ${phaseDotClass(phase)}`} /> <span className={`h-2 w-2 rounded-full ${phaseDotClass(phase)}`} />
<p className="typography-ui-label text-foreground truncate">{title}</p> <p className="typography-ui-label text-foreground truncate">{title}</p>
@@ -1824,8 +1829,7 @@ export const RemoteInstancesPage: React.FC = () => {
</div> </div>
); );
})} })}
</section> </SettingsSection> : null}
</div> : null}
{showInstanceManagement ? <Dialog open={sshAddDialogOpen} onOpenChange={setSshAddDialogOpen}> {showInstanceManagement ? <Dialog open={sshAddDialogOpen} onOpenChange={setSshAddDialogOpen}>
<DialogContent className="sm:max-w-lg"> <DialogContent className="sm:max-w-lg">
@@ -1844,11 +1848,9 @@ export const RemoteInstancesPage: React.FC = () => {
</DialogContent> </DialogContent>
</Dialog> : null} </Dialog> : null}
{showInstanceManagement ? <div className="mb-8 border-t border-[var(--surface-subtle)] pt-8"> {showInstanceManagement ? <SettingsSection
<div className="mb-1 px-1 space-y-0.5"> title={t('settings.remoteInstances.page.import.sectionTitle')}
<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">
{isImportsLoading ? ( {isImportsLoading ? (
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.import.loading')}</p> <p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.import.loading')}</p>
) : importCandidates.length === 0 ? ( ) : importCandidates.length === 0 ? (
@@ -1877,8 +1879,7 @@ export const RemoteInstancesPage: React.FC = () => {
))} ))}
</div> </div>
)} )}
</section> </SettingsSection> : null}
</div> : null}
<Dialog <Dialog
open={Boolean(patternHost)} open={Boolean(patternHost)}
@@ -1930,7 +1931,7 @@ export const RemoteInstancesPage: React.FC = () => {
<Dialog open={Boolean(draft)} onOpenChange={(open) => { if (!open) setSelectedId(null); }}> <Dialog open={Boolean(draft)} onOpenChange={(open) => { if (!open) setSelectedId(null); }}>
<DialogContent className="sm:max-w-4xl max-h-[90vh] overflow-auto"> <DialogContent className="sm:max-w-4xl max-h-[90vh] overflow-auto">
<div className="mb-6 px-1"> <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"> <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 className={`h-2.5 w-2.5 rounded-full ${phaseDotClass(statusPhase)}`} />
<span>{t(phaseLabelKey(statusPhase))}</span> <span>{t(phaseLabelKey(statusPhase))}</span>
@@ -1939,12 +1940,12 @@ export const RemoteInstancesPage: React.FC = () => {
</div> </div>
</div> </div>
<div className="mb-8"> <SettingsSection
<div className="mb-1 px-1 space-y-0.5"> title={t('settings.remoteInstances.page.section.actions')}
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.section.actions')}</h3> info={t('settings.remoteInstances.page.section.actionsDescription')}
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.section.actionsDescription')}</p> divider={false}
</div> contentClassName="space-y-3"
<section className="px-2 pb-2 pt-0 space-y-3"> >
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Button <Button
type="button" type="button"
@@ -2010,15 +2011,13 @@ export const RemoteInstancesPage: React.FC = () => {
<span className="font-mono text-foreground/90">{status.localUrl}</span> <span className="font-mono text-foreground/90">{status.localUrl}</span>
</div> </div>
) : null} ) : null}
</section> </SettingsSection>
</div>
<div className="mb-8"> <SettingsSection
<div className="mb-1 px-1 space-y-0.5"> title={t('settings.remoteInstances.page.section.instance')}
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.section.instance')}</h3> info={t('settings.remoteInstances.page.section.instanceDescription')}
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.section.instanceDescription')}</p> contentClassName="space-y-3"
</div> >
<section className="px-2 pb-2 pt-0 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="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> <span className="typography-ui-label text-foreground w-56 shrink-0">{t('settings.remoteInstances.page.field.sshCommand')}</span>
<Input <Input
@@ -2064,15 +2063,13 @@ export const RemoteInstancesPage: React.FC = () => {
}} }}
/> />
</div> </div>
</section> </SettingsSection>
</div>
<div className="mb-8 border-t border-[var(--surface-subtle)] pt-8"> <SettingsSection
<div className="mb-1 px-1 space-y-0.5"> title={t('settings.remoteInstances.page.section.remoteServer')}
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.section.remoteServer')}</h3> info={t('settings.remoteInstances.page.section.remoteServerDescription')}
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.section.remoteServerDescription')}</p> contentClassName="space-y-3"
</div> >
<section className="px-2 pb-2 pt-0 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="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
<div className="w-56 shrink-0"> <div className="w-56 shrink-0">
<HintLabel <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')} /> <SelectValue placeholder={t('settings.remoteInstances.page.field.modePlaceholder')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <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')} /> <SelectValue placeholder={t('settings.remoteInstances.page.field.selectInstallMethodPlaceholder')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -2198,15 +2195,13 @@ export const RemoteInstancesPage: React.FC = () => {
</div> </div>
</div> </div>
) : null} ) : null}
</section> </SettingsSection>
</div>
<div className="mb-8 border-t border-[var(--surface-subtle)] pt-8"> <SettingsSection
<div className="mb-1 px-1 space-y-0.5"> title={t('settings.remoteInstances.page.section.mainTunnel')}
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.section.mainTunnel')}</h3> info={t('settings.remoteInstances.page.section.mainTunnelDescription')}
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.section.mainTunnelDescription')}</p> contentClassName="space-y-3"
</div> >
<section className="px-2 pb-2 pt-0 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="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
<div className="w-56 shrink-0"> <div className="w-56 shrink-0">
<HintLabel <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')} /> <SelectValue placeholder={t('settings.remoteInstances.page.field.selectBindHostPlaceholder')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -2298,15 +2293,13 @@ export const RemoteInstancesPage: React.FC = () => {
</Button> </Button>
</div> </div>
</div> </div>
</section> </SettingsSection>
</div>
<div className="mb-8 border-t border-[var(--surface-subtle)] pt-8"> <SettingsSection
<div className="mb-1 px-1 space-y-0.5"> title={t('settings.remoteInstances.page.section.authentication')}
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.section.authentication')}</h3> info={t('settings.remoteInstances.page.section.authenticationDescription')}
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.section.authenticationDescription')}</p> contentClassName="space-y-3"
</div> >
<section className="px-2 pb-2 pt-0 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="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> <span className="typography-ui-label text-foreground w-56 shrink-0">{t('settings.remoteInstances.page.field.sshPasswordOptional')}</span>
<Input <Input
@@ -2352,15 +2345,13 @@ export const RemoteInstancesPage: React.FC = () => {
placeholder={t('settings.remoteInstances.page.field.uiPasswordPlaceholder')} placeholder={t('settings.remoteInstances.page.field.uiPasswordPlaceholder')}
/> />
</div> </div>
</section> </SettingsSection>
</div>
<div className="mb-8 border-t border-[var(--surface-subtle)] pt-8"> <SettingsSection
<div className="mb-1 px-1 space-y-0.5"> title={t('settings.remoteInstances.page.section.portForwards')}
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.section.portForwards')}</h3> info={t('settings.remoteInstances.page.section.portForwardsDescription')}
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.section.portForwardsDescription')}</p> contentClassName="space-y-2"
</div> >
<section className="px-2 pb-2 pt-0 space-y-2">
{draft.portForwards.length === 0 ? ( {draft.portForwards.length === 0 ? (
<p className="typography-micro text-muted-foreground/80">{t('settings.remoteInstances.page.empty.noExtraForwards')}</p> <p className="typography-micro text-muted-foreground/80">{t('settings.remoteInstances.page.empty.noExtraForwards')}</p>
) : null} ) : null}
@@ -2436,12 +2427,16 @@ export const RemoteInstancesPage: React.FC = () => {
</div> </div>
<CollapsibleContent className="pt-2"> <CollapsibleContent className="pt-2">
<div className="space-y-0 pb-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="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
<div className="w-56 shrink-0"> <div className="w-56 shrink-0">
<HintLabel <HintLabel
label={t('settings.remoteInstances.page.field.forwardType')} 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> </div>
<Select <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')} /> <SelectValue placeholder={t('settings.remoteInstances.page.field.typePlaceholder')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -2624,8 +2619,7 @@ export const RemoteInstancesPage: React.FC = () => {
<Icon name="add" className="h-3.5 w-3.5" /> <Icon name="add" className="h-3.5 w-3.5" />
{t('settings.remoteInstances.page.actions.addForward')} {t('settings.remoteInstances.page.actions.addForward')}
</Button> </Button>
</section> </SettingsSection>
</div>
<div className="mt-8 border-t border-[var(--interactive-border)] pt-3"> <div className="mt-8 border-t border-[var(--interactive-border)] pt-3">
<div className="flex items-center gap-2"> <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 React from 'react';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; 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 { cn } from '@/lib/utils';
import {
SETTINGS_DESCRIPTION_CLASS,
SETTINGS_PAGE_TITLE_CLASS,
} from '@/components/sections/shared/SettingsSection';
interface SettingsPageLayoutProps { interface SettingsPageLayoutProps {
/** Page content */ /** Page content */
children: React.ReactNode; 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 */ /** Additional className for the content container */
className?: string; className?: string;
/** Additional className for the outer ScrollableOverlay */ /** Additional className for the outer ScrollableOverlay */
@@ -13,36 +32,120 @@ interface SettingsPageLayoutProps {
/** /**
* Standard layout wrapper for settings page content. * Standard layout wrapper for settings page content.
* Provides scrolling and centered max-width container. * UI Kit: max-width 840px, padding 32px vertical / 48px horizontal.
*
* @example
* <SettingsPageLayout>
* <SettingsSection title="General">
* <SomeSettingsForm />
* </SettingsSection>
* <SettingsSection title="Advanced" divider>
* <OtherSettingsForm />
* </SettingsSection>
* </SettingsPageLayout>
*/ */
export const SettingsPageLayout: React.FC<SettingsPageLayoutProps> = ({ export const SettingsPageLayout: React.FC<SettingsPageLayoutProps> = ({
children, children,
className, className,
outerClassName, 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 ( return (
<ScrollableOverlay <ScrollableOverlay
outerClassName={cn('h-full', outerClassName)} outerClassName={cn('h-full', outerClassName)}
className="w-full" className="w-full @container"
> >
<div <div
className={cn( 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 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} {children}
</div> </div>
</ScrollableOverlay> </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 { useSkillsStore, type SkillConfig, type SkillScope, type SupportingFile, type PendingFile } from '@/stores/useSkillsStore';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; 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 { import {
Select, Select,
SelectContent, SelectContent,
@@ -504,113 +513,103 @@ const SkillsInstalledPage: React.FC = () => {
} }
return ( 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 && ( {isNewSkill && (
<div className="py-1.5"> <SettingsFieldRow
<span className="typography-ui-label text-foreground">{t('settings.skills.page.field.skillNameLocation')}</span> label={t('settings.skills.page.field.skillNameLocation')}
<span className="typography-meta text-muted-foreground ml-2">{t('settings.skills.page.field.skillNameHint')}</span> info={t('settings.skills.page.field.skillNameHint')}
<div className="flex items-center gap-2 mt-1.5"> >
<Input <Input
value={draftName} value={draftName}
onChange={(e) => setDraftName(e.target.value.toLowerCase().replace(/\s+/g, '-'))} onChange={(e) => setDraftName(e.target.value.toLowerCase().replace(/\s+/g, '-'))}
placeholder={t('settings.skills.page.field.skillNamePlaceholder')} placeholder={t('settings.skills.page.field.skillNamePlaceholder')}
className="h-7 w-40 px-2" className="h-7 w-40 px-2"
/> />
<Select <Select
value={locationValueFrom(draftScope, draftSource)} value={locationValueFrom(draftScope, draftSource)}
onValueChange={(v) => { onValueChange={(v) => {
const next = locationPartsFrom(v as SkillLocationValue); const next = locationPartsFrom(v as SkillLocationValue);
setDraftScope(next.scope); setDraftScope(next.scope);
setDraftSource(next.source === 'agents' ? 'agents' : 'opencode'); setDraftSource(next.source === 'agents' ? 'agents' : 'opencode');
}} }}
> >
<SelectTrigger className="w-fit gap-1.5"> <SelectTrigger size={SETTINGS_SELECT_SIZE} className="w-fit gap-1.5">
{draftScope === 'user' ? ( {draftScope === 'user' ? (
<Icon name="user-3" className="h-3.5 w-3.5" /> <Icon name="user-3" className="h-3.5 w-3.5" />
) : ( ) : (
<Icon name="folder" 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} {draftSource === 'agents' ? <Icon name="robot-2" className="h-3.5 w-3.5" /> : null}
<span>{locationLabelText(locationValueFrom(draftScope, draftSource))}</span> <span>{locationLabelText(locationValueFrom(draftScope, draftSource))}</span>
</SelectTrigger> </SelectTrigger>
<SelectContent align="start"> <SelectContent align="start">
{SKILL_LOCATION_OPTIONS.map((option) => ( {SKILL_LOCATION_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}> <SelectItem key={option.value} value={option.value}>
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2"> <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.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} {option.source === 'agents' ? <Icon name="robot-2" className="h-3.5 w-3.5" /> : null}
<span>{locationLabelText(option.value)}</span> <span>{locationLabelText(option.value)}</span>
</div>
<span className="typography-micro text-muted-foreground ml-6">{locationDescriptionText(option.value)}</span>
</div> </div>
</SelectItem> <span className="typography-micro text-muted-foreground ml-6">{locationDescriptionText(option.value)}</span>
))} </div>
</SelectContent> </SelectItem>
</Select> ))}
</div> </SelectContent>
</div> </Select>
</SettingsFieldRow>
)} )}
<div className="py-1.5"> <SettingsStackedField
<span className="typography-ui-label text-foreground">{t('settings.common.field.description')} <span className="text-[var(--status-error)]">*</span></span> label={(
<span className="typography-meta text-muted-foreground ml-2">{t('settings.skills.page.field.descriptionHint')}</span> <>
<div className="mt-1.5"> {t('settings.common.field.description')} <span className="text-[var(--status-error)]">*</span>
<Textarea </>
value={description} )}
onChange={(e) => handleDescriptionChange(e.target.value)} info={t('settings.skills.page.field.descriptionHint')}
placeholder={t('settings.skills.page.field.descriptionPlaceholder')} controlClassName="w-full max-w-none"
rows={2} >
className="w-full resize-none min-h-[60px] max-h-32 bg-transparent" <Textarea
disabled={isReadOnlySkill} value={description}
/> onChange={(e) => handleDescriptionChange(e.target.value)}
</div> placeholder={t('settings.skills.page.field.descriptionPlaceholder')}
</div> rows={2}
className="w-full resize-none min-h-[60px] max-h-32 bg-transparent"
disabled={isReadOnlySkill}
/>
</SettingsStackedField>
</section> </SettingsSection>
</div>
{/* Instructions */} <SettingsSection
<div data-settings-item="skills.instructions" className="mb-8"> title={t('settings.skills.page.section.instructions')}
<div className="mb-1 px-1 flex items-center justify-between gap-2"> headerAction={(
<h3 className="typography-ui-header font-medium text-foreground">
{t('settings.skills.page.section.instructions')}
</h3>
<PreviewToggleButton <PreviewToggleButton
currentMode={skillEditorMode === 'preview' ? 'preview' : 'edit'} currentMode={skillEditorMode === 'preview' ? 'preview' : 'edit'}
onToggle={() => setSkillEditorMode((mode) => mode === 'preview' ? 'edit' : 'preview')} onToggle={() => setSkillEditorMode((mode) => mode === 'preview' ? 'edit' : 'preview')}
/> />
</div> )}
settingsItem="skills.instructions"
<section className="px-2 pb-2 pt-0"> >
<div <div
className={cn( className={cn(
'overflow-hidden rounded-md border border-[var(--surface-subtle)] bg-background', 'overflow-hidden rounded-md border border-[var(--surface-subtle)] bg-background',
@@ -639,21 +638,17 @@ const SkillsInstalledPage: React.FC = () => {
/> />
)} )}
</div> </div>
</section> </SettingsSection>
</div>
{/* Supporting Files */} <SettingsSection
<div data-settings-item="skills.supporting-files" className="mb-2"> title={t('settings.skills.page.section.supportingFiles')}
<div className="mb-1 px-1 flex items-center gap-2"> headerAction={(
<h3 className="typography-ui-header font-medium text-foreground">
{t('settings.skills.page.section.supportingFiles')}
</h3>
<Button variant="outline" size="xs" className="!font-normal gap-1" onClick={handleAddFile} disabled={isReadOnlySkill}> <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')} <Icon name="add" className="h-3.5 w-3.5" /> {t('settings.skills.page.actions.addFile')}
</Button> </Button>
</div> )}
settingsItem="skills.supporting-files"
<section className="px-2 pb-2 pt-0"> >
{(() => { {(() => {
const filesToShow = isNewSkill ? pendingFiles : supportingFiles; const filesToShow = isNewSkill ? pendingFiles : supportingFiles;
@@ -697,11 +692,9 @@ const SkillsInstalledPage: React.FC = () => {
</div> </div>
); );
})()} })()}
</section> </SettingsSection>
</div>
{/* Save action */} <SettingsSection>
<div className="px-2 py-1">
<Button <Button
onClick={handleSave} onClick={handleSave}
disabled={isReadOnlySkill || isSaving || !hasSkillChanges} 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')} {isSaving ? t('settings.common.actions.saving') : isNewSkill ? t('settings.skills.page.actions.createSkill') : t('settings.common.actions.saveChanges')}
</Button> </Button>
</div> </SettingsSection>
</SettingsPageLayout>
</div>
{/* Add/Edit File Dialog */} {/* Add/Edit File Dialog */}
<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-4 flex-1 min-h-0 flex flex-col pt-2">
<div className="space-y-2 flex-shrink-0"> <div className="space-y-2 flex-shrink-0">
<label className="typography-ui-label font-medium text-foreground"> <div className="flex items-center gap-1">
{t('settings.skills.page.fileDialog.field.filePath')} <label className={SETTINGS_FIELD_LABEL_CLASS}>
</label> {t('settings.skills.page.fileDialog.field.filePath')}
</label>
{!editingFilePath && (
<SettingsInfoHint>{t('settings.skills.page.fileDialog.field.filePathHint')}</SettingsInfoHint>
)}
</div>
<Input <Input
value={newFileName} value={newFileName}
onChange={(e) => setNewFileName(e.target.value)} 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)]" className="text-foreground placeholder:text-muted-foreground focus-visible:ring-[var(--primary-base)]"
disabled={editingFilePath !== null} disabled={editingFilePath !== null}
/> />
{!editingFilePath && (
<p className="typography-micro text-muted-foreground">
{t('settings.skills.page.fileDialog.field.filePathHint')}
</p>
)}
</div> </div>
<div className="space-y-2 flex-1 min-h-0 flex flex-col"> <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')} {t('settings.skills.page.fileDialog.field.content')}
</label> </label>
<div className="h-[45vh] min-h-[250px] max-h-[55vh] overflow-hidden rounded-md border border-[var(--surface-subtle)] bg-background"> <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> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</ScrollableOverlay>
</>
); );
}; };
@@ -26,6 +26,7 @@ import { SettingsProjectSelector } from '@/components/sections/shared/SettingsPr
import { SidebarGroup } from '@/components/sections/shared/SidebarGroup'; import { SidebarGroup } from '@/components/sections/shared/SidebarGroup';
import { Icon } from "@/components/icon/Icon"; import { Icon } from "@/components/icon/Icon";
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { SETTINGS_PANEL_TITLE_CLASS } from '@/components/sections/shared/SettingsSection';
interface SkillsSidebarProps { interface SkillsSidebarProps {
onItemSelect?: () => void; onItemSelect?: () => void;
@@ -232,7 +233,7 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
return ( return (
<div className={cn('flex h-full flex-col', bgClass)}> <div className={cn('flex h-full flex-col', bgClass)}>
<div className="border-b px-3 pt-4 pb-3"> <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" /> <SettingsProjectSelector className="mb-3" />
<div className="flex items-center justify-between gap-2"> <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> <span className="typography-meta text-muted-foreground">{t('settings.skills.sidebar.total', { count: skills.length })}</span>
@@ -19,6 +19,7 @@ import {
SelectTrigger, SelectTrigger,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Icon } from "@/components/icon/Icon"; import { Icon } from "@/components/icon/Icon";
import { SettingsInfoHint } from '@/components/sections/shared/SettingsInfoHint';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { isVSCodeRuntime } from '@/lib/desktop'; import { isVSCodeRuntime } from '@/lib/desktop';
@@ -278,7 +279,10 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
</div> </div>
<div className="space-y-2"> <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 <Input
value={source} value={source}
onChange={(e) => { onChange={(e) => {
@@ -287,9 +291,6 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
}} }}
placeholder={t('settings.skills.catalog.shared.field.repositoryPlaceholder')} 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>
<div className="space-y-2"> <div className="space-y-2">
@@ -306,9 +307,10 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
{identityOptions.length > 0 && !isVSCodeRuntime() ? ( {identityOptions.length > 0 && !isVSCodeRuntime() ? (
<div className="space-y-2"> <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-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> <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> </div>
<Select <Select
value={gitIdentityId || ''} value={gitIdentityId || ''}
@@ -328,9 +330,6 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<p className="typography-micro text-muted-foreground">
{t('settings.skills.catalog.shared.auth.footerHint')}
</p>
</div> </div>
) : null} ) : null}
@@ -3,7 +3,12 @@ import { runtimeFetch } from '@/lib/runtime-fetch';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; 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 { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
import { import {
Dialog, Dialog,
@@ -161,12 +166,12 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
}; };
return ( 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.skills.catalog.page.title')}
{/* Header */} showSaveStatus={false}
<div className="mb-4"> >
{showModeTabs && ( {showModeTabs && (
<div className="mb-4"> <div className="mb-4">
<div className="h-10"> <div className="h-10">
<SortableTabsStrip <SortableTabsStrip
@@ -184,22 +189,22 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
</div> </div>
</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"> <div className="flex flex-wrap items-center gap-2 py-1.5">
<Select <Select
value={selectedSourceId || ''} value={selectedSourceId || ''}
onValueChange={(v) => setSelectedSource(v)} 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')}> <SelectValue placeholder={t('settings.skills.catalog.page.field.selectSourcePlaceholder')}>
{selectedSource?.label} {selectedSource?.label}
</SelectValue> </SelectValue>
@@ -269,20 +274,18 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
: t('settings.skills.catalog.page.foundCount', { count: filtered.length })} : t('settings.skills.catalog.page.foundCount', { count: filtered.length })}
</span> </span>
</div> </div>
</section> </SettingsSection>
</div>
{/* Error State */}
{lastCatalogError && ( {lastCatalogError && (
<div className="mb-8 rounded-lg border border-[var(--status-error-border)] bg-[var(--status-error-background)] px-4 py-3"> <SettingsSection>
<div className="typography-ui-label font-medium text-[var(--status-error)]">{t('settings.skills.catalog.page.error.catalogTitle')}</div> <div className="rounded-lg border border-[var(--status-error-border)] bg-[var(--status-error-background)] px-4 py-3">
<div className="typography-meta text-[var(--status-error)]/80 mt-1">{lastCatalogError.message}</div> <div className="typography-ui-label font-medium text-[var(--status-error)]">{t('settings.skills.catalog.page.error.catalogTitle')}</div>
</div> <div className="typography-meta text-[var(--status-error)]/80 mt-1">{lastCatalogError.message}</div>
</div>
</SettingsSection>
)} )}
{/* Skills List */} <SettingsSection>
<div className="mb-8">
<section className="px-2 pb-2 pt-0">
{filtered.length === 0 && !isLoadingSource ? ( {filtered.length === 0 && !isLoadingSource ? (
<div className="py-8 text-center text-muted-foreground"> <div className="py-8 text-center text-muted-foreground">
<p className="typography-body">{t('settings.skills.catalog.page.empty.noSkillsTitle')}</p> <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> </div>
)} )}
</section>
{isClawdHubSource && hasMoreClawdHub && !isLoadingSource && filtered.length > 0 && ( {isClawdHubSource && hasMoreClawdHub && !isLoadingSource && filtered.length > 0 && (
<div className="flex justify-center mt-2 px-2"> <div className="flex justify-center mt-2">
<Button <Button
variant="outline" variant="outline"
size="xs" size="xs"
@@ -382,7 +383,8 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
</Button> </Button>
</div> </div>
)} )}
</div> </SettingsSection>
</SettingsPageLayout>
{/* Dialogs */} {/* Dialogs */}
<AddCatalogDialog open={addCatalogOpen} onOpenChange={setAddCatalogOpen} /> <AddCatalogDialog open={addCatalogOpen} onOpenChange={setAddCatalogOpen} />
@@ -417,7 +419,6 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </>
</ScrollableOverlay>
); );
}; };
@@ -5,10 +5,17 @@ import { Textarea } from '@/components/ui/textarea';
import { toast } from '@/components/ui'; import { toast } from '@/components/ui';
import { useSnippetsStore, type SnippetScope } from '@/stores/useSnippetsStore'; import { useSnippetsStore, type SnippetScope } from '@/stores/useSnippetsStore';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Icon } from '@/components/icon/Icon'; import { Icon } from '@/components/icon/Icon';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useI18n } from '@/lib/i18n'; 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 = () => { export const SnippetsPage: React.FC = () => {
const { t } = useI18n(); const { t } = useI18n();
@@ -117,53 +124,61 @@ export const SnippetsPage: React.FC = () => {
} }
return ( return (
<ScrollableOverlay outerClassName="h-full" className="w-full"> <SettingsPageLayout
<div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8"> title={isNew ? t('settings.snippets.page.title.new') : `#${selectedSnippetName}`}
<div className="mb-4 min-w-0"> description={selectedSnippet ? selectedSnippet.filePath : undefined}
<h2 className="typography-ui-header font-semibold text-foreground truncate"> showSaveStatus={false}
{isNew ? t('settings.snippets.page.title.new') : `#${selectedSnippetName}`} >
</h2> <SettingsSection divider={false} contentClassName="space-y-0">
{selectedSnippet ? <p className="typography-meta text-muted-foreground truncate">{selectedSnippet.filePath}</p> : null} {isNew ? (
</div> <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"> <SettingsSection settingsItem="snippets.content">
<div> <div className="flex items-center gap-1">
{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">
<span className="typography-ui-label text-foreground">{t('settings.snippets.page.field.content')}</span> <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" /> <SettingsInfoHint>{t('settings.snippets.page.hint')}</SettingsInfoHint>
<p className="mt-2 typography-meta text-muted-foreground">{t('settings.snippets.page.hint')}</p>
</div> </div>
<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="px-2 py-1"> <div className="pt-3">
<Button onClick={handleSave} disabled={isSaving || !isDirty} size="xs" className="!font-normal"> <Button onClick={handleSave} disabled={isSaving || !isDirty} size="xs" className="!font-normal">
{isSaving ? t('settings.common.actions.saving') : t('settings.common.actions.saveChanges')} {isSaving ? t('settings.common.actions.saving') : t('settings.common.actions.saveChanges')}
</Button> </Button>
</div> </div>
</div> </SettingsSection>
</ScrollableOverlay> </SettingsPageLayout>
); );
}; };
@@ -11,6 +11,7 @@ import { cn } from '@/lib/utils';
import type { Snippet } from '@/types/snippet'; import type { Snippet } from '@/types/snippet';
import { Icon } from '@/components/icon/Icon'; import { Icon } from '@/components/icon/Icon';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { SETTINGS_PANEL_TITLE_CLASS } from '@/components/sections/shared/SettingsSection';
interface SnippetsSidebarProps { interface SnippetsSidebarProps {
onItemSelect?: () => void; onItemSelect?: () => void;
@@ -62,7 +63,7 @@ export const SnippetsSidebar: React.FC<SnippetsSidebarProps> = ({ onItemSelect }
return ( return (
<div className={cn('flex h-full flex-col', 'bg-background')}> <div className={cn('flex h-full flex-col', 'bg-background')}>
<div className="border-b px-3 pt-4 pb-3"> <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"> <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> <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')}> <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); } 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>; 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> <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"> <section className="space-y-3 px-2 pb-2 pt-0">
{providerId === 'opencode-go' && field('workspaceId', t('settings.providers.page.openCodeGo.workspaceId'), 'wrk_...')} {providerId === 'opencode-go' && field('workspaceId', t('settings.providers.page.openCodeGo.workspaceId'), 'wrk_...')}
@@ -1,7 +1,6 @@
import React from 'react'; import React from 'react';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Checkbox } from '@/components/ui/checkbox';
import { UsageCard } from './UsageCard'; import { UsageCard } from './UsageCard';
import { QuotaCredentials } from './QuotaCredentials';
import { QUOTA_PROVIDERS } from '@/lib/quota'; import { QUOTA_PROVIDERS } from '@/lib/quota';
import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore'; import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
import { updateDesktopSettings } from '@/lib/persistence'; import { updateDesktopSettings } from '@/lib/persistence';
@@ -13,11 +12,15 @@ import {
} from '@/components/ui/collapsible'; } from '@/components/ui/collapsible';
import type { UsageWindows, QuotaProviderId } from '@/types'; import type { UsageWindows, QuotaProviderId } from '@/types';
import { getAllModelFamilies, getDisplayModelName, sortModelFamilies, groupModelsByFamilyWithGetter } from '@/lib/quota/model-families'; 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 { Icon } from "@/components/icon/Icon";
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { formatTimeForPreference } from '@/lib/timeFormat'; import { formatTimeForPreference } from '@/lib/timeFormat';
import { useUIStore, type TimeFormatPreference } from '@/stores/useUIStore'; 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) => { const formatTime = (timestamp: number | null, timeFormatPreference: TimeFormatPreference) => {
if (!timestamp) return '-'; if (!timestamp) return '-';
@@ -74,6 +77,7 @@ export const UsagePage: React.FC = () => {
const providerName = providerMeta?.name ?? selectedProviderId ?? t('settings.usage.sidebar.title'); const providerName = providerMeta?.name ?? selectedProviderId ?? t('settings.usage.sidebar.title');
const usage = selectedResult?.usage; const usage = selectedResult?.usage;
const showInDropdown = selectedProviderId ? dropdownProviderIds.includes(selectedProviderId) : false; const showInDropdown = selectedProviderId ? dropdownProviderIds.includes(selectedProviderId) : false;
const hasCredentialsForm = selectedProviderId === 'opencode-go' || selectedProviderId === 'ollama-cloud' || selectedProviderId === 'cursor';
const handleDropdownToggle = React.useCallback((enabled: boolean) => { const handleDropdownToggle = React.useCallback((enabled: boolean) => {
if (!selectedProviderId) { if (!selectedProviderId) {
return; return;
@@ -149,222 +153,181 @@ export const UsagePage: React.FC = () => {
} }
return ( return (
<ScrollableOverlay outerClassName="h-full" className="w-full"> <SettingsPageLayout
<div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8"> 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 */} {!selectedResult && (
<div className="mb-4 flex items-center gap-3"> <p className="typography-ui-label text-foreground pb-8">{t('settings.usage.page.state.noData')}</p>
<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"> {error && (
{t('settings.usage.page.header.providerUsage', { provider: providerName })} <div className="mb-8 rounded-lg border border-[var(--status-error-border)] bg-[var(--status-error-background)] px-4 py-3">
</h2> <p className="typography-ui-label font-medium text-[var(--status-error)]">{t('settings.usage.page.state.refreshFailedTitle')}</p>
<p className="typography-meta text-muted-foreground truncate"> <p className="typography-meta text-[var(--status-error)]/80 mt-1">{error}</p>
{isLoading ? (
<span className="animate-pulse">{t('settings.usage.page.header.refreshing')}</span>
) : (
t('settings.usage.page.header.lastUpdated', { time: formatTime(lastUpdated, timeFormatPreference) })
)}
</p>
</div>
</div> </div>
)}
{/* Options */} {/* Providers with an inline credentials form don't need the "go to Providers" banner — the form IS the fix. */}
<div data-settings-item="usage.header-menu" className="mb-8 px-2"> {selectedResult && !selectedResult.configured && !hasCredentialsForm && (
<div <div className="mb-8 rounded-lg border border-[var(--status-warning-border)] bg-[var(--status-warning-background)] px-4 py-3">
className="group flex cursor-pointer items-center gap-2 py-1.5" <p className="typography-ui-label font-medium text-[var(--status-warning)]">{t('settings.usage.page.state.providerNotConfiguredTitle')}</p>
role="button" <p className="typography-meta text-[var(--status-warning)]/80 mt-1">
tabIndex={0} {t('settings.usage.page.state.providerNotConfiguredDescription')}
aria-pressed={showInDropdown} </p>
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>
</div> </div>
)}
{/* State Messages */} {(selectedProviderId === 'opencode-go' || selectedProviderId === 'ollama-cloud' || selectedProviderId === 'cursor') && (
{!selectedResult && ( <QuotaCredentials providerId={selectedProviderId} providerName={providerName} />
<div className="mb-8 px-2"> )}
<p className="typography-ui-label text-foreground">{t('settings.usage.page.state.noData')}</p>
{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> </div>
)} </SettingsSection>
)}
{error && ( {providerModels.length > 0 && (
<div className="mb-8 rounded-lg border border-[var(--status-error-border)] bg-[var(--status-error-background)] px-4 py-3"> <SettingsSection
<p className="typography-ui-label font-medium text-[var(--status-error)]">{t('settings.usage.page.state.refreshFailedTitle')}</p> title={t('settings.usage.page.section.modelQuotas')}
<p className="typography-meta text-[var(--status-error)]/80 mt-1">{error}</p> contentClassName="space-y-3"
</div> >
)} {sortedFamilies.map((family) => {
const familyModels = modelsByFamily.get(family.id) ?? [];
if (familyModels.length === 0) return null;
{selectedResult && !selectedResult.configured && ( const isCollapsed = collapsedFamilies[family.id] ?? false;
<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>
)}
{/* Overall Usage Windows */} return (
{usage?.windows && Object.keys(usage.windows).length > 0 && ( <section key={family.id} className="p-2">
<div data-settings-item="usage.model-quotas" className="mb-8"> <Collapsible
<section className="px-2 pb-2 pt-0"> open={!isCollapsed}
<div className="divide-y divide-[var(--surface-subtle)]"> onOpenChange={() => toggleFamilyCollapsed(family.id)}
{Object.entries(usage.windows).map(([label, window]) => ( >
<UsageCard key={label} title={label} window={window} /> <CollapsibleTrigger className="flex w-full items-center justify-between py-0.5 group">
))} <div className="flex items-center gap-1.5 text-left">
</div> <span className="typography-ui-label font-normal text-foreground">{family.label}</span>
</section> <span className="typography-micro text-muted-foreground">
</div> ({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 */} return (
{providerModels.length > 0 && ( <UsageCard
<div className="mb-8"> key={model.name}
<div className="mb-1 px-1"> title={label}
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.usage.page.section.modelQuotas')}</h3> subtitle={getDisplayModelName(model.name)}
</div> window={window}
showToggle
toggleEnabled={isSelected}
onToggle={() => handleModelToggle(model.name)}
/>
);
})}
</div>
</CollapsibleContent>
</Collapsible>
</section>
);
})}
<div className="space-y-3"> {(() => {
{/* Predefined families */} const otherModels = modelsByFamily.get(null) ?? [];
{sortedFamilies.map((family) => { if (otherModels.length === 0) return null;
const familyModels = modelsByFamily.get(family.id) ?? [];
if (familyModels.length === 0) return null;
const isCollapsed = collapsedFamilies[family.id] ?? false; const isCollapsed = collapsedFamilies['other'] ?? false;
return ( return (
<section key={family.id} className="p-2"> <section className="p-2">
<Collapsible <Collapsible
open={!isCollapsed} open={!isCollapsed}
onOpenChange={() => toggleFamilyCollapsed(family.id)} onOpenChange={() => toggleFamilyCollapsed('other')}
> >
<CollapsibleTrigger className="flex w-full items-center justify-between py-0.5 group"> <CollapsibleTrigger className="flex w-full items-center justify-between py-0.5 group">
<div className="flex items-center gap-1.5 text-left"> <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-ui-label font-normal text-foreground">{t('settings.usage.page.section.otherModels')}</span>
<span className="typography-micro text-muted-foreground"> <span className="typography-micro text-muted-foreground">
({familyModels.length}) ({otherModels.length})
</span> </span>
</div> </div>
{isCollapsed ? ( {isCollapsed ? (
<Icon name="arrow-right-s" className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors" /> <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" /> <Icon name="arrow-down-s" className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors" />
)} )}
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent> <CollapsibleContent>
<div className="divide-y divide-[var(--surface-subtle)] mt-1"> <div className="divide-y divide-[var(--surface-subtle)] mt-1">
{familyModels.map((model) => { {otherModels.map((model) => {
const entries = Object.entries(model.windows.windows); const entries = Object.entries(model.windows.windows);
if (entries.length === 0) return null; if (entries.length === 0) return null;
const [label, window] = entries[0]; const [label, window] = entries[0];
const isSelected = providerSelectedModels.includes(model.name); const isSelected = providerSelectedModels.includes(model.name);
return ( return (
<UsageCard <UsageCard
key={model.name} key={model.name}
title={label} title={label}
subtitle={getDisplayModelName(model.name)} subtitle={getDisplayModelName(model.name)}
window={window} window={window}
showToggle showToggle
toggleEnabled={isSelected} toggleEnabled={isSelected}
onToggle={() => handleModelToggle(model.name)} onToggle={() => handleModelToggle(model.name)}
/> />
); );
})} })}
</div> </div>
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
</section> </section>
); );
})} })()}
</SettingsSection>
)}
{/* Other family */} {selectedResult?.configured && usage && Object.keys(usage.windows ?? {}).length === 0 &&
{(() => { providerModels.length === 0 && (
const otherModels = modelsByFamily.get(null) ?? []; <div className="pb-8">
if (otherModels.length === 0) return null; <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>
const isCollapsed = collapsedFamilies['other'] ?? false; </div>
)}
return ( </SettingsPageLayout>
<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>
); );
}; };
@@ -11,6 +11,7 @@ import { QUOTA_PROVIDERS, resolveUsageTone } from '@/lib/quota';
import { useQuotaStore } from '@/stores/useQuotaStore'; import { useQuotaStore } from '@/stores/useQuotaStore';
import { updateDesktopSettings } from '@/lib/persistence'; import { updateDesktopSettings } from '@/lib/persistence';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { SETTINGS_PANEL_TITLE_CLASS } from '@/components/sections/shared/SettingsSection';
interface UsageSidebarProps { interface UsageSidebarProps {
onItemSelect?: () => void; onItemSelect?: () => void;
@@ -88,7 +89,7 @@ export const UsageSidebar: React.FC<UsageSidebarProps> = ({ onItemSelect }) => {
return ( return (
<div className={cn('flex h-full flex-col', bgClass)}> <div className={cn('flex h-full flex-col', bgClass)}>
<div className="border-b px-3 pt-4 pb-3"> <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"> <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> <span className="typography-meta text-muted-foreground">{t('settings.usage.sidebar.total', { count: QUOTA_PROVIDERS.length })}</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -24,6 +24,7 @@ import {
CommandSeparator, CommandSeparator,
} from '@/components/ui/command'; } from '@/components/ui/command';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger';
import { useProjectsStore } from '@/stores/useProjectsStore'; import { useProjectsStore } from '@/stores/useProjectsStore';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
@@ -1584,12 +1585,15 @@ export function NewWorktreeDialog({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<DropdownMenu open={existingBranchDropdownOpen} onOpenChange={setExistingBranchDropdownOpen}> <DropdownMenu open={existingBranchDropdownOpen} onOpenChange={setExistingBranchDropdownOpen}>
<DropdownMenuTrigger asChild> <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')}> <span className={cn('truncate', existingBranchState.selectedBranch ? 'text-foreground' : 'text-muted-foreground')}>
{existingBranchState.selectedBranch || t('session.newWorktree.chooseBranch')} {existingBranchState.selectedBranch || t('session.newWorktree.chooseBranch')}
</span> </span>
<Icon name="arrow-down-s" className="h-4 w-4 shrink-0 text-muted-foreground" /> <Icon name="arrow-down-s" className="h-4 w-4 shrink-0 text-muted-foreground" />
</Button> </button>
</DropdownMenuTrigger> </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}> <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}> <Command shouldFilter={false}>
@@ -1825,12 +1829,15 @@ export function NewWorktreeDialog({
</label> </label>
<DropdownMenu open={sourceBranchDropdownOpen} onOpenChange={setSourceBranchDropdownOpen}> <DropdownMenu open={sourceBranchDropdownOpen} onOpenChange={setSourceBranchDropdownOpen}>
<DropdownMenuTrigger asChild> <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')}> <span className={cn('truncate', newBranchState.sourceBranch ? 'text-foreground' : 'text-muted-foreground')}>
{newBranchState.sourceBranch || t('session.newWorktree.selectSourceBranchPlaceholder')} {newBranchState.sourceBranch || t('session.newWorktree.selectSourceBranchPlaceholder')}
</span> </span>
<Icon name="arrow-down-s" className="h-4 w-4 shrink-0 text-muted-foreground" /> <Icon name="arrow-down-s" className="h-4 w-4 shrink-0 text-muted-foreground" />
</Button> </button>
</DropdownMenuTrigger> </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}> <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}> <Command shouldFilter={false}>
@@ -1,6 +1,7 @@
import * as React from 'react'; import * as React from 'react';
import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger';
import { ScrollShadow } from '@/components/ui/ScrollShadow'; import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea'; 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> <SelectContent>
{TIMEZONE_OPTIONS.map((timezone) => ( {TIMEZONE_OPTIONS.map((timezone) => (
<SelectItem key={timezone} value={timezone}>{timezone}</SelectItem> <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> <SelectValue>
{(value) => value === 'daily' {(value) => value === 'daily'
? t('sessions.scheduledTasks.editor.scheduleType.daily') ? t('sessions.scheduledTasks.editor.scheduleType.daily')
@@ -1257,7 +1258,7 @@ export function ScheduledTaskEditorDialog(props: {
<div className="relative"> <div className="relative">
<button <button
type="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)} onClick={() => setIsDatePickerOpen((prev) => !prev)}
> >
<span className="inline-flex items-center gap-2"> <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> <SelectContent>
{TIMEZONE_OPTIONS.map((timezone) => ( {TIMEZONE_OPTIONS.map((timezone) => (
<SelectItem key={timezone} value={timezone}>{timezone}</SelectItem> <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> <SelectContent>
{TIMEZONE_OPTIONS.map((timezone) => ( {TIMEZONE_OPTIONS.map((timezone) => (
<SelectItem key={timezone} value={timezone}>{timezone}</SelectItem> <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> <SelectValue>
{(value) => value === '__default' {(value) => value === '__default'
? t('sessions.scheduledTasks.editor.thinkingLevel.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 ? ( {selectedProject ? (
<SelectValue>{renderProjectLabel(selectedProject)}</SelectValue> <SelectValue>{renderProjectLabel(selectedProject)}</SelectValue>
) : ( ) : (
@@ -414,7 +414,7 @@ export function ScheduledTasksDialog() {
const tasksContent = ( const tasksContent = (
<div className="space-y-4"> <div className="space-y-4">
{!isMobile ? ( {!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} {projectSelector}
<Button onClick={openNewTaskEditor} disabled={!selectedProjectID}> <Button onClick={openNewTaskEditor} disabled={!selectedProjectID}>
<Icon name="add" className="mr-1 h-4 w-4" /> {t('sessions.scheduledTasks.dialog.actions.newTask')} <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 ref
) => { ) => {
const { t } = useI18n() 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 { isMobile } = useDeviceInfo()
const ignoreNextClickRef = React.useRef(false) const ignoreNextClickRef = React.useRef(false)
const swallowNextClickCleanupRef = React.useRef<(() => void) | null>(null) const swallowNextClickCleanupRef = React.useRef<(() => void) | null>(null)
@@ -100,7 +100,7 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
}, []) }, [])
React.useEffect(() => { React.useEffect(() => {
setDraft(value === undefined ? '' : String(value)) setDraft(value == null ? '' : String(value))
}, [value]) }, [value])
const baseValue = React.useMemo(() => { const baseValue = React.useMemo(() => {
@@ -158,7 +158,7 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
(event: React.FocusEvent<HTMLInputElement>) => { (event: React.FocusEvent<HTMLInputElement>) => {
if (draft.trim() === '') { if (draft.trim() === '') {
if (!onClear) { if (!onClear) {
setDraft(value === undefined ? '' : String(value)) setDraft(value == null ? '' : String(value))
} }
onBlur?.(event) onBlur?.(event)
return return
@@ -166,7 +166,7 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
const parsed = Number(draft) const parsed = Number(draft)
if (!Number.isFinite(parsed)) { if (!Number.isFinite(parsed)) {
setDraft(value === undefined ? '' : String(value)) setDraft(value == null ? '' : String(value))
} else { } else {
const clamped = clamp(parsed, min, max) const clamped = clamp(parsed, min, max)
const normalized = normalizeToStep(clamped, step) const normalized = normalizeToStep(clamped, step)
@@ -228,7 +228,7 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
<div <div
className={cn( className={cn(
// NOTE: mobile.css enforces min-height:36px on buttons; match it to avoid clipping. // 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]", "[-webkit-user-select:none] [-webkit-touch-callout:none]",
"disabled:pointer-events-none disabled:opacity-50", "disabled:pointer-events-none disabled:opacity-50",
containerClassName containerClassName
@@ -241,7 +241,7 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
onTouchStart={handleMobileTouchActivate(handleMobileDecrement)} onTouchStart={handleMobileTouchActivate(handleMobileDecrement)}
onClick={handleMobileClickActivate(handleMobileDecrement)} onClick={handleMobileClickActivate(handleMobileDecrement)}
className={cn( 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", "text-muted-foreground",
"disabled:pointer-events-none disabled:opacity-50", "disabled:pointer-events-none disabled:opacity-50",
!decrementDisabled && "active:bg-interactive-hover" !decrementDisabled && "active:bg-interactive-hover"
@@ -252,13 +252,13 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
<div <div
className={cn( 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]", "text-center text-[16px] leading-none text-foreground [font-variant-numeric:tabular-nums]",
className className
)} )}
aria-live="polite" aria-live="polite"
> >
{value === undefined ? emptyLabel : draft} {value == null ? emptyLabel : draft}
</div> </div>
<button <button
@@ -268,7 +268,7 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
onTouchStart={handleMobileTouchActivate(handleMobileIncrement)} onTouchStart={handleMobileTouchActivate(handleMobileIncrement)}
onClick={handleMobileClickActivate(handleMobileIncrement)} onClick={handleMobileClickActivate(handleMobileIncrement)}
className={cn( 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", "text-muted-foreground",
"disabled:pointer-events-none disabled:opacity-50", "disabled:pointer-events-none disabled:opacity-50",
!incrementDisabled && "active:bg-interactive-hover" !incrementDisabled && "active:bg-interactive-hover"
@@ -283,8 +283,9 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
return ( return (
<div <div
className={cn( 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", "disabled:pointer-events-none disabled:opacity-50",
"transition-[background-color,border-color,box-shadow] duration-150 ease-in-out",
containerClassName containerClassName
)} )}
> >
@@ -294,9 +295,10 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
disabled={decrementDisabled} disabled={decrementDisabled}
onClick={() => commitValue(committedValueRef.current - step)} onClick={() => commitValue(committedValueRef.current - step)}
className={cn( 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", "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" /> <Icon name="subtract" className="block h-3.5 w-3.5" />
@@ -315,7 +317,7 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
autoCorrect="off" autoCorrect="off"
autoCapitalize="off" autoCapitalize="off"
className={cn( 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", "placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground",
"appearance-none outline-none [appearance:textfield] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden", "appearance-none outline-none [appearance:textfield] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
"disabled:pointer-events-none disabled:cursor-not-allowed", "disabled:pointer-events-none disabled:cursor-not-allowed",
@@ -328,9 +330,10 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
disabled={incrementDisabled} disabled={incrementDisabled}
onClick={() => commitValue(committedValueRef.current + step)} onClick={() => commitValue(committedValueRef.current + step)}
className={cn( 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", "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" /> <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 type { SelectRootChangeEventDetails } from "@base-ui/react/select";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { dropdownTriggerVariants } from "@/components/ui/dropdown-trigger"
import { ScrollableOverlay } from "@/components/ui/ScrollableOverlay"; import { ScrollableOverlay } from "@/components/ui/ScrollableOverlay";
import { Icon } from "@/components/icon/Icon"; import { Icon } from "@/components/icon/Icon";
@@ -108,7 +109,7 @@ function SelectTrigger({
onFocusCapture, onFocusCapture,
...props ...props
}: React.ComponentProps<typeof BaseSelect.Trigger> & AsChildProps & { }: React.ComponentProps<typeof BaseSelect.Trigger> & AsChildProps & {
size?: "sm" | "default" | "lg" | "chip" size?: "sm" | "default" | "lg" | "chip" | "settings"
}) { }) {
const portalContext = React.useContext(SelectPortalContext); const portalContext = React.useContext(SelectPortalContext);
@@ -128,7 +129,10 @@ function SelectTrigger({
data-slot="select-trigger" data-slot="select-trigger"
data-size={size} data-size={size}
className={cn( 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 className
)} )}
onPointerDownCapture={(event) => { 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 type { OpenChamberSection } from '@/components/sections/openchamber/types';
import { OpenChamberPage } from '@/components/sections/openchamber/OpenChamberPage'; import { OpenChamberPage } from '@/components/sections/openchamber/OpenChamberPage';
import { AboutSettings } from '@/components/sections/openchamber/AboutSettings'; 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 { useDeviceInfo } from '@/lib/device';
import { isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime, isWebRuntime } from '@/lib/desktop'; import { isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime, isWebRuntime } from '@/lib/desktop';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
@@ -54,16 +58,11 @@ import {
} from '@/lib/settings/metadata'; } from '@/lib/settings/metadata';
import { buildSettingsSearchResults, type SettingsSearchResult } from '@/lib/settings/search'; import { buildSettingsSearchResults, type SettingsSearchResult } from '@/lib/settings/search';
// Same constraints as main sidebar // UI Kit: fixed settings navigation width
const SETTINGS_NAV_MIN_WIDTH = 176; const SETTINGS_NAV_WIDTH = 256;
const SETTINGS_NAV_MAX_WIDTH = 280; const SETTINGS_SPLIT_SIDEBAR_WIDTH = 280;
const SETTINGS_NAV_RESIZE_STEP = 8;
const SETTINGS_DETAIL_HISTORY_KEY = '__openchamberSettingsDetail'; 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 MobileStage = 'nav' | 'page-sidebar' | 'page-content';
type SettingsDetailHistoryEntry = { type SettingsDetailHistoryEntry = {
page: SettingsPageSlug; page: SettingsPageSlug;
@@ -82,30 +81,37 @@ interface SettingsViewProps {
} }
const pageOrder: SettingsPageSlug[] = [ const pageOrder: SettingsPageSlug[] = [
// 'general' group — OpenChamber
'general',
'appearance', 'appearance',
'chat', 'chat',
'notifications', 'notifications',
'sessions', 'sessions',
'shortcuts', 'shortcuts',
'git', 'voice',
'magic-prompts', 'usage',
'snippets', 'about',
// 'projects' group — Workspace
'projects', 'projects',
'remote-instances', 'remote-instances',
'tunnel',
'git',
// 'opencode' group — OpenCode
'providers',
'agents', 'agents',
'behavior', 'behavior',
'commands', 'commands',
'mcp', 'mcp',
'plugins', 'plugins',
'providers', // 'content' group — Library
'usage', 'magic-prompts',
'snippets',
'skills.installed', 'skills.installed',
'skills.catalog', '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 SNIPPETS_SETTINGS_ICON = { icon: 'chat-thread' } as const;
const ADD_PROVIDER_SETTINGS_ID = '__add_provider__'; 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 // eslint-disable-next-line react-refresh/only-export-components
export function getSettingsNavIcon(slug: SettingsPageSlug): IconName | null { export function getSettingsNavIcon(slug: SettingsPageSlug): IconName | null {
switch (slug) { switch (slug) {
case 'general':
return 'settings-3';
case 'projects': case 'projects':
return 'folders'; return 'folders';
case 'remote-instances': case 'remote-instances':
return 'server'; return 'computer';
case 'appearance': case 'appearance':
return 'palette'; return 'palette';
case 'chat': case 'chat':
@@ -197,7 +205,7 @@ export function getSettingsNavIcon(slug: SettingsPageSlug): IconName | null {
case 'mcp': case 'mcp':
return null; return null;
case 'plugins': case 'plugins':
return 'code-box'; return 'plug-2';
case 'skills.installed': case 'skills.installed':
return 'book-open'; return 'book-open';
@@ -212,7 +220,7 @@ export function getSettingsNavIcon(slug: SettingsPageSlug): IconName | null {
case 'voice': case 'voice':
return 'mic'; return 'mic';
case 'tunnel': case 'tunnel':
return 'global'; return 'home-office';
case 'about': case 'about':
return 'information'; return 'information';
case 'home': 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' }) => { export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile, isWindowed, visiblePageSlugs, initialMobileStage = 'nav' }) => {
const { t } = useI18n(); const { t } = useI18n();
const deviceInfo = useDeviceInfo(); const deviceInfo = useDeviceInfo();
@@ -311,14 +243,18 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
const [mobileStage, setMobileStage] = React.useState<MobileStage>(initialMobileStage); const [mobileStage, setMobileStage] = React.useState<MobileStage>(initialMobileStage);
const autoNavSlugRef = React.useRef<string | null>(null); 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 [settingsSearchQuery, setSettingsSearchQuery] = React.useState('');
const [pendingSearchItemId, setPendingSearchItemId] = React.useState<string | null>(null); const [pendingSearchItemId, setPendingSearchItemId] = React.useState<string | null>(null);
const [activeSearchResultIndex, setActiveSearchResultIndex] = React.useState(0); 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 containerRef = React.useRef<HTMLDivElement>(null);
const searchResultRefs = React.useRef<(HTMLButtonElement | null)[]>([]); const searchResultRefs = React.useRef<(HTMLButtonElement | null)[]>([]);
const activeSearchResultIndexRef = React.useRef(0); const activeSearchResultIndexRef = React.useRef(0);
@@ -362,68 +298,6 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
const activeProjectId = useProjectsStore((state) => state.activeProjectId); 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. // Load stores when project changes or when a page becomes active.
React.useEffect(() => { React.useEffect(() => {
if (!isSettingsDialogOpen && !runtimeCtx.isVSCode && !isWindowed) { if (!isSettingsDialogOpen && !runtimeCtx.isVSCode && !isWindowed) {
@@ -476,6 +350,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
// Nav is always open (collapsed state removed) // Nav is always open (collapsed state removed)
const openChamberSectionBySlug: Partial<Record<SettingsPageSlug, OpenChamberSection>> = React.useMemo(() => ({ const openChamberSectionBySlug: Partial<Record<SettingsPageSlug, OpenChamberSection>> = React.useMemo(() => ({
general: 'general',
appearance: 'visual', appearance: 'visual',
chat: 'chat', chat: 'chat',
shortcuts: 'shortcuts', shortcuts: 'shortcuts',
@@ -487,6 +362,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
const getPageTitle = React.useCallback((slug: SettingsPageSlug): string => { const getPageTitle = React.useCallback((slug: SettingsPageSlug): string => {
switch (slug) { switch (slug) {
case 'general':
return t('settings.page.general.title');
case 'projects': case 'projects':
return t('settings.page.projects.title'); return t('settings.page.projects.title');
case 'remote-instances': case 'remote-instances':
@@ -739,7 +616,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
return ( return (
<div className="flex h-full items-center justify-center px-6"> <div className="flex h-full items-center justify-center px-6">
<div className="max-w-md text-center"> <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> <p className="typography-ui text-muted-foreground mt-1">{t('settings.view.unavailable.description')}</p>
</div> </div>
</div> </div>
@@ -780,8 +657,6 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
} }
switch (slug) { switch (slug) {
case 'home':
return <SettingsHome onOpen={openPage} />;
case 'projects': case 'projects':
return <ProjectsPage />; return <ProjectsPage />;
case 'remote-instances': case 'remote-instances':
@@ -805,13 +680,18 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
case 'usage': case 'usage':
return <UsagePage />; return <UsagePage />;
case 'about': 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': case 'magic-prompts':
return <MagicPromptsPage />; return <MagicPromptsPage />;
case 'snippets': case 'snippets':
return <SnippetsPage />; return <SnippetsPage />;
case 'git': case 'git':
return <GitPage />; return <GitPage />;
case 'general':
case 'appearance': case 'appearance':
case 'chat': case 'chat':
case 'shortcuts': case 'shortcuts':
@@ -822,10 +702,11 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
const section = openChamberSectionBySlug[slug] ?? 'visual'; const section = openChamberSectionBySlug[slug] ?? 'visual';
return <OpenChamberPage section={section} />; return <OpenChamberPage section={section} />;
} }
case 'home':
default: 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. // Mobile: if opened via deep-link / palette to a non-home page, jump into it once.
React.useEffect(() => { React.useEffect(() => {
@@ -938,7 +819,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
return ( return (
<div className="flex h-full flex-col overflow-hidden"> <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"> <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" /> <Icon name="search" className="h-4 w-4 shrink-0" />
<input <input
@@ -964,7 +845,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
{/* Scrollable nav items */} {/* Scrollable nav items */}
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden"> <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 ? ( {hasSearchQuery ? (
settingsSearchResults.length > 0 ? (() => { settingsSearchResults.length > 0 ? (() => {
let resultIndex = 0; let resultIndex = 0;
@@ -1010,54 +891,83 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
{t('settings.view.search.noResults')} {t('settings.view.search.noResults')}
</div> </div>
) )
) : sortedFilteredPages.map((page) => { ) : (() => {
const selected = settingsSlug === page.slug; const pagesByGroup = new Map<string, typeof sortedFilteredPages>();
const iconName = getSettingsNavIcon(page.slug); for (const page of sortedFilteredPages) {
if (!iconName && page.slug !== 'mcp') return null; const group = page.group;
const existing = pagesByGroup.get(group);
if (existing) {
existing.push(page);
} else {
pagesByGroup.set(group, [page]);
}
}
return ( const visibleGroups = NAV_GROUP_ORDER
<Tooltip key={page.slug}> .map((group) => ({ group, pages: pagesByGroup.get(group) ?? [] }))
<TooltipTrigger asChild> .filter((entry) => entry.pages.length > 0);
<button
type="button" return visibleGroups.map(({ group, pages }, groupIndex) => (
onClick={() => openPage(page.slug)} <div key={group} className="space-y-0.5">
aria-current={selected ? 'page' : undefined} <div
className={cn( className={cn(
'flex h-8 items-center gap-2 rounded-md px-2 overflow-hidden', 'px-3 pb-1 typography-micro font-semibold uppercase tracking-wide text-muted-foreground sm:px-2 sm:pb-0.5',
selected groupIndex === 0 ? 'pt-1' : 'pt-4 sm:pt-3',
? 'bg-interactive-selection text-foreground' )}
: 'text-foreground hover:bg-interactive-hover' >
)} {t(`settings.view.nav.group.${group}`)}
> </div>
{page.slug === 'mcp' {pages.map((page) => {
? <McpIcon className="h-4 w-4 shrink-0" /> const selected = settingsSlug === page.slug;
: <Icon name={iconName!} className="h-4 w-4 shrink-0" />} const iconName = getSettingsNavIcon(page.slug);
<span className="flex items-center gap-1.5 whitespace-nowrap overflow-hidden transition-opacity duration-150 opacity-100"> if (!iconName && page.slug !== 'mcp') return null;
<span className="typography-ui-label font-normal truncate">{getPageTitle(page.slug)}</span>
{page.slug === 'tunnel' && ( return (
<span className="shrink-0 typography-micro px-1 rounded leading-none pb-px text-[var(--status-warning)] bg-[var(--status-warning)]/10"> <Tooltip key={page.slug}>
{t('settings.view.badge.beta')} <TooltipTrigger asChild>
</span> <button
)} type="button"
</span> onClick={() => openPage(page.slug)}
</button> aria-current={selected ? 'page' : undefined}
</TooltipTrigger> className={cn(
</Tooltip> '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>
</div> </div>
{/* Footer */} {/* Footer */}
<div className="overflow-hidden transition-opacity duration-150 opacity-100"> <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 && ( {!runtimeCtx.isVSCode && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
type="button" type="button"
className={cn( 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', 'text-sm font-semibold text-sidebar-foreground/90',
'hover:text-sidebar-foreground hover:bg-interactive-hover', 'hover:text-sidebar-foreground hover:bg-interactive-hover',
)} )}
@@ -1082,7 +992,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
const renderMobileStage = () => { const renderMobileStage = () => {
if (mobileStage === 'nav') { if (mobileStage === 'nav') {
return ( 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"> <div className="flex h-full min-h-0 flex-col">
<ErrorBoundary>{renderSettingsNav()}</ErrorBoundary> <ErrorBoundary>{renderSettingsNav()}</ErrorBoundary>
</div> </div>
@@ -1105,7 +1015,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
); );
} }
return ( 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> <ErrorBoundary>
{renderPageSidebar(settingsSlug, { onItemSelect: handleMobilePageSidebarItemSelect })} {renderPageSidebar(settingsSlug, { onItemSelect: handleMobilePageSidebarItemSelect })}
</ErrorBoundary> </ErrorBoundary>
@@ -1125,13 +1035,13 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
const renderDesktopContent = () => { const renderDesktopContent = () => {
if (!activePageMeta || settingsSlug === 'home') { if (!activePageMeta || settingsSlug === 'home') {
return <SettingsHome onOpen={openPage} />; return null;
} }
if (activePageMeta.kind === 'split') { if (activePageMeta.kind === 'split') {
return ( return (
<div className="flex h-full min-h-0 overflow-hidden"> <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> <ErrorBoundary>{renderPageSidebar(settingsSlug, {})}</ErrorBoundary>
</div> </div>
<div className="flex-1 min-h-0 overflow-hidden bg-background"> <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 : runtimeCtx.isVSCode
? 'bg-background' ? 'bg-background'
: 'bg-sidebar', : 'bg-sidebar',
isResizing ? '' : 'transition-[width,min-width] duration-200 ease-[cubic-bezier(0.25,0.1,0.25,1)]'
)} )}
style={{ style={{
width: `${navWidth}px`, width: `${SETTINGS_NAV_WIDTH}px`,
minWidth: `${navWidth}px`, minWidth: `${SETTINGS_NAV_WIDTH}px`,
borderColor: 'var(--interactive-border)', 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> <ErrorBoundary>
{renderSettingsNav()} {renderSettingsNav()}
</ErrorBoundary> </ErrorBoundary>
@@ -3,6 +3,7 @@ import { toast } from '@/components/ui';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { copyTextToClipboard } from '@/lib/clipboard'; import { copyTextToClipboard } from '@/lib/clipboard';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger';
import { ProviderLogo } from '@/components/ui/ProviderLogo'; import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { useAgentGroupsStore, type AgentGroup, type AgentGroupSession } from '@/stores/useAgentGroupsStore'; import { useAgentGroupsStore, type AgentGroup, type AgentGroupSession } from '@/stores/useAgentGroupsStore';
import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionUIStore } from '@/sync/session-ui-store';
@@ -179,9 +180,9 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <button
variant="outline" type="button"
className="w-full justify-between h-10 px-3" className={cn(dropdownTriggerVariants({ size: 'default' }), 'w-full')}
> >
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
{selectedSession && ( {selectedSession && (
@@ -203,7 +204,7 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
)} )}
</div> </div>
<Icon name="arrow-down-s" className="h-4 w-4 flex-shrink-0 text-muted-foreground" /> <Icon name="arrow-down-s" className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
</Button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[var(--anchor-width)]"> <DropdownMenuContent align="start" className="w-[var(--anchor-width)]">
{group.sessions.map((session) => ( {group.sessions.map((session) => (
@@ -25,6 +25,7 @@ import {
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Icon } from "@/components/icon/Icon"; import { Icon } from "@/components/icon/Icon";
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
type OperationType = 'merge' | 'rebase'; type OperationType = 'merge' | 'rebase';
@@ -300,12 +301,15 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
</p> </p>
<DropdownMenu open={branchDropdownOpen} onOpenChange={setBranchDropdownOpen} modal={false}> <DropdownMenu open={branchDropdownOpen} onOpenChange={setBranchDropdownOpen} modal={false}>
<DropdownMenuTrigger asChild> <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')}> <span className={cn('truncate', !selectedBranch && 'text-muted-foreground')}>
{selectedBranch || t('gitView.branch.selectBranch')} {selectedBranch || t('gitView.branch.selectBranch')}
</span> </span>
<Icon name="arrow-down-s" className="size-4 opacity-60 shrink-0" /> <Icon name="arrow-down-s" className="size-4 opacity-60 shrink-0" />
</Button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
align="start" align="start"
@@ -228,7 +228,7 @@ export const HistorySection: React.FC<HistorySectionProps> = ({
> >
<SelectTrigger <SelectTrigger
size="sm" 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} disabled={isLogLoading}
> >
<SelectValue placeholder={t('gitView.history.commitsPlaceholder')} /> <SelectValue placeholder={t('gitView.history.commitsPlaceholder')} />
@@ -5,6 +5,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger';
import { import {
Command, Command,
CommandEmpty, CommandEmpty,
@@ -361,11 +362,14 @@ export const IntegrateCommitsSection: React.FC<{
<DropdownMenu open={branchDropdownOpen} onOpenChange={setBranchDropdownOpen}> <DropdownMenu open={branchDropdownOpen} onOpenChange={setBranchDropdownOpen}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-1.5"> <button
type="button"
className={dropdownTriggerVariants({ size: 'default' })}
>
{t('gitView.integrate.target')} {t('gitView.integrate.target')}
<span className="max-w-[160px] truncate font-mono text-xs text-muted-foreground">{targetBranch}</span> <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" /> <Icon name="arrow-down-s" className="size-4 opacity-60" />
</Button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
align="end" align="end"
@@ -1697,7 +1697,7 @@ export const PullRequestSection: React.FC<{
onValueChange={(value) => setMergeMethod(value as MergeMethod)} onValueChange={(value) => setMergeMethod(value as MergeMethod)}
disabled={isMerging || pr.state !== 'open'} 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 /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -1761,7 +1761,7 @@ export const PullRequestSection: React.FC<{
<div className="typography-micro text-muted-foreground">{t('gitView.pr.field.baseBranch')}</div> <div className="typography-micro text-muted-foreground">{t('gitView.pr.field.baseBranch')}</div>
{availableBaseBranches.length > 0 ? ( {availableBaseBranches.length > 0 ? (
<Select value={targetBaseBranch} onValueChange={setTargetBaseBranch}> <Select value={targetBaseBranch} onValueChange={setTargetBaseBranch}>
<SelectTrigger className="h-9"> <SelectTrigger size="lg">
<SelectValue placeholder={t('gitView.pr.placeholder.selectBaseBranch')} /> <SelectValue placeholder={t('gitView.pr.placeholder.selectBaseBranch')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
+16 -10
View File
@@ -662,16 +662,22 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
); );
const setThemeModeHandler = useCallback((mode: ThemeMode) => { const setThemeModeHandler = useCallback((mode: ThemeMode) => {
setPreferences((prev) => { if (preferences.themeMode === mode) {
if (prev.themeMode === mode) { return;
return prev; }
}
return { setPreferences((prev) => ({
...prev, ...prev,
themeMode: mode, 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( const setSystemPreferenceHandler = useCallback(
(use: boolean) => { (use: boolean) => {
@@ -47,6 +47,10 @@ export const settingsDict = {
'settings.view.search.aria': 'Search settings', 'settings.view.search.aria': 'Search settings',
'settings.view.search.clear': 'Clear settings search', 'settings.view.search.clear': 'Clear settings search',
'settings.view.search.noResults': 'No matching settings', '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.projects.title': 'Projects',
'settings.page.remoteInstances.title': 'Remote Instances', 'settings.page.remoteInstances.title': 'Remote Instances',
'settings.page.providers.title': 'Providers', 'settings.page.providers.title': 'Providers',
@@ -59,13 +63,22 @@ export const settingsDict = {
'settings.page.skillsCatalog.title': 'Skills Catalog', 'settings.page.skillsCatalog.title': 'Skills Catalog',
'settings.page.git.title': 'Git', 'settings.page.git.title': 'Git',
'settings.page.appearance.title': 'Appearance', 'settings.page.appearance.title': 'Appearance',
'settings.page.appearance.description': 'Customize how OpenChamber looks and feels.',
'settings.page.chat.title': 'Chat', 'settings.page.chat.title': 'Chat',
'settings.page.chat.description': 'Configure how messages and tools are displayed.',
'settings.page.shortcuts.title': 'Shortcuts', '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.title': 'Sessions',
'settings.page.sessions.description': 'Set defaults and retention for sessions.',
'settings.page.magicPrompts.title': 'Magic Prompts', 'settings.page.magicPrompts.title': 'Magic Prompts',
'settings.page.notifications.title': 'Notifications', 'settings.page.notifications.title': 'Notifications',
'settings.page.notifications.description': 'Choose when and how you get notified.',
'settings.page.voice.title': 'Voice', '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.about.title': 'About',
'settings.page.snippets.title': 'Snippets', 'settings.page.snippets.title': 'Snippets',
'settings.snippets.sidebar.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.created': 'Template created',
'settings.promptTemplates.page.toast.createFailed': 'Failed to create template', 'settings.promptTemplates.page.toast.createFailed': 'Failed to create template',
'settings.promptTemplates.page.toast.saveUnexpectedError': 'An unexpected error occurred while saving', '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.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.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.', '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.logsDialog.selectedInstanceFallback': 'Selected instance',
'settings.remoteInstances.page.patternDialog.descriptionWithHost': '{host} requires a concrete destination.', 'settings.remoteInstances.page.patternDialog.descriptionWithHost': '{host} requires a concrete destination.',
'settings.remoteInstances.page.patternDialog.description': 'Enter destination.', 'settings.remoteInstances.page.patternDialog.description': 'Enter destination.',
'settings.common.infoAria': 'More information',
'settings.common.actions.cancel': 'Cancel', 'settings.common.actions.cancel': 'Cancel',
'settings.common.actions.create': 'Create', 'settings.common.actions.create': 'Create',
'settings.common.actions.delete': 'Delete', 'settings.common.actions.delete': 'Delete',
@@ -409,6 +423,8 @@ export const settingsDict = {
'settings.common.actions.copyAll': 'Copy all', 'settings.common.actions.copyAll': 'Copy all',
'settings.common.actions.clear': 'Clear', 'settings.common.actions.clear': 'Clear',
'settings.common.actions.saving': 'Saving...', 'settings.common.actions.saving': 'Saving...',
'settings.common.status.saved': 'Saved',
'settings.common.status.saveFailed': 'Save failed',
'settings.common.actions.saveChanges': 'Save Changes', 'settings.common.actions.saveChanges': 'Save Changes',
'settings.common.scope.global': 'Global', 'settings.common.scope.global': 'Global',
'settings.common.scope.project': 'Project', 'settings.common.scope.project': 'Project',
@@ -493,6 +509,33 @@ export const settingsDict = {
'settings.agents.page.section.identityRole': 'Identity & Role', 'settings.agents.page.section.identityRole': 'Identity & Role',
'settings.agents.page.section.modelParameters': 'Model & Parameters', 'settings.agents.page.section.modelParameters': 'Model & Parameters',
'settings.agents.page.section.systemPrompt': 'System Prompt', '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.section.toolPermissions': 'Tool Permissions',
'settings.agents.page.field.agentName': 'Agent Name', 'settings.agents.page.field.agentName': 'Agent Name',
'settings.agents.page.field.agentNamePlaceholder': '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.permissionPlaceholder': 'Permission...',
'settings.agents.page.permissions.patternPlaceholder': 'Pattern (e.g. *)', 'settings.agents.page.permissions.patternPlaceholder': 'Pattern (e.g. *)',
'settings.page.behavior.title': 'Behavior', 'settings.page.behavior.title': 'Behavior',
'settings.page.behavior.description': 'Guide how the agent responds.',
'settings.behavior.page.title': 'Behavior', 'settings.behavior.page.title': 'Behavior',
'settings.behavior.page.warning.title': 'Global rules are combined with project rules', '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.', '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.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.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.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.password': 'Desktop UI Password',
'settings.openchamber.desktopPassword.field.passwordPlaceholder': 'No password required', '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.', '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.section.playbackAndSummary': 'Playback',
'settings.voice.page.field.provider': 'Provider', 'settings.voice.page.field.provider': 'Provider',
'settings.voice.page.provider.browser': 'Browser', 'settings.voice.page.provider.browser': 'Browser',
'settings.voice.page.provider.openai': 'OpenAI',
'settings.voice.page.provider.custom': 'Custom', 'settings.voice.page.provider.custom': 'Custom',
'settings.voice.page.provider.say': 'Say', 'settings.voice.page.provider.say': 'Say',
'settings.voice.page.provider.server': 'Server', 'settings.voice.page.provider.server': 'Server',
@@ -1697,18 +1744,24 @@ export const settingsDict = {
'settings.voice.page.field.ttsInputModeRaw': 'Raw Markdown', 'settings.voice.page.field.ttsInputModeRaw': 'Raw Markdown',
'settings.voice.page.field.ttsInputModeSummarized': 'summarized', 'settings.voice.page.field.ttsInputModeSummarized': 'summarized',
'settings.openchamber.visual.section.colorMode': 'Color Mode', '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.section.mobileLayout': 'Mobile Layout',
'settings.openchamber.visual.option.mobileLayout.default': 'Old', 'settings.openchamber.visual.option.mobileLayout.default': 'Old',
'settings.openchamber.visual.option.mobileLayout.new': 'New', 'settings.openchamber.visual.option.mobileLayout.new': 'New',
'settings.openchamber.visual.section.localization': 'Localization', 'settings.openchamber.visual.section.localization': 'Localization',
'settings.openchamber.visual.section.spacingAndLayout': 'Spacing & Layout', '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.navigation': 'Navigation',
'settings.openchamber.visual.section.chatRenderMode': 'Chat Render Mode', 'settings.openchamber.visual.section.chatRenderMode': 'Chat Render Mode',
'settings.openchamber.visual.section.chatRenderModeAria': '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.messageStreamTransport': 'Message Stream Transport',
'settings.openchamber.visual.section.activityDefault': 'Activity Default', 'settings.openchamber.visual.section.activityDefault': 'Activity Default',
'settings.openchamber.visual.section.activityDefaultAria': 'Activity default mode', '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.sessionAssistance': 'Session Assistance',
'settings.openchamber.visual.section.reasoning': 'Reasoning', 'settings.openchamber.visual.section.reasoning': 'Reasoning',
'settings.openchamber.visual.section.messageAppearance': 'Message Appearance', '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.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.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': 'System',
'settings.openchamber.visual.option.themeMode.system.description': 'Follow system setting',
'settings.openchamber.visual.option.themeMode.light': 'Light', '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': '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.label': 'Dynamic',
'settings.openchamber.visual.option.diffLayout.dynamic.description': 'New inline, modified side-by-side.', 'settings.openchamber.visual.option.diffLayout.dynamic.description': 'New inline, modified side-by-side.',
'settings.openchamber.visual.option.diffLayout.inline.label': 'Always inline', '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.aria": "Buscar configuración",
"settings.view.search.clear": "Borrar búsqueda de configuración", "settings.view.search.clear": "Borrar búsqueda de configuración",
"settings.view.search.noResults": "No hay configuraciones coincidentes", "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.projects.title": "Proyectos",
"settings.page.remoteInstances.title": "Instancias remotas", "settings.page.remoteInstances.title": "Instancias remotas",
"settings.page.providers.title": "Proveedores", "settings.page.providers.title": "Proveedores",
@@ -59,13 +63,22 @@ export const settingsDict = {
"settings.page.skillsCatalog.title": "Catálogo de habilidades", "settings.page.skillsCatalog.title": "Catálogo de habilidades",
"settings.page.git.title": "Git", "settings.page.git.title": "Git",
"settings.page.appearance.title": "Apariencia", "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.title": "Chat",
"settings.page.chat.description": "Configura cómo se muestran los mensajes y las herramientas.",
"settings.page.shortcuts.title": "Atajos", "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.title": "Sesiones",
"settings.page.sessions.description": "Define valores predeterminados y retención de sesiones.",
"settings.page.magicPrompts.title": "Prompts mágicos", "settings.page.magicPrompts.title": "Prompts mágicos",
"settings.page.notifications.title": "Notificaciones", "settings.page.notifications.title": "Notificaciones",
"settings.page.notifications.description": "Elige cuándo y cómo recibir notificaciones.",
"settings.page.voice.title": "Voz", "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.about.title": "Acerca de",
"settings.page.snippets.title": "Snippets", "settings.page.snippets.title": "Snippets",
"settings.snippets.sidebar.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.contentRequired": "El contenido del snippet es obligatorio",
"settings.snippets.page.toast.saveFailed": "No se pudo guardar el snippet", "settings.snippets.page.toast.saveFailed": "No se pudo guardar el snippet",
"settings.snippets.page.toast.saved": "Snippet guardado", "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.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.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.", "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.logsDialog.selectedInstanceFallback": "Instancia seleccionada",
"settings.remoteInstances.page.patternDialog.descriptionWithHost": "{host} requiere un destino concreto.", "settings.remoteInstances.page.patternDialog.descriptionWithHost": "{host} requiere un destino concreto.",
"settings.remoteInstances.page.patternDialog.description": "Introduce destino.", "settings.remoteInstances.page.patternDialog.description": "Introduce destino.",
"settings.common.infoAria": "Más información",
"settings.common.actions.cancel": "Cancelar", "settings.common.actions.cancel": "Cancelar",
"settings.common.actions.create": "Crear", "settings.common.actions.create": "Crear",
"settings.common.actions.delete": "Eliminar", "settings.common.actions.delete": "Eliminar",
@@ -376,6 +390,8 @@ export const settingsDict = {
"settings.common.actions.copyAll": "Copiar todo", "settings.common.actions.copyAll": "Copiar todo",
"settings.common.actions.clear": "Limpiar", "settings.common.actions.clear": "Limpiar",
"settings.common.actions.saving": "Guardando...", "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.actions.saveChanges": "Guardar cambios",
"settings.common.scope.global": "Global", "settings.common.scope.global": "Global",
"settings.common.scope.project": "Proyecto", "settings.common.scope.project": "Proyecto",
@@ -460,6 +476,33 @@ export const settingsDict = {
"settings.agents.page.section.identityRole": "Identidad y rol", "settings.agents.page.section.identityRole": "Identidad y rol",
"settings.agents.page.section.modelParameters": "Modelo y parámetros", "settings.agents.page.section.modelParameters": "Modelo y parámetros",
"settings.agents.page.section.systemPrompt": "Prompt del sistema", "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.section.toolPermissions": "Permisos de herramientas",
"settings.agents.page.field.agentName": "Nombre del agente", "settings.agents.page.field.agentName": "Nombre del agente",
"settings.agents.page.field.agentNamePlaceholder": "nombre-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.permissionPlaceholder": "Permiso...",
"settings.agents.page.permissions.patternPlaceholder": "Patrón (ej. *)", "settings.agents.page.permissions.patternPlaceholder": "Patrón (ej. *)",
"settings.page.behavior.title": "Comportamiento", "settings.page.behavior.title": "Comportamiento",
'settings.page.behavior.description': 'Guía cómo responde el agente.',
"settings.behavior.page.title": "Comportamiento", "settings.behavior.page.title": "Comportamiento",
"settings.behavior.page.warning.title": "Las reglas globales se combinan con las reglas del proyecto", "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.", "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.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.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.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.concise": "Conciso",
"settings.behavior.page.responseStyle.option.detailed": "Detallado", "settings.behavior.page.responseStyle.option.detailed": "Detallado",
"settings.behavior.page.responseStyle.option.mentor": "Mentor", "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.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.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.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.password": "Contraseña de UI de escritorio",
"settings.openchamber.desktopPassword.field.passwordPlaceholder": "No se requiere contraseña", "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.", "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.section.playbackAndSummary": "Reproducción",
"settings.voice.page.field.provider": "Proveedor", "settings.voice.page.field.provider": "Proveedor",
"settings.voice.page.provider.browser": "Navegador", "settings.voice.page.provider.browser": "Navegador",
"settings.voice.page.provider.openai": "OpenAI",
"settings.voice.page.provider.custom": "Personalizado", "settings.voice.page.provider.custom": "Personalizado",
"settings.voice.page.provider.say": "Decir", "settings.voice.page.provider.say": "Decir",
"settings.voice.page.provider.server": "Servidor", "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.ttsInputModeRaw": "Markdown sin procesar",
"settings.voice.page.field.ttsInputModeSummarized": "resumido", "settings.voice.page.field.ttsInputModeSummarized": "resumido",
"settings.openchamber.visual.section.colorMode": "Modo de color", "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.section.mobileLayout": "Diseño móvil",
"settings.openchamber.visual.option.mobileLayout.default": "Anterior", "settings.openchamber.visual.option.mobileLayout.default": "Anterior",
"settings.openchamber.visual.option.mobileLayout.new": "Nuevo", "settings.openchamber.visual.option.mobileLayout.new": "Nuevo",
"settings.openchamber.visual.section.localization": "Localización", "settings.openchamber.visual.section.localization": "Localización",
"settings.openchamber.visual.section.spacingAndLayout": "Espaciado y diseño", "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.navigation": "Navegación",
"settings.openchamber.visual.section.chatRenderMode": "Modo de renderizado de chat", "settings.openchamber.visual.section.chatRenderMode": "Modo de renderizado de chat",
"settings.openchamber.visual.section.chatRenderModeAria": "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.messageStreamTransport": "Transporte del flujo de mensajes",
"settings.openchamber.visual.section.activityDefault": "Modo predeterminado de actividad", "settings.openchamber.visual.section.activityDefault": "Modo predeterminado de actividad",
"settings.openchamber.visual.section.activityDefaultAria": "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.sessionAssistance": "Asistencia de sesión",
"settings.openchamber.visual.section.reasoning": "Razonamiento", "settings.openchamber.visual.section.reasoning": "Razonamiento",
"settings.openchamber.visual.section.messageAppearance": "Apariencia de los mensajes", "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.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.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": "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": "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": "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.label": "Dinámico",
"settings.openchamber.visual.option.diffLayout.dynamic.description": "Nuevo en línea, modificado lado a lado.", "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", "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.skillsCatalog.title': 'Catalogue de skills',
'settings.page.git.title': 'Git', 'settings.page.git.title': 'Git',
'settings.page.appearance.title': 'Apparence', 'settings.page.appearance.title': 'Apparence',
'settings.page.appearance.description': 'Personnalisez l’apparence et le ressenti d’OpenChamber.',
'settings.page.chat.title': 'Chat', 'settings.page.chat.title': 'Chat',
'settings.page.chat.description': 'Configurez l’affichage des messages et des outils.',
'settings.page.shortcuts.title': 'Raccourcis', '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.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.magicPrompts.title': 'Prompts magiques',
'settings.page.notifications.title': 'Notifications', 'settings.page.notifications.title': 'Notifications',
'settings.page.notifications.description': 'Choisissez quand et comment être notifié.',
'settings.page.voice.title': 'Voix', '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.page.snippets.title': 'Extraits',
'settings.snippets.sidebar.title': 'Extraits', 'settings.snippets.sidebar.title': 'Extraits',
'settings.snippets.sidebar.total': 'Total : {count}', '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.created': 'Modèle créé',
'settings.promptTemplates.page.toast.createFailed': 'Échec de la création du modèle', '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.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.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.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.', '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.logsDialog.selectedInstanceFallback': 'Instance sélectionnée',
'settings.remoteInstances.page.patternDialog.descriptionWithHost': '{host} nécessite une destination concrète.', 'settings.remoteInstances.page.patternDialog.descriptionWithHost': '{host} nécessite une destination concrète.',
'settings.remoteInstances.page.patternDialog.description': 'Entrez la destination.', 'settings.remoteInstances.page.patternDialog.description': 'Entrez la destination.',
'settings.common.infoAria': 'Plus d’informations',
'settings.common.actions.cancel': 'Annuler', 'settings.common.actions.cancel': 'Annuler',
'settings.common.actions.create': 'Créer', 'settings.common.actions.create': 'Créer',
'settings.common.actions.delete': 'Supprimer', 'settings.common.actions.delete': 'Supprimer',
@@ -303,6 +313,8 @@ export const settingsDict = {
'settings.common.actions.copyAll': 'Copier tout', 'settings.common.actions.copyAll': 'Copier tout',
'settings.common.actions.clear': 'Effacer', 'settings.common.actions.clear': 'Effacer',
'settings.common.actions.saving': 'Enregistrement...', 'settings.common.actions.saving': 'Enregistrement...',
'settings.common.status.saved': 'Enregistré',
'settings.common.status.saveFailed': 'Échec de l’enregistrement',
'settings.common.actions.saveChanges': 'Enregistrer les modifications', 'settings.common.actions.saveChanges': 'Enregistrer les modifications',
'settings.common.scope.global': 'Mondial', 'settings.common.scope.global': 'Mondial',
'settings.common.scope.project': 'Projet', '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.identityRole': 'Identité et rôle',
'settings.agents.page.section.modelParameters': 'Modèle et paramètres', 'settings.agents.page.section.modelParameters': 'Modèle et paramètres',
'settings.agents.page.section.systemPrompt': 'Prompt système', '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.section.toolPermissions': 'Autorisations des outils',
'settings.agents.page.field.agentName': 'Nom de l\'agent', 'settings.agents.page.field.agentName': 'Nom de l\'agent',
'settings.agents.page.field.agentNamePlaceholder': 'nom-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.permissionPlaceholder': 'Autorisation...',
'settings.agents.page.permissions.patternPlaceholder': 'Modèle (e.g. *)', 'settings.agents.page.permissions.patternPlaceholder': 'Modèle (e.g. *)',
'settings.page.behavior.title': 'Comportement', '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.title': 'Comportement',
'settings.behavior.page.warning.title': 'Les règles globales sont combinées avec les règles du projet', '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.', '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.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.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.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.password': 'Mot de passe de l\'interface utilisateur du bureau',
'settings.openchamber.desktopPassword.field.passwordPlaceholder': 'Aucun mot de passe requis', '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.', '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.section.playbackAndSummary': 'Lecture',
'settings.voice.page.field.provider': 'Fournisseur', 'settings.voice.page.field.provider': 'Fournisseur',
'settings.voice.page.provider.browser': 'Navigateur', 'settings.voice.page.provider.browser': 'Navigateur',
'settings.voice.page.provider.openai': 'OpenAI',
'settings.voice.page.provider.custom': 'Personnalisé', 'settings.voice.page.provider.custom': 'Personnalisé',
'settings.voice.page.provider.say': 'Dire', 'settings.voice.page.provider.say': 'Dire',
'settings.voice.page.provider.server': 'Serveur', '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.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.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.colorMode': 'Mode couleur',
'settings.openchamber.visual.section.colorModeAndTheme': 'Mode couleur et thème',
'settings.openchamber.visual.section.localization': 'Localisation', 'settings.openchamber.visual.section.localization': 'Localisation',
'settings.openchamber.visual.section.spacingAndLayout': 'Espacement et disposition', 'settings.openchamber.visual.section.spacingAndLayout': 'Espacement et disposition',
'settings.openchamber.visual.section.densityAndType': 'Densité et typographie',
'settings.openchamber.visual.section.appInstall': 'Installation de l’app',
'settings.openchamber.visual.section.navigation': 'Navigation', 'settings.openchamber.visual.section.navigation': 'Navigation',
'settings.openchamber.visual.section.chatRenderMode': 'Mode de rendu du chat', 'settings.openchamber.visual.section.chatRenderMode': 'Mode de rendu du chat',
'settings.openchamber.visual.section.chatRenderModeAria': '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.messageStreamTransport': 'Transport de flux de messages',
'settings.openchamber.visual.section.activityDefault': 'Activité par défaut', 'settings.openchamber.visual.section.activityDefault': 'Activité par défaut',
'settings.openchamber.visual.section.activityDefaultAria': 'Mode d\'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.sessionAssistance': 'Assistance de session',
'settings.openchamber.visual.section.reasoning': 'Raisonnement', 'settings.openchamber.visual.section.reasoning': 'Raisonnement',
'settings.openchamber.visual.section.messageAppearance': 'Apparence des messages', '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.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 l’application, la plate-forme et le runtime sont collectés – aucune donnée personnelle ni code.', '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 l’application, 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': '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': 'Clair',
'settings.openchamber.visual.option.themeMode.light.description': 'Toujours utiliser l’apparence claire',
'settings.openchamber.visual.option.themeMode.dark': 'Sombre', 'settings.openchamber.visual.option.themeMode.dark': 'Sombre',
'settings.openchamber.visual.option.themeMode.dark.description': 'Toujours utiliser l’apparence sombre',
'settings.openchamber.visual.option.diffLayout.dynamic.label': 'Dynamique', '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.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é', '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.aria': 'Rechercher dans les paramètres',
'settings.view.search.clear': 'Effacer la recherche des paramètres', 'settings.view.search.clear': 'Effacer la recherche des paramètres',
'settings.view.search.noResults': 'Aucun paramètre correspondant', '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.page.about.title': 'À propos',
'settings.openchamber.tunnel.notAvailable.dependencyNotFound': '{dependency} est introuvable.', 'settings.openchamber.tunnel.notAvailable.dependencyNotFound': '{dependency} est introuvable.',
'settings.magicPrompts.sidebar.item.sessionExplore': 'Tour du codebase', 'settings.magicPrompts.sidebar.item.sessionExplore': 'Tour du codebase',
@@ -47,6 +47,10 @@ export const settingsDict = {
'settings.view.search.aria': '設定を検索', 'settings.view.search.aria': '設定を検索',
'settings.view.search.clear': '設定検索をクリア', 'settings.view.search.clear': '設定検索をクリア',
'settings.view.search.noResults': '該当する設定がありません', '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.projects.title': 'プロジェクト',
'settings.page.remoteInstances.title': 'リモートインスタンス', 'settings.page.remoteInstances.title': 'リモートインスタンス',
'settings.page.providers.title': 'プロバイダー', 'settings.page.providers.title': 'プロバイダー',
@@ -59,13 +63,22 @@ export const settingsDict = {
'settings.page.skillsCatalog.title': 'スキルカタログ', 'settings.page.skillsCatalog.title': 'スキルカタログ',
'settings.page.git.title': 'Git', 'settings.page.git.title': 'Git',
'settings.page.appearance.title': '外観', 'settings.page.appearance.title': '外観',
'settings.page.appearance.description': 'OpenChamber の見た目と操作感をカスタマイズします。',
'settings.page.chat.title': 'チャット', 'settings.page.chat.title': 'チャット',
'settings.page.chat.description': 'メッセージとツールの表示方法を設定します。',
'settings.page.shortcuts.title': 'ショートカット', 'settings.page.shortcuts.title': 'ショートカット',
'settings.page.shortcuts.description': 'キーボードショートカットをカスタマイズします。',
'settings.page.general.title': '一般',
'settings.page.general.description': 'アプリの起動、セキュリティ、接続、プライバシー。',
'settings.page.sessions.title': 'セッション', 'settings.page.sessions.title': 'セッション',
'settings.page.sessions.description': 'セッションのデフォルトと保持期間を設定します。',
'settings.page.magicPrompts.title': 'マジックプロンプト', 'settings.page.magicPrompts.title': 'マジックプロンプト',
'settings.page.notifications.title': '通知', 'settings.page.notifications.title': '通知',
'settings.page.notifications.description': '通知のタイミングと方法を選択します。',
'settings.page.voice.title': '音声', '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.about.title': '概要',
'settings.page.snippets.title': 'スニペット', 'settings.page.snippets.title': 'スニペット',
'settings.snippets.sidebar.title': 'スニペット', 'settings.snippets.sidebar.title': 'スニペット',
@@ -123,7 +136,7 @@ export const settingsDict = {
'settings.promptTemplates.page.toast.created': 'Template を作成しました', 'settings.promptTemplates.page.toast.created': 'Template を作成しました',
'settings.promptTemplates.page.toast.createFailed': 'Template の作成に失敗しました', 'settings.promptTemplates.page.toast.createFailed': 'Template の作成に失敗しました',
'settings.promptTemplates.page.toast.saveUnexpectedError': '保存中に予期しないエラーが発生しました', 'settings.promptTemplates.page.toast.saveUnexpectedError': '保存中に予期しないエラーが発生しました',
'settings.openchamber.tunnel.title': 'リモートトンネル', 'settings.openchamber.tunnel.title': '外部トンネル',
'settings.openchamber.tunnel.description': 'クイックリンクまたは自身で管理するリモート Cloudflare Tunnel を使用して安全なリモートアクセスを設定します。', 'settings.openchamber.tunnel.description': 'クイックリンクまたは自身で管理するリモート Cloudflare Tunnel を使用して安全なリモートアクセスを設定します。',
'settings.openchamber.tunnel.note.serverSideEnforced': '安全な Tunnel アクセスはサーバー側で強制されます。', 'settings.openchamber.tunnel.note.serverSideEnforced': '安全な Tunnel アクセスはサーバー側で強制されます。',
'settings.openchamber.tunnel.note.connectLinksOneTime': '接続リンクは一度きりで、Tunnel が停止するか接続リンクの TTL が期限切れになると無効になります。', 'settings.openchamber.tunnel.note.connectLinksOneTime': '接続リンクは一度きりで、Tunnel が停止するか接続リンクの TTL が期限切れになると無効になります。',
@@ -399,6 +412,7 @@ export const settingsDict = {
'settings.remoteInstances.page.logsDialog.selectedInstanceFallback': '選択したインスタンス', 'settings.remoteInstances.page.logsDialog.selectedInstanceFallback': '選択したインスタンス',
'settings.remoteInstances.page.patternDialog.descriptionWithHost': '{host} には具体的な接続先が必要です。', 'settings.remoteInstances.page.patternDialog.descriptionWithHost': '{host} には具体的な接続先が必要です。',
'settings.remoteInstances.page.patternDialog.description': '接続先を入力してください。', 'settings.remoteInstances.page.patternDialog.description': '接続先を入力してください。',
'settings.common.infoAria': '詳細情報',
'settings.common.actions.cancel': 'キャンセル', 'settings.common.actions.cancel': 'キャンセル',
'settings.common.actions.create': '作成', 'settings.common.actions.create': '作成',
'settings.common.actions.delete': '削除', 'settings.common.actions.delete': '削除',
@@ -409,6 +423,8 @@ export const settingsDict = {
'settings.common.actions.copyAll': 'すべてコピー', 'settings.common.actions.copyAll': 'すべてコピー',
'settings.common.actions.clear': 'クリア', 'settings.common.actions.clear': 'クリア',
'settings.common.actions.saving': '保存中...', 'settings.common.actions.saving': '保存中...',
'settings.common.status.saved': '保存済み',
'settings.common.status.saveFailed': '保存に失敗しました',
'settings.common.actions.saveChanges': '変更を保存', 'settings.common.actions.saveChanges': '変更を保存',
'settings.common.scope.global': 'グローバル', 'settings.common.scope.global': 'グローバル',
'settings.common.scope.project': 'プロジェクト', 'settings.common.scope.project': 'プロジェクト',
@@ -493,6 +509,33 @@ export const settingsDict = {
'settings.agents.page.section.identityRole': '識別情報と役割', 'settings.agents.page.section.identityRole': '識別情報と役割',
'settings.agents.page.section.modelParameters': 'モデルとパラメーター', 'settings.agents.page.section.modelParameters': 'モデルとパラメーター',
'settings.agents.page.section.systemPrompt': 'システムプロンプト', '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.section.toolPermissions': 'ツール権限',
'settings.agents.page.field.agentName': 'Agent 名', 'settings.agents.page.field.agentName': 'Agent 名',
'settings.agents.page.field.agentNamePlaceholder': 'agent-name', 'settings.agents.page.field.agentNamePlaceholder': 'agent-name',
@@ -528,6 +571,7 @@ export const settingsDict = {
'settings.agents.page.permissions.permissionPlaceholder': '権限...', 'settings.agents.page.permissions.permissionPlaceholder': '権限...',
'settings.agents.page.permissions.patternPlaceholder': 'パターン(例: *)', 'settings.agents.page.permissions.patternPlaceholder': 'パターン(例: *)',
'settings.page.behavior.title': '動作', 'settings.page.behavior.title': '動作',
'settings.page.behavior.description': 'エージェントの応答の仕方をガイドします。',
'settings.behavior.page.title': '動作', 'settings.behavior.page.title': '動作',
'settings.behavior.page.warning.title': 'グローバルルールはプロジェクトルールと組み合わされます', 'settings.behavior.page.warning.title': 'グローバルルールはプロジェクトルールと組み合わされます',
'settings.behavior.page.warning.description': 'ここでの変更は {path} を更新します。OpenCode はプロジェクトレベルの AGENTS.md ルールも存在する場合は含めます。', '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.allowLanAccessDescription': 'アプリを再起動して、Wi-Fi 上の他のデバイスから開けるようにします。',
'settings.openchamber.desktopNetwork.field.warning': '警告: 有効にすると、アプリは同じローカルネットワーク上の誰からもアクセス可能になります。', 'settings.openchamber.desktopNetwork.field.warning': '警告: 有効にすると、アプリは同じローカルネットワーク上の誰からもアクセス可能になります。',
'settings.openchamber.desktopNetwork.field.passwordRequiredWarning': 'LAN アクセスには Desktop UI パスワードが必要です。設定されるまで、Desktop アプリはローカルのみで起動します。', '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.password': 'Desktop UI パスワード',
'settings.openchamber.desktopPassword.field.passwordPlaceholder': 'パスワード不要', 'settings.openchamber.desktopPassword.field.passwordPlaceholder': 'パスワード不要',
'settings.openchamber.desktopPassword.field.passwordDescription': 'OpenChamber は再起動後、およびログイン Session の有効期限後(12時間、または「このデバイスを信頼」の場合は7日)に確認を求めます。空のままにするとログインが無効になります。', 'settings.openchamber.desktopPassword.field.passwordDescription': 'OpenChamber は再起動後、およびログイン Session の有効期限後(12時間、または「このデバイスを信頼」の場合は7日)に確認を求めます。空のままにするとログインが無効になります。',
@@ -1618,6 +1664,7 @@ export const settingsDict = {
'settings.voice.page.section.playbackAndSummary': '再生', 'settings.voice.page.section.playbackAndSummary': '再生',
'settings.voice.page.field.provider': 'プロバイダー', 'settings.voice.page.field.provider': 'プロバイダー',
'settings.voice.page.provider.browser': 'ブラウザ', 'settings.voice.page.provider.browser': 'ブラウザ',
'settings.voice.page.provider.openai': 'OpenAI',
'settings.voice.page.provider.custom': 'カスタム', 'settings.voice.page.provider.custom': 'カスタム',
'settings.voice.page.provider.say': 'Say', 'settings.voice.page.provider.say': 'Say',
'settings.voice.page.provider.server': 'サーバー', 'settings.voice.page.provider.server': 'サーバー',
@@ -1697,18 +1744,24 @@ export const settingsDict = {
'settings.voice.page.field.ttsInputModeRaw': '生 Markdown', 'settings.voice.page.field.ttsInputModeRaw': '生 Markdown',
'settings.voice.page.field.ttsInputModeSummarized': '要約', 'settings.voice.page.field.ttsInputModeSummarized': '要約',
'settings.openchamber.visual.section.colorMode': 'カラーモード', 'settings.openchamber.visual.section.colorMode': 'カラーモード',
'settings.openchamber.visual.section.colorModeAndTheme': 'カラーモードとテーマ',
'settings.openchamber.visual.section.mobileLayout': 'モバイルレイアウト', 'settings.openchamber.visual.section.mobileLayout': 'モバイルレイアウト',
'settings.openchamber.visual.option.mobileLayout.default': '旧', 'settings.openchamber.visual.option.mobileLayout.default': '旧',
'settings.openchamber.visual.option.mobileLayout.new': '新', 'settings.openchamber.visual.option.mobileLayout.new': '新',
'settings.openchamber.visual.section.localization': 'ローカライゼーション', 'settings.openchamber.visual.section.localization': 'ローカライゼーション',
'settings.openchamber.visual.section.spacingAndLayout': '間隔とレイアウト', 'settings.openchamber.visual.section.spacingAndLayout': '間隔とレイアウト',
'settings.openchamber.visual.section.densityAndType': '密度と書体',
'settings.openchamber.visual.section.appInstall': 'アプリのインストール',
'settings.openchamber.visual.section.navigation': 'ナビゲーション', 'settings.openchamber.visual.section.navigation': 'ナビゲーション',
'settings.openchamber.visual.section.chatRenderMode': 'Chat 表示モード', 'settings.openchamber.visual.section.chatRenderMode': 'Chat 表示モード',
'settings.openchamber.visual.section.chatRenderModeAria': '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.messageStreamTransport': 'メッセージストリーム転送',
'settings.openchamber.visual.section.activityDefault': 'アクティビティデフォルト', 'settings.openchamber.visual.section.activityDefault': 'アクティビティデフォルト',
'settings.openchamber.visual.section.activityDefaultAria': 'アクティビティデフォルトモード', 'settings.openchamber.visual.section.activityDefaultAria': 'アクティビティデフォルトモード',
'settings.openchamber.visual.section.showToolsOpenedByDefault': 'デフォルトで開くツールを表示:', 'settings.openchamber.visual.section.showToolsOpenedByDefault': 'デフォルトで開くツールを表示',
'settings.openchamber.visual.section.sessionAssistance': 'セッション支援', 'settings.openchamber.visual.section.sessionAssistance': 'セッション支援',
'settings.openchamber.visual.section.reasoning': '推論', 'settings.openchamber.visual.section.reasoning': '推論',
'settings.openchamber.visual.section.messageAppearance': 'メッセージの外観', 'settings.openchamber.visual.section.messageAppearance': 'メッセージの外観',
@@ -1839,8 +1892,11 @@ export const settingsDict = {
'settings.openchamber.visual.field.sendAnonymousUsageReports': '匿名使用状況レポートを送信', 'settings.openchamber.visual.field.sendAnonymousUsageReports': '匿名使用状況レポートを送信',
'settings.openchamber.visual.field.sendAnonymousUsageReportsHint': 'どのアプリバージョンがアクティブに使用されているかを把握し、改善の優先順位を決めるのに役立ちます。収集されるのはアプリバージョン、プラットフォーム、ランタイムのみで、個人データやコードは収集されません。', 'settings.openchamber.visual.field.sendAnonymousUsageReportsHint': 'どのアプリバージョンがアクティブに使用されているかを把握し、改善の優先順位を決めるのに役立ちます。収集されるのはアプリバージョン、プラットフォーム、ランタイムのみで、個人データやコードは収集されません。',
'settings.openchamber.visual.option.themeMode.system': 'システム', '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': 'ライト',
'settings.openchamber.visual.option.themeMode.light.description': '常にライト表示を使用',
'settings.openchamber.visual.option.themeMode.dark': 'ダーク', '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.label': '動的',
'settings.openchamber.visual.option.diffLayout.dynamic.description': '新規はインライン、変更は並列表示。', 'settings.openchamber.visual.option.diffLayout.dynamic.description': '新規はインライン、変更は並列表示。',
'settings.openchamber.visual.option.diffLayout.inline.label': '常にインライン', 'settings.openchamber.visual.option.diffLayout.inline.label': '常にインライン',
@@ -47,6 +47,10 @@ export const settingsDict = {
'settings.view.search.aria': '설정 검색', 'settings.view.search.aria': '설정 검색',
'settings.view.search.clear': '설정 검색 지우기', 'settings.view.search.clear': '설정 검색 지우기',
'settings.view.search.noResults': '일치하는 설정이 없습니다', '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.projects.title': '프로젝트',
'settings.page.remoteInstances.title': '원격 인스턴스', 'settings.page.remoteInstances.title': '원격 인스턴스',
'settings.page.providers.title': '프로바이더', 'settings.page.providers.title': '프로바이더',
@@ -59,13 +63,22 @@ export const settingsDict = {
'settings.page.skillsCatalog.title': '스킬 카탈로그', 'settings.page.skillsCatalog.title': '스킬 카탈로그',
'settings.page.git.title': 'Git', 'settings.page.git.title': 'Git',
'settings.page.appearance.title': '외관', 'settings.page.appearance.title': '외관',
'settings.page.appearance.description': 'OpenChamber의 모양과 느낌을 맞춤 설정하세요.',
'settings.page.chat.title': '채팅', 'settings.page.chat.title': '채팅',
'settings.page.chat.description': '메시지와 도구가 표시되는 방식을 구성합니다.',
'settings.page.shortcuts.title': '단축키', 'settings.page.shortcuts.title': '단축키',
'settings.page.shortcuts.description': '키보드 단축키를 맞춤 설정합니다.',
'settings.page.general.title': '일반',
'settings.page.general.description': '앱 시작, 보안, 연결 및 개인정보.',
'settings.page.sessions.title': '세션', 'settings.page.sessions.title': '세션',
'settings.page.sessions.description': '세션 기본값과 보관 기간을 설정합니다.',
'settings.page.magicPrompts.title': '매직 프롬프트', 'settings.page.magicPrompts.title': '매직 프롬프트',
'settings.page.notifications.title': '알림', 'settings.page.notifications.title': '알림',
'settings.page.notifications.description': '알림을 받을 시점과 방식을 선택합니다.',
'settings.page.voice.title': '음성', '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.about.title': '정보',
'settings.page.snippets.title': '스니펫', 'settings.page.snippets.title': '스니펫',
'settings.snippets.sidebar.title': '스니펫', 'settings.snippets.sidebar.title': '스니펫',
@@ -90,7 +103,7 @@ export const settingsDict = {
'settings.snippets.page.toast.contentRequired': '스니펫 내용은 필수입니다', 'settings.snippets.page.toast.contentRequired': '스니펫 내용은 필수입니다',
'settings.snippets.page.toast.saveFailed': '스니펫 저장 실패', 'settings.snippets.page.toast.saveFailed': '스니펫 저장 실패',
'settings.snippets.page.toast.saved': '스니펫이 저장되었습니다', 'settings.snippets.page.toast.saved': '스니펫이 저장되었습니다',
'settings.openchamber.tunnel.title': '원격 터널', 'settings.openchamber.tunnel.title': '외부 터널',
'settings.openchamber.tunnel.description': '퀵 링크나 직접 관리하는 Cloudflare 터널로 안전한 원격 접속을 설정하세요.', 'settings.openchamber.tunnel.description': '퀵 링크나 직접 관리하는 Cloudflare 터널로 안전한 원격 접속을 설정하세요.',
'settings.openchamber.tunnel.note.serverSideEnforced': '보안 터널 접근은 서버 측에서 강제됩니다.', 'settings.openchamber.tunnel.note.serverSideEnforced': '보안 터널 접근은 서버 측에서 강제됩니다.',
'settings.openchamber.tunnel.note.connectLinksOneTime': '접속 링크는 일회용이며, 터널이 중지되거나 링크 TTL이 만료되면 폐기됩니다.', 'settings.openchamber.tunnel.note.connectLinksOneTime': '접속 링크는 일회용이며, 터널이 중지되거나 링크 TTL이 만료되면 폐기됩니다.',
@@ -366,6 +379,7 @@ export const settingsDict = {
'settings.remoteInstances.page.logsDialog.selectedInstanceFallback': '선택한 인스턴스', 'settings.remoteInstances.page.logsDialog.selectedInstanceFallback': '선택한 인스턴스',
'settings.remoteInstances.page.patternDialog.descriptionWithHost': '{host}에는 구체적인 대상이 필요합니다.', 'settings.remoteInstances.page.patternDialog.descriptionWithHost': '{host}에는 구체적인 대상이 필요합니다.',
'settings.remoteInstances.page.patternDialog.description': '대상을 입력하세요.', 'settings.remoteInstances.page.patternDialog.description': '대상을 입력하세요.',
'settings.common.infoAria': '자세한 정보',
'settings.common.actions.cancel': '취소', 'settings.common.actions.cancel': '취소',
'settings.common.actions.create': '생성', 'settings.common.actions.create': '생성',
'settings.common.actions.delete': '삭제', 'settings.common.actions.delete': '삭제',
@@ -376,6 +390,8 @@ export const settingsDict = {
'settings.common.actions.copyAll': '모두 복사', 'settings.common.actions.copyAll': '모두 복사',
'settings.common.actions.clear': '지우기', 'settings.common.actions.clear': '지우기',
'settings.common.actions.saving': '저장 중...', 'settings.common.actions.saving': '저장 중...',
'settings.common.status.saved': '저장됨',
'settings.common.status.saveFailed': '저장 실패',
'settings.common.actions.saveChanges': '변경 사항 저장', 'settings.common.actions.saveChanges': '변경 사항 저장',
'settings.common.scope.global': '전역', 'settings.common.scope.global': '전역',
'settings.common.scope.project': '프로젝트', 'settings.common.scope.project': '프로젝트',
@@ -460,6 +476,33 @@ export const settingsDict = {
'settings.agents.page.section.identityRole': '정체성 및 역할', 'settings.agents.page.section.identityRole': '정체성 및 역할',
'settings.agents.page.section.modelParameters': '모델 및 파라미터', 'settings.agents.page.section.modelParameters': '모델 및 파라미터',
'settings.agents.page.section.systemPrompt': '시스템 프롬프트', '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.section.toolPermissions': '도구 권한',
'settings.agents.page.field.agentName': '에이전트 이름', 'settings.agents.page.field.agentName': '에이전트 이름',
'settings.agents.page.field.agentNamePlaceholder': 'agent-name (예: code-reviewer)', '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.permissionPlaceholder': '권한...',
'settings.agents.page.permissions.patternPlaceholder': '패턴(예: *)', 'settings.agents.page.permissions.patternPlaceholder': '패턴(예: *)',
'settings.page.behavior.title': '동작', 'settings.page.behavior.title': '동작',
'settings.page.behavior.description': '에이전트의 응답 방식을 안내합니다.',
'settings.behavior.page.title': '동작', 'settings.behavior.page.title': '동작',
'settings.behavior.page.warning.title': '전역 규칙은 프로젝트 규칙과 함께 사용됩니다', 'settings.behavior.page.warning.title': '전역 규칙은 프로젝트 규칙과 함께 사용됩니다',
'settings.behavior.page.warning.description': '여기에서 변경한 내용은 {path}를 업데이트합니다. 프로젝트 수준의 AGENTS.md가 있으면 OpenCode도 함께 포함합니다.', '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.allowLanAccessDescription': '휴대폰, 태블릿, Wi-Fi의 다른 컴퓨터에서 열 수 있도록 앱을 다시 시작합니다.',
'settings.openchamber.desktopNetwork.field.warning': '경고: 활성화된 동안 같은 로컬 네트워크의 누구나 앱에 접속할 수 있습니다.', 'settings.openchamber.desktopNetwork.field.warning': '경고: 활성화된 동안 같은 로컬 네트워크의 누구나 앱에 접속할 수 있습니다.',
'settings.openchamber.desktopNetwork.field.passwordRequiredWarning': 'LAN 접속에는 Desktop UI 비밀번호가 필요합니다. 설정하기 전까지 desktop 앱은 로컬 전용으로 시작됩니다.', '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.password': 'Desktop UI 비밀번호',
'settings.openchamber.desktopPassword.field.passwordPlaceholder': '비밀번호 필요 없음', 'settings.openchamber.desktopPassword.field.passwordPlaceholder': '비밀번호 필요 없음',
'settings.openchamber.desktopPassword.field.passwordDescription': 'OpenChamber는 다시 시작 후 비밀번호를 요청하고, 이후 로그인 세션이 만료되면 다시 요청합니다. 기본 12시간, 이 디바이스 신뢰 선택 시 7일입니다. 로그인을 끄려면 비워 두세요.', 'settings.openchamber.desktopPassword.field.passwordDescription': 'OpenChamber는 다시 시작 후 비밀번호를 요청하고, 이후 로그인 세션이 만료되면 다시 요청합니다. 기본 12시간, 이 디바이스 신뢰 선택 시 7일입니다. 로그인을 끄려면 비워 두세요.',
@@ -1585,6 +1631,7 @@ export const settingsDict = {
'settings.voice.page.section.playbackAndSummary': '재생', 'settings.voice.page.section.playbackAndSummary': '재생',
'settings.voice.page.field.provider': '프로바이더', 'settings.voice.page.field.provider': '프로바이더',
'settings.voice.page.provider.browser': '브라우저', 'settings.voice.page.provider.browser': '브라우저',
'settings.voice.page.provider.openai': 'OpenAI',
'settings.voice.page.provider.custom': '사용자 정의', 'settings.voice.page.provider.custom': '사용자 정의',
'settings.voice.page.provider.say': 'Say', 'settings.voice.page.provider.say': 'Say',
'settings.voice.page.provider.server': '서버', 'settings.voice.page.provider.server': '서버',
@@ -1664,18 +1711,24 @@ export const settingsDict = {
'settings.voice.page.field.ttsInputModeRaw': '원본 Markdown', 'settings.voice.page.field.ttsInputModeRaw': '원본 Markdown',
'settings.voice.page.field.ttsInputModeSummarized': '요약', 'settings.voice.page.field.ttsInputModeSummarized': '요약',
'settings.openchamber.visual.section.colorMode': '색상 모드', 'settings.openchamber.visual.section.colorMode': '색상 모드',
'settings.openchamber.visual.section.colorModeAndTheme': '색상 모드 및 테마',
'settings.openchamber.visual.section.mobileLayout': '모바일 레이아웃', 'settings.openchamber.visual.section.mobileLayout': '모바일 레이아웃',
'settings.openchamber.visual.option.mobileLayout.default': '이전', 'settings.openchamber.visual.option.mobileLayout.default': '이전',
'settings.openchamber.visual.option.mobileLayout.new': '새로움', 'settings.openchamber.visual.option.mobileLayout.new': '새로움',
'settings.openchamber.visual.section.localization': '지역화', 'settings.openchamber.visual.section.localization': '지역화',
'settings.openchamber.visual.section.spacingAndLayout': '간격 및 레이아웃', 'settings.openchamber.visual.section.spacingAndLayout': '간격 및 레이아웃',
'settings.openchamber.visual.section.densityAndType': '밀도 및 서체',
'settings.openchamber.visual.section.appInstall': '앱 설치',
'settings.openchamber.visual.section.navigation': '내비게이션', 'settings.openchamber.visual.section.navigation': '내비게이션',
'settings.openchamber.visual.section.chatRenderMode': '채팅 렌더링 모드', 'settings.openchamber.visual.section.chatRenderMode': '채팅 렌더링 모드',
'settings.openchamber.visual.section.chatRenderModeAria': '채팅 렌더링 모드', '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.messageStreamTransport': '메시지 스트림 전송',
'settings.openchamber.visual.section.activityDefault': 'Activity 기본값', 'settings.openchamber.visual.section.activityDefault': 'Activity 기본값',
'settings.openchamber.visual.section.activityDefaultAria': '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.sessionAssistance': '세션 지원',
'settings.openchamber.visual.section.reasoning': '추론', 'settings.openchamber.visual.section.reasoning': '추론',
'settings.openchamber.visual.section.messageAppearance': '메시지 모양', 'settings.openchamber.visual.section.messageAppearance': '메시지 모양',
@@ -1806,8 +1859,11 @@ export const settingsDict = {
'settings.openchamber.visual.field.sendAnonymousUsageReports': '익명 사용량 보고서 보내기', 'settings.openchamber.visual.field.sendAnonymousUsageReports': '익명 사용량 보고서 보내기',
'settings.openchamber.visual.field.sendAnonymousUsageReportsHint': '활성 사용 앱 버전을 파악해 개선 우선순위를 정하는 데 도움이 됩니다. 앱 버전, 플랫폼, 런타임만 수집되며 개인 데이터나 코드는 수집되지 않습니다.', 'settings.openchamber.visual.field.sendAnonymousUsageReportsHint': '활성 사용 앱 버전을 파악해 개선 우선순위를 정하는 데 도움이 됩니다. 앱 버전, 플랫폼, 런타임만 수집되며 개인 데이터나 코드는 수집되지 않습니다.',
'settings.openchamber.visual.option.themeMode.system': 'System', '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': 'Light',
'settings.openchamber.visual.option.themeMode.light.description': '항상 라이트 모양 사용',
'settings.openchamber.visual.option.themeMode.dark': 'Dark', '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.label': '동적',
'settings.openchamber.visual.option.diffLayout.dynamic.description': '새 항목은 inline, 수정 항목은 side-by-side로 표시합니다.', 'settings.openchamber.visual.option.diffLayout.dynamic.description': '새 항목은 inline, 수정 항목은 side-by-side로 표시합니다.',
'settings.openchamber.visual.option.diffLayout.inline.label': '항상 inline', '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.identityRole': 'Tożsamość i Rola',
'settings.agents.page.section.modelParameters': 'Model i Parametry', 'settings.agents.page.section.modelParameters': 'Model i Parametry',
'settings.agents.page.section.systemPrompt': 'System Prompt', '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.section.toolPermissions': 'Uprawnienia do narzędzi',
'settings.agents.page.subtitle.edit': 'Edytuj ustawienia agenta', 'settings.agents.page.subtitle.edit': 'Edytuj ustawienia agenta',
'settings.agents.page.subtitle.new': 'Skonfiguruj nową personę asystenta', '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.noFiller': 'Bez zbędnych treści',
'settings.behavior.page.responseStyle.option.pushback': 'Krytyczne podejście', 'settings.behavior.page.responseStyle.option.pushback': 'Krytyczne podejście',
'settings.behavior.page.responseStyle.option.warmPeer': 'Przyjazny kolega', '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.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.responseStyle': 'Styl odpowiedzi',
'settings.behavior.page.section.systemPrompt': 'Globalny AGENTS.md', '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.renameFailed': 'Nie udało się zmienić nazwy polecenia',
'settings.commands.sidebar.toast.resetFailed': 'Nie udało się zresetować polecenia', 'settings.commands.sidebar.toast.resetFailed': 'Nie udało się zresetować polecenia',
'settings.commands.sidebar.total': 'Suma: {count}', 'settings.commands.sidebar.total': 'Suma: {count}',
'settings.common.infoAria': 'Więcej informacji',
'settings.common.actions.cancel': 'Anuluj', 'settings.common.actions.cancel': 'Anuluj',
'settings.common.actions.clear': 'Wyczyść', 'settings.common.actions.clear': 'Wyczyść',
'settings.common.actions.copyAll': 'Kopiuj wszystko', 'settings.common.actions.copyAll': 'Kopiuj wszystko',
@@ -184,6 +212,8 @@ export const settingsDict = {
'settings.common.actions.reset': 'Zresetuj', 'settings.common.actions.reset': 'Zresetuj',
'settings.common.actions.saveChanges': 'Zapisz zmiany', 'settings.common.actions.saveChanges': 'Zapisz zmiany',
'settings.common.actions.saving': 'Zapisywanie...', '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.modified': 'Zmodyfikowano',
'settings.common.badge.new': 'Nowy', 'settings.common.badge.new': 'Nowy',
'settings.common.field.description': 'Opis', '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.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.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.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.password': 'Hasło UI pulpitu',
'settings.openchamber.desktopPassword.field.passwordPlaceholder': 'Hasło nie jest wymagane', '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.', '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.noExpiry': 'Bez wygaśnięcia',
'settings.openchamber.tunnel.state.revoked': 'Unieważniony', 'settings.openchamber.tunnel.state.revoked': 'Unieważniony',
'settings.openchamber.tunnel.state.tunnelReady': 'Tunel gotowy', '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.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.checkAvailabilityFailed': 'Nie udało się sprawdzić dostępności tunelu',
'settings.openchamber.tunnel.toast.connectLinkCopied': 'Link połączenia został skopiowany', '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.description': 'Uwzględniaj ustawienie obrotu urządzenia.',
'settings.openchamber.visual.option.pwaOrientation.system.label': 'Zgodnie z systemem', 'settings.openchamber.visual.option.pwaOrientation.system.label': 'Zgodnie z systemem',
'settings.openchamber.visual.option.themeMode.dark': 'Ciemny', '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': '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': '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.description': 'Pokazuj czas jako 02:15 PM.',
'settings.openchamber.visual.option.timeFormat.12h.label': '12-godzinny', 'settings.openchamber.visual.option.timeFormat.12h.label': '12-godzinny',
'settings.openchamber.visual.option.timeFormat.24h.description': 'Pokazuj czas jako 14:15.', '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.activityDefaultAria': 'Domyślny tryb aktywności',
'settings.openchamber.visual.section.chatRenderMode': 'Tryb renderowania czatu', 'settings.openchamber.visual.section.chatRenderMode': 'Tryb renderowania czatu',
'settings.openchamber.visual.section.chatRenderModeAria': '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.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.section.mobileLayout': 'Układ mobilny',
'settings.openchamber.visual.option.mobileLayout.default': 'Poprzedni', 'settings.openchamber.visual.option.mobileLayout.default': 'Poprzedni',
'settings.openchamber.visual.option.mobileLayout.new': 'Nowy', '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.messageStreamTransport': 'Transport strumienia wiadomości',
'settings.openchamber.visual.section.navigation': 'Nawigacja', 'settings.openchamber.visual.section.navigation': 'Nawigacja',
'settings.openchamber.visual.section.privacy': 'Prywatność', '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.sessionAssistance': 'Wsparcie sesji',
'settings.openchamber.visual.section.reasoning': 'Rozumowanie', 'settings.openchamber.visual.section.reasoning': 'Rozumowanie',
'settings.openchamber.visual.section.messageAppearance': 'Wygląd wiadomości', 'settings.openchamber.visual.section.messageAppearance': 'Wygląd wiadomości',
'settings.openchamber.visual.section.toolsAndFiles': 'Narzędzia i pliki', 'settings.openchamber.visual.section.toolsAndFiles': 'Narzędzia i pliki',
'settings.openchamber.visual.section.composer': 'Pole wiadomości', 'settings.openchamber.visual.section.composer': 'Pole wiadomości',
'settings.openchamber.visual.section.spacingAndLayout': 'Odstępy i układ', '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.userMessageRendering': 'Renderowanie wiadomości użytkownika',
'settings.openchamber.visual.section.userMessageRenderingAria': 'Tryb renderowania wiadomości użytkownika', 'settings.openchamber.visual.section.userMessageRenderingAria': 'Tryb renderowania wiadomości użytkownika',
'settings.openchamber.worktrees.list.deleteWorktreeAria': 'Usuń worktree {name}', '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.openchamber.worktrees.state.selectProject': 'Wybierz projekt, aby zarządzać worktree.',
'settings.page.agents.title': 'Agenci', 'settings.page.agents.title': 'Agenci',
'settings.page.appearance.title': 'Wygląd', 'settings.page.appearance.title': 'Wygląd',
'settings.page.appearance.description': 'Dostosuj wygląd i odczucie OpenChamber.',
'settings.page.behavior.title': 'Zachowanie', 'settings.page.behavior.title': 'Zachowanie',
'settings.page.behavior.description': 'Kieruj sposobem, w jaki agent odpowiada.',
'settings.page.chat.title': 'Czat', '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.commands.title': 'Polecenia',
'settings.page.git.title': 'Git', 'settings.page.git.title': 'Git',
'settings.page.magicPrompts.title': 'Magiczne Prompty', 'settings.page.magicPrompts.title': 'Magiczne Prompty',
'settings.page.mcp.title': 'MCP', 'settings.page.mcp.title': 'MCP',
'settings.page.plugins.title': 'Plugins', 'settings.page.plugins.title': 'Plugins',
'settings.page.notifications.title': 'Powiadomienia', 'settings.page.notifications.title': 'Powiadomienia',
'settings.page.notifications.description': 'Wybierz, kiedy i jak otrzymywać powiadomienia.',
'settings.page.projects.title': 'Projekty', 'settings.page.projects.title': 'Projekty',
'settings.page.providers.title': 'Dostawcy', 'settings.page.providers.title': 'Dostawcy',
'settings.page.remoteInstances.title': 'Zdalne instancje', '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.title': 'Sesje',
'settings.page.sessions.description': 'Ustaw wartości domyślne i retencję sesji.',
'settings.page.shortcuts.title': 'Skróty', 'settings.page.shortcuts.title': 'Skróty',
'settings.page.shortcuts.description': 'Dostosuj skróty klawiszowe.',
'settings.page.skills.title': 'Umiejętności', 'settings.page.skills.title': 'Umiejętności',
'settings.page.skillsCatalog.title': 'Katalog 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.about.title': 'O aplikacji',
'settings.page.snippets.title': 'Fragmenty', 'settings.page.snippets.title': 'Fragmenty',
'settings.snippets.sidebar.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.promptTemplates.page.toast.saveUnexpectedError': 'Wystąpił nieoczekiwany błąd podczas zapisywania',
'settings.page.usage.title': 'Użycie', 'settings.page.usage.title': 'Użycie',
'settings.page.voice.title': 'Głos', '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.add': 'Dodaj akcję',
'settings.projects.actions.actions.save': 'Zapisz akcje', 'settings.projects.actions.actions.save': 'Zapisz akcje',
'settings.projects.actions.description': 'Polecenia dla projektu wyświetlane w nagłówku obok nazwy projektu.', '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.aria': 'Szukaj ustawień',
'settings.view.search.clear': 'Wyczyść wyszukiwanie ustawień', 'settings.view.search.clear': 'Wyczyść wyszukiwanie ustawień',
'settings.view.search.noResults': 'Brak pasujących 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.badge.beta': 'beta',
'settings.view.home.cards.agents.description': 'Prompty, narzędzia, uprawnienia', 'settings.view.home.cards.agents.description': 'Prompty, narzędzia, uprawnienia',
'settings.view.home.cards.agents.title': 'Agenci', '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.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.preview.voiceLine': 'Cześć! Jestem {voiceName}. Tak brzmi mój głos.',
'settings.voice.page.provider.browser': 'Przeglądarka', 'settings.voice.page.provider.browser': 'Przeglądarka',
'settings.voice.page.provider.openai': 'OpenAI',
'settings.voice.page.provider.custom': 'Własny', 'settings.voice.page.provider.custom': 'Własny',
'settings.voice.page.provider.say': 'Say', 'settings.voice.page.provider.say': 'Say',
'settings.voice.page.provider.server': 'Serwer', 'settings.voice.page.provider.server': 'Serwer',
@@ -47,6 +47,10 @@ export const settingsDict = {
"settings.view.search.aria": "Buscar configurações", "settings.view.search.aria": "Buscar configurações",
"settings.view.search.clear": "Limpar busca de configurações", "settings.view.search.clear": "Limpar busca de configurações",
"settings.view.search.noResults": "Nenhuma configuração encontrada", "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.projects.title": "Projetos",
"settings.page.remoteInstances.title": "Instâncias remotas", "settings.page.remoteInstances.title": "Instâncias remotas",
"settings.page.providers.title": "Provedores", "settings.page.providers.title": "Provedores",
@@ -59,13 +63,22 @@ export const settingsDict = {
"settings.page.skillsCatalog.title": "Catálogo de habilidades", "settings.page.skillsCatalog.title": "Catálogo de habilidades",
"settings.page.git.title": "Git", "settings.page.git.title": "Git",
"settings.page.appearance.title": "Aparência", "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.title": "Chat",
"settings.page.chat.description": "Configure como as mensagens e as ferramentas são exibidas.",
"settings.page.shortcuts.title": "Atalhos", "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.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.magicPrompts.title": "Prompts mágicos",
"settings.page.notifications.title": "Notificações", "settings.page.notifications.title": "Notificações",
"settings.page.notifications.description": "Escolha quando e como receber notificações.",
"settings.page.voice.title": "Voz", "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.about.title": "Sobre",
"settings.page.snippets.title": "Snippets", "settings.page.snippets.title": "Snippets",
"settings.snippets.sidebar.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.contentRequired": "O conteúdo do snippet é obrigatório",
"settings.snippets.page.toast.saveFailed": "Falha ao salvar snippet", "settings.snippets.page.toast.saveFailed": "Falha ao salvar snippet",
"settings.snippets.page.toast.saved": "Snippet salvo", "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.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.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.", "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.logsDialog.selectedInstanceFallback": "Instância selecionada",
"settings.remoteInstances.page.patternDialog.descriptionWithHost": "{host} exige um destino concreto.", "settings.remoteInstances.page.patternDialog.descriptionWithHost": "{host} exige um destino concreto.",
"settings.remoteInstances.page.patternDialog.description": "Digite destino.", "settings.remoteInstances.page.patternDialog.description": "Digite destino.",
"settings.common.infoAria": "Mais informações",
"settings.common.actions.cancel": "Cancelar", "settings.common.actions.cancel": "Cancelar",
"settings.common.actions.create": "Criar", "settings.common.actions.create": "Criar",
"settings.common.actions.delete": "Excluir", "settings.common.actions.delete": "Excluir",
@@ -376,6 +390,8 @@ export const settingsDict = {
"settings.common.actions.copyAll": "Copiar tudo", "settings.common.actions.copyAll": "Copiar tudo",
"settings.common.actions.clear": "Limpar", "settings.common.actions.clear": "Limpar",
"settings.common.actions.saving": "Salvando...", "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.actions.saveChanges": "Salvar alterações",
"settings.common.scope.global": "Global", "settings.common.scope.global": "Global",
"settings.common.scope.project": "Projeto", "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.identityRole": "Identidade e função",
"settings.agents.page.section.modelParameters": "Modelo e parâmetros", "settings.agents.page.section.modelParameters": "Modelo e parâmetros",
"settings.agents.page.section.systemPrompt": "Prompt do sistema", "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.section.toolPermissions": "Permissões de ferramentas",
"settings.agents.page.field.agentName": "Nome do agente", "settings.agents.page.field.agentName": "Nome do agente",
"settings.agents.page.field.agentNamePlaceholder": "nome-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.permissionPlaceholder": "Permissão...",
"settings.agents.page.permissions.patternPlaceholder": "Padrão (ex.: *)", "settings.agents.page.permissions.patternPlaceholder": "Padrão (ex.: *)",
"settings.page.behavior.title": "Comportamento", "settings.page.behavior.title": "Comportamento",
"settings.page.behavior.description": "Oriente como o agente responde.",
"settings.behavior.page.title": "Comportamento", "settings.behavior.page.title": "Comportamento",
"settings.behavior.page.warning.title": "Regras globais são combinadas com regras do projeto", "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.", "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.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.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.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.password": "Senha da UI do desktop",
"settings.openchamber.desktopPassword.field.passwordPlaceholder": "Nenhuma senha obrigatória", "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.", "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.section.playbackAndSummary": "Reprodução",
"settings.voice.page.field.provider": "Provedor", "settings.voice.page.field.provider": "Provedor",
"settings.voice.page.provider.browser": "Navegador", "settings.voice.page.provider.browser": "Navegador",
"settings.voice.page.provider.openai": "OpenAI",
"settings.voice.page.provider.custom": "Personalizado", "settings.voice.page.provider.custom": "Personalizado",
"settings.voice.page.provider.say": "Falar", "settings.voice.page.provider.say": "Falar",
"settings.voice.page.provider.server": "Servidor", "settings.voice.page.provider.server": "Servidor",
@@ -1664,18 +1711,24 @@ export const settingsDict = {
"settings.voice.page.field.ttsInputModeRaw": "Markdown bruto", "settings.voice.page.field.ttsInputModeRaw": "Markdown bruto",
"settings.voice.page.field.ttsInputModeSummarized": "resumido", "settings.voice.page.field.ttsInputModeSummarized": "resumido",
"settings.openchamber.visual.section.colorMode": "Modo de cor", "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.section.mobileLayout": "Layout móvel",
"settings.openchamber.visual.option.mobileLayout.default": "Anterior", "settings.openchamber.visual.option.mobileLayout.default": "Anterior",
"settings.openchamber.visual.option.mobileLayout.new": "Novo", "settings.openchamber.visual.option.mobileLayout.new": "Novo",
"settings.openchamber.visual.section.localization": "Localização", "settings.openchamber.visual.section.localization": "Localização",
"settings.openchamber.visual.section.spacingAndLayout": "Espaçamento e layout", "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.navigation": "Navegação",
"settings.openchamber.visual.section.chatRenderMode": "Modo de renderizado de chat", "settings.openchamber.visual.section.chatRenderMode": "Modo de renderizado de chat",
"settings.openchamber.visual.section.chatRenderModeAria": "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.messageStreamTransport": "Transporte do fluxo de mensagens",
"settings.openchamber.visual.section.activityDefault": "Modo padrão de atividade", "settings.openchamber.visual.section.activityDefault": "Modo padrão de atividade",
"settings.openchamber.visual.section.activityDefaultAria": "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.sessionAssistance": "Assistência da sessão",
"settings.openchamber.visual.section.reasoning": "Raciocínio", "settings.openchamber.visual.section.reasoning": "Raciocínio",
"settings.openchamber.visual.section.messageAppearance": "Aparência das mensagens", "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.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.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": "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": "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": "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.label": "Dinâmico",
"settings.openchamber.visual.option.diffLayout.dynamic.description": "Novo em linha, modificado lado a lado.", "settings.openchamber.visual.option.diffLayout.dynamic.description": "Novo em linha, modificado lado a lado.",
"settings.openchamber.visual.option.diffLayout.inline.label": "Sempre em linha", "settings.openchamber.visual.option.diffLayout.inline.label": "Sempre em linha",
@@ -47,6 +47,10 @@ export const settingsDict = {
"settings.view.search.aria": "Пошук налаштувань", "settings.view.search.aria": "Пошук налаштувань",
"settings.view.search.clear": "Очистити пошук налаштувань", "settings.view.search.clear": "Очистити пошук налаштувань",
"settings.view.search.noResults": "Нічого не знайдено", "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.projects.title": "Проєкти",
"settings.page.remoteInstances.title": "Віддалені інстанси", "settings.page.remoteInstances.title": "Віддалені інстанси",
"settings.page.providers.title": "Провайдери", "settings.page.providers.title": "Провайдери",
@@ -59,13 +63,22 @@ export const settingsDict = {
"settings.page.skillsCatalog.title": "Каталог навичок", "settings.page.skillsCatalog.title": "Каталог навичок",
"settings.page.git.title": "Git", "settings.page.git.title": "Git",
"settings.page.appearance.title": "Зовнішній вигляд", "settings.page.appearance.title": "Зовнішній вигляд",
"settings.page.appearance.description": "Налаштуйте вигляд і відчуття OpenChamber.",
"settings.page.chat.title": "Чат", "settings.page.chat.title": "Чат",
"settings.page.chat.description": "Налаштуйте відображення повідомлень і інструментів.",
"settings.page.shortcuts.title": "Гарячі клавіші", "settings.page.shortcuts.title": "Гарячі клавіші",
"settings.page.shortcuts.description": "Налаштуйте комбінації клавіш.",
"settings.page.general.title": "Загальні",
"settings.page.general.description": "Запуск застосунку, безпека, з'єднання та приватність.",
"settings.page.sessions.title": "Сесії", "settings.page.sessions.title": "Сесії",
"settings.page.sessions.description": "Встановіть значення за замовчуванням і зберігання сесій.",
"settings.page.magicPrompts.title": "Магічні промпти", "settings.page.magicPrompts.title": "Магічні промпти",
"settings.page.notifications.title": "Сповіщення", "settings.page.notifications.title": "Сповіщення",
"settings.page.notifications.description": "Оберіть, коли і як отримувати сповіщення.",
"settings.page.voice.title": "Голос", "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.about.title": "Про застосунок",
"settings.page.snippets.title": "Сніпети", "settings.page.snippets.title": "Сніпети",
"settings.snippets.sidebar.title": "Сніпети", "settings.snippets.sidebar.title": "Сніпети",
@@ -90,7 +103,7 @@ export const settingsDict = {
"settings.snippets.page.toast.contentRequired": "Вміст сніпета обов'язковий", "settings.snippets.page.toast.contentRequired": "Вміст сніпета обов'язковий",
"settings.snippets.page.toast.saveFailed": "Не вдалося зберегти сніпет", "settings.snippets.page.toast.saveFailed": "Не вдалося зберегти сніпет",
"settings.snippets.page.toast.saved": "Сніпет збережено", "settings.snippets.page.toast.saved": "Сніпет збережено",
"settings.openchamber.tunnel.title": "Віддалений тунель", "settings.openchamber.tunnel.title": "Зовнішній тунель",
"settings.openchamber.tunnel.description": "Налаштуйте безпечний віддалений доступ за допомогою швидких посилань або власного керованого тунелю Cloudflare.", "settings.openchamber.tunnel.description": "Налаштуйте безпечний віддалений доступ за допомогою швидких посилань або власного керованого тунелю Cloudflare.",
"settings.openchamber.tunnel.note.serverSideEnforced": "Безпечний доступ до тунелю забезпечується на стороні сервера.", "settings.openchamber.tunnel.note.serverSideEnforced": "Безпечний доступ до тунелю забезпечується на стороні сервера.",
"settings.openchamber.tunnel.note.connectLinksOneTime": "Посилання для підключення одноразові: вони відкликаються, коли тунель зупиняється або минає TTL посилання.", "settings.openchamber.tunnel.note.connectLinksOneTime": "Посилання для підключення одноразові: вони відкликаються, коли тунель зупиняється або минає TTL посилання.",
@@ -366,6 +379,7 @@ export const settingsDict = {
"settings.remoteInstances.page.logsDialog.selectedInstanceFallback": "Вибраний інстанс", "settings.remoteInstances.page.logsDialog.selectedInstanceFallback": "Вибраний інстанс",
"settings.remoteInstances.page.patternDialog.descriptionWithHost": "Для {host} потрібно вказати конкретне призначення.", "settings.remoteInstances.page.patternDialog.descriptionWithHost": "Для {host} потрібно вказати конкретне призначення.",
"settings.remoteInstances.page.patternDialog.description": "Введіть призначення.", "settings.remoteInstances.page.patternDialog.description": "Введіть призначення.",
"settings.common.infoAria": "Докладніше",
"settings.common.actions.cancel": "Скасувати", "settings.common.actions.cancel": "Скасувати",
"settings.common.actions.create": "Створити", "settings.common.actions.create": "Створити",
"settings.common.actions.delete": "Видалити", "settings.common.actions.delete": "Видалити",
@@ -376,6 +390,8 @@ export const settingsDict = {
"settings.common.actions.copyAll": "Копіювати все", "settings.common.actions.copyAll": "Копіювати все",
"settings.common.actions.clear": "Очистити", "settings.common.actions.clear": "Очистити",
"settings.common.actions.saving": "Збереження...", "settings.common.actions.saving": "Збереження...",
'settings.common.status.saved': 'Збережено',
'settings.common.status.saveFailed': 'Не вдалося зберегти',
"settings.common.actions.saveChanges": "Зберегти зміни", "settings.common.actions.saveChanges": "Зберегти зміни",
"settings.common.scope.global": "Глобальний", "settings.common.scope.global": "Глобальний",
"settings.common.scope.project": "Проєкт", "settings.common.scope.project": "Проєкт",
@@ -460,6 +476,33 @@ export const settingsDict = {
"settings.agents.page.section.identityRole": "Ідентичність і роль", "settings.agents.page.section.identityRole": "Ідентичність і роль",
"settings.agents.page.section.modelParameters": "Модель і параметри", "settings.agents.page.section.modelParameters": "Модель і параметри",
"settings.agents.page.section.systemPrompt": "Системний промпт", "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.section.toolPermissions": "Дозволи інструмента",
"settings.agents.page.field.agentName": "Ім'я агента", "settings.agents.page.field.agentName": "Ім'я агента",
"settings.agents.page.field.agentNamePlaceholder": "Ім’я агента", "settings.agents.page.field.agentNamePlaceholder": "Ім’я агента",
@@ -495,6 +538,7 @@ export const settingsDict = {
"settings.agents.page.permissions.permissionPlaceholder": "Дозвіл...", "settings.agents.page.permissions.permissionPlaceholder": "Дозвіл...",
"settings.agents.page.permissions.patternPlaceholder": "Шаблон (наприклад, *)", "settings.agents.page.permissions.patternPlaceholder": "Шаблон (наприклад, *)",
"settings.page.behavior.title": "Поведінка", "settings.page.behavior.title": "Поведінка",
"settings.page.behavior.description": "Керуйте тим, як агент відповідає.",
"settings.behavior.page.title": "Поведінка", "settings.behavior.page.title": "Поведінка",
"settings.behavior.page.warning.title": "Глобальні правила поєднуються з правилами проєкту", "settings.behavior.page.warning.title": "Глобальні правила поєднуються з правилами проєкту",
"settings.behavior.page.warning.description": "Зміни тут оновлюють {path}. OpenCode також додає правила з AGENTS.md на рівні проєкту, якщо вони існують.", "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.allowLanAccessDescription": "Перезапускає застосунок, щоб телефони, планшети та інші комп’ютери в мережі Wi-Fi могли його відкрити.",
"settings.openchamber.desktopNetwork.field.warning": "Попередження: якщо це ввімкнено, застосунок доступний усім у тій самій локальній мережі.", "settings.openchamber.desktopNetwork.field.warning": "Попередження: якщо це ввімкнено, застосунок доступний усім у тій самій локальній мережі.",
"settings.openchamber.desktopNetwork.field.passwordRequiredWarning": "Для LAN-доступу потрібен пароль десктопного UI. Доки його не задано, десктопний застосунок запускається лише локально.", "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.password": "Пароль для десктопного UI",
"settings.openchamber.desktopPassword.field.passwordPlaceholder": "Пароль не потрібен", "settings.openchamber.desktopPassword.field.passwordPlaceholder": "Пароль не потрібен",
"settings.openchamber.desktopPassword.field.passwordDescription": "OpenChamber попросить пароль після перезапуску, а потім коли сесія логіну спливе: через 12 годин або через 7 днів із «Довіряти цьому пристрою». Залиште порожнім, щоб вимкнути логін.", "settings.openchamber.desktopPassword.field.passwordDescription": "OpenChamber попросить пароль після перезапуску, а потім коли сесія логіну спливе: через 12 годин або через 7 днів із «Довіряти цьому пристрою». Залиште порожнім, щоб вимкнути логін.",
@@ -1585,6 +1631,7 @@ export const settingsDict = {
"settings.voice.page.section.playbackAndSummary": "Відтворення", "settings.voice.page.section.playbackAndSummary": "Відтворення",
"settings.voice.page.field.provider": "Провайдер", "settings.voice.page.field.provider": "Провайдер",
"settings.voice.page.provider.browser": "Браузер", "settings.voice.page.provider.browser": "Браузер",
"settings.voice.page.provider.openai": "OpenAI",
"settings.voice.page.provider.custom": "Власний", "settings.voice.page.provider.custom": "Власний",
"settings.voice.page.provider.say": "Say", "settings.voice.page.provider.say": "Say",
"settings.voice.page.provider.server": "Сервер", "settings.voice.page.provider.server": "Сервер",
@@ -1664,18 +1711,24 @@ export const settingsDict = {
"settings.voice.page.field.ttsInputModeRaw": "Сирий Markdown", "settings.voice.page.field.ttsInputModeRaw": "Сирий Markdown",
"settings.voice.page.field.ttsInputModeSummarized": "скорочений", "settings.voice.page.field.ttsInputModeSummarized": "скорочений",
"settings.openchamber.visual.section.colorMode": "Режим теми", "settings.openchamber.visual.section.colorMode": "Режим теми",
"settings.openchamber.visual.section.colorModeAndTheme": "Режим кольору та тема",
"settings.openchamber.visual.section.mobileLayout": "Мобільний макет", "settings.openchamber.visual.section.mobileLayout": "Мобільний макет",
"settings.openchamber.visual.option.mobileLayout.default": "Попередній", "settings.openchamber.visual.option.mobileLayout.default": "Попередній",
"settings.openchamber.visual.option.mobileLayout.new": "Новий", "settings.openchamber.visual.option.mobileLayout.new": "Новий",
"settings.openchamber.visual.section.localization": "Локалізація", "settings.openchamber.visual.section.localization": "Локалізація",
"settings.openchamber.visual.section.spacingAndLayout": "Відступи й компонування", "settings.openchamber.visual.section.spacingAndLayout": "Відступи й компонування",
"settings.openchamber.visual.section.densityAndType": "Щільність і шрифти",
"settings.openchamber.visual.section.appInstall": "Встановлення застосунку",
"settings.openchamber.visual.section.navigation": "Навігація", "settings.openchamber.visual.section.navigation": "Навігація",
"settings.openchamber.visual.section.chatRenderMode": "Режим візуалізації чату", "settings.openchamber.visual.section.chatRenderMode": "Режим візуалізації чату",
"settings.openchamber.visual.section.chatRenderModeAria": "Режим візуалізації чату", "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.messageStreamTransport": "Транспорт потокових повідомлень",
"settings.openchamber.visual.section.activityDefault": "Активність за замовчуванням", "settings.openchamber.visual.section.activityDefault": "Активність за замовчуванням",
"settings.openchamber.visual.section.activityDefaultAria": "Режим активності за замовчуванням", "settings.openchamber.visual.section.activityDefaultAria": "Режим активності за замовчуванням",
"settings.openchamber.visual.section.showToolsOpenedByDefault": "Показувати інструменти відкритими за замовчуванням:", "settings.openchamber.visual.section.showToolsOpenedByDefault": "Показувати інструменти відкритими за замовчуванням",
"settings.openchamber.visual.section.sessionAssistance": "Допомога із сесією", "settings.openchamber.visual.section.sessionAssistance": "Допомога із сесією",
"settings.openchamber.visual.section.reasoning": "Міркування", "settings.openchamber.visual.section.reasoning": "Міркування",
"settings.openchamber.visual.section.messageAppearance": "Вигляд повідомлень", "settings.openchamber.visual.section.messageAppearance": "Вигляд повідомлень",
@@ -1806,8 +1859,11 @@ export const settingsDict = {
"settings.openchamber.visual.field.sendAnonymousUsageReports": "Надсилати анонімні звіти про використання", "settings.openchamber.visual.field.sendAnonymousUsageReports": "Надсилати анонімні звіти про використання",
"settings.openchamber.visual.field.sendAnonymousUsageReportsHint": "Допомагає нам зрозуміти, які версії застосунків активно використовуються, щоб ми могли визначити пріоритети покращень. Збираються лише версія застосунку, платформа та середовище виконання – без особистих даних чи коду.", "settings.openchamber.visual.field.sendAnonymousUsageReportsHint": "Допомагає нам зрозуміти, які версії застосунків активно використовуються, щоб ми могли визначити пріоритети покращень. Збираються лише версія застосунку, платформа та середовище виконання – без особистих даних чи коду.",
"settings.openchamber.visual.option.themeMode.system": "Системна", "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": "Світла",
"settings.openchamber.visual.option.themeMode.light.description": "Завжди використовувати світлий вигляд",
"settings.openchamber.visual.option.themeMode.dark": "Темна", "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.label": "Динамічний",
"settings.openchamber.visual.option.diffLayout.dynamic.description": "Нові файли показуються inline, змінені — поруч.", "settings.openchamber.visual.option.diffLayout.dynamic.description": "Нові файли показуються inline, змінені — поруч.",
"settings.openchamber.visual.option.diffLayout.inline.label": "Завжди inline", "settings.openchamber.visual.option.diffLayout.inline.label": "Завжди inline",
@@ -47,6 +47,10 @@ export const settingsDict = {
'settings.view.search.aria': '搜索设置', 'settings.view.search.aria': '搜索设置',
'settings.view.search.clear': '清除设置搜索', 'settings.view.search.clear': '清除设置搜索',
'settings.view.search.noResults': '没有匹配的设置', '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.projects.title': '项目',
'settings.page.remoteInstances.title': '远程实例', 'settings.page.remoteInstances.title': '远程实例',
'settings.page.providers.title': '提供商', 'settings.page.providers.title': '提供商',
@@ -59,13 +63,22 @@ export const settingsDict = {
'settings.page.skillsCatalog.title': '技能目录', 'settings.page.skillsCatalog.title': '技能目录',
'settings.page.git.title': 'Git', 'settings.page.git.title': 'Git',
'settings.page.appearance.title': '外观', 'settings.page.appearance.title': '外观',
'settings.page.appearance.description': '自定义 OpenChamber 的外观与体验。',
'settings.page.chat.title': '聊天', 'settings.page.chat.title': '聊天',
'settings.page.chat.description': '配置消息和工具的显示方式。',
'settings.page.shortcuts.title': '快捷键', 'settings.page.shortcuts.title': '快捷键',
'settings.page.shortcuts.description': '自定义键盘快捷键。',
'settings.page.general.title': '通用',
'settings.page.general.description': '应用启动、安全、连接与隐私。',
'settings.page.sessions.title': '会话', 'settings.page.sessions.title': '会话',
'settings.page.sessions.description': '设置会话的默认值和保留策略。',
'settings.page.magicPrompts.title': '魔法提示词', 'settings.page.magicPrompts.title': '魔法提示词',
'settings.page.notifications.title': '通知', 'settings.page.notifications.title': '通知',
'settings.page.notifications.description': '选择何时以及如何接收通知。',
'settings.page.voice.title': '语音', '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.about.title': '关于',
'settings.page.snippets.title': '代码片段', 'settings.page.snippets.title': '代码片段',
'settings.snippets.sidebar.title': '代码片段', 'settings.snippets.sidebar.title': '代码片段',
@@ -90,7 +103,7 @@ export const settingsDict = {
'settings.snippets.page.toast.contentRequired': '代码片段内容为必填项', 'settings.snippets.page.toast.contentRequired': '代码片段内容为必填项',
'settings.snippets.page.toast.saveFailed': '保存代码片段失败', 'settings.snippets.page.toast.saveFailed': '保存代码片段失败',
'settings.snippets.page.toast.saved': '代码片段已保存', 'settings.snippets.page.toast.saved': '代码片段已保存',
'settings.openchamber.tunnel.title': '远程隧道', 'settings.openchamber.tunnel.title': '外部隧道',
'settings.openchamber.tunnel.description': '通过快速链接或你自己的 Cloudflare Managed Remote 隧道配置安全远程访问。', 'settings.openchamber.tunnel.description': '通过快速链接或你自己的 Cloudflare Managed Remote 隧道配置安全远程访问。',
'settings.openchamber.tunnel.note.serverSideEnforced': '安全隧道访问在服务端强制执行。', 'settings.openchamber.tunnel.note.serverSideEnforced': '安全隧道访问在服务端强制执行。',
'settings.openchamber.tunnel.note.connectLinksOneTime': '连接链接为一次性,隧道停止或连接链接 TTL 到期后会被吊销。', 'settings.openchamber.tunnel.note.connectLinksOneTime': '连接链接为一次性,隧道停止或连接链接 TTL 到期后会被吊销。',
@@ -366,6 +379,7 @@ export const settingsDict = {
'settings.remoteInstances.page.logsDialog.selectedInstanceFallback': '所选实例', 'settings.remoteInstances.page.logsDialog.selectedInstanceFallback': '所选实例',
'settings.remoteInstances.page.patternDialog.descriptionWithHost': '{host} 需要一个具体目标地址。', 'settings.remoteInstances.page.patternDialog.descriptionWithHost': '{host} 需要一个具体目标地址。',
'settings.remoteInstances.page.patternDialog.description': '输入目标地址。', 'settings.remoteInstances.page.patternDialog.description': '输入目标地址。',
'settings.common.infoAria': '更多信息',
'settings.common.actions.cancel': '取消', 'settings.common.actions.cancel': '取消',
'settings.common.actions.create': '创建', 'settings.common.actions.create': '创建',
'settings.common.actions.delete': '删除', 'settings.common.actions.delete': '删除',
@@ -376,6 +390,8 @@ export const settingsDict = {
'settings.common.actions.copyAll': '全部复制', 'settings.common.actions.copyAll': '全部复制',
'settings.common.actions.clear': '清除', 'settings.common.actions.clear': '清除',
'settings.common.actions.saving': '保存中...', 'settings.common.actions.saving': '保存中...',
'settings.common.status.saved': '已保存',
'settings.common.status.saveFailed': '保存失败',
'settings.common.actions.saveChanges': '保存更改', 'settings.common.actions.saveChanges': '保存更改',
'settings.common.scope.global': '全局', 'settings.common.scope.global': '全局',
'settings.common.scope.project': '项目', 'settings.common.scope.project': '项目',
@@ -460,6 +476,33 @@ export const settingsDict = {
'settings.agents.page.section.identityRole': '身份与角色', 'settings.agents.page.section.identityRole': '身份与角色',
'settings.agents.page.section.modelParameters': '模型与参数', 'settings.agents.page.section.modelParameters': '模型与参数',
'settings.agents.page.section.systemPrompt': '系统提示词', '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.section.toolPermissions': '工具权限',
'settings.agents.page.field.agentName': '智能体名称', 'settings.agents.page.field.agentName': '智能体名称',
'settings.agents.page.field.agentNamePlaceholder': 'agent-name', 'settings.agents.page.field.agentNamePlaceholder': 'agent-name',
@@ -495,6 +538,7 @@ export const settingsDict = {
'settings.agents.page.permissions.permissionPlaceholder': '权限...', 'settings.agents.page.permissions.permissionPlaceholder': '权限...',
'settings.agents.page.permissions.patternPlaceholder': '模式(例如 *)', 'settings.agents.page.permissions.patternPlaceholder': '模式(例如 *)',
'settings.page.behavior.title': '行为', 'settings.page.behavior.title': '行为',
'settings.page.behavior.description': '引导智能体的回复方式。',
'settings.behavior.page.title': '行为', 'settings.behavior.page.title': '行为',
'settings.behavior.page.warning.title': '全局规则会与项目规则合并使用', 'settings.behavior.page.warning.title': '全局规则会与项目规则合并使用',
'settings.behavior.page.warning.description': '此处所做的更改会更新 {path}。当项目级 AGENTS.md 存在时,OpenCode 也会包含它。', '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.allowLanAccessDescription': '会重启应用,以便手机、平板和同一 Wi‑Fi 下的其他电脑访问。',
'settings.openchamber.desktopNetwork.field.warning': '警告:启用后,同一本地网络中的任何人都可访问此应用。', 'settings.openchamber.desktopNetwork.field.warning': '警告:启用后,同一本地网络中的任何人都可访问此应用。',
'settings.openchamber.desktopNetwork.field.passwordRequiredWarning': '局域网访问需要桌面 UI 密码。在设置密码之前,桌面应用只会以本机访问模式启动。', '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.password': '桌面 UI 密码',
'settings.openchamber.desktopPassword.field.passwordPlaceholder': '不需要密码', 'settings.openchamber.desktopPassword.field.passwordPlaceholder': '不需要密码',
'settings.openchamber.desktopPassword.field.passwordDescription': 'OpenChamber 会在重启后要求输入密码,之后在登录会话过期时再次要求:12 小时后,或选择“信任此设备”后 7 天。留空可关闭登录。', 'settings.openchamber.desktopPassword.field.passwordDescription': 'OpenChamber 会在重启后要求输入密码,之后在登录会话过期时再次要求:12 小时后,或选择“信任此设备”后 7 天。留空可关闭登录。',
@@ -1585,6 +1631,7 @@ export const settingsDict = {
'settings.voice.page.section.playbackAndSummary': '播放', 'settings.voice.page.section.playbackAndSummary': '播放',
'settings.voice.page.field.provider': '提供方', 'settings.voice.page.field.provider': '提供方',
'settings.voice.page.provider.browser': '浏览器', 'settings.voice.page.provider.browser': '浏览器',
'settings.voice.page.provider.openai': 'OpenAI',
'settings.voice.page.provider.custom': '自定义', 'settings.voice.page.provider.custom': '自定义',
'settings.voice.page.provider.say': 'Say', 'settings.voice.page.provider.say': 'Say',
'settings.voice.page.provider.server': '服务器', 'settings.voice.page.provider.server': '服务器',
@@ -1664,18 +1711,24 @@ export const settingsDict = {
'settings.voice.page.field.ttsInputModeRaw': '原始 Markdown', 'settings.voice.page.field.ttsInputModeRaw': '原始 Markdown',
'settings.voice.page.field.ttsInputModeSummarized': '摘要', 'settings.voice.page.field.ttsInputModeSummarized': '摘要',
'settings.openchamber.visual.section.colorMode': '颜色模式', 'settings.openchamber.visual.section.colorMode': '颜色模式',
'settings.openchamber.visual.section.colorModeAndTheme': '颜色模式与主题',
'settings.openchamber.visual.section.mobileLayout': '移动端布局', 'settings.openchamber.visual.section.mobileLayout': '移动端布局',
'settings.openchamber.visual.option.mobileLayout.default': '旧版', 'settings.openchamber.visual.option.mobileLayout.default': '旧版',
'settings.openchamber.visual.option.mobileLayout.new': '新版', 'settings.openchamber.visual.option.mobileLayout.new': '新版',
'settings.openchamber.visual.section.localization': '本地化', 'settings.openchamber.visual.section.localization': '本地化',
'settings.openchamber.visual.section.spacingAndLayout': '间距与布局', 'settings.openchamber.visual.section.spacingAndLayout': '间距与布局',
'settings.openchamber.visual.section.densityAndType': '密度与字体',
'settings.openchamber.visual.section.appInstall': '应用安装',
'settings.openchamber.visual.section.navigation': '导航', 'settings.openchamber.visual.section.navigation': '导航',
'settings.openchamber.visual.section.chatRenderMode': '聊天渲染模式', 'settings.openchamber.visual.section.chatRenderMode': '聊天渲染模式',
'settings.openchamber.visual.section.chatRenderModeAria': '聊天渲染模式', '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.messageStreamTransport': '消息流传输方式',
'settings.openchamber.visual.section.activityDefault': '活动默认状态', 'settings.openchamber.visual.section.activityDefault': '活动默认状态',
'settings.openchamber.visual.section.activityDefaultAria': '活动默认模式', 'settings.openchamber.visual.section.activityDefaultAria': '活动默认模式',
'settings.openchamber.visual.section.showToolsOpenedByDefault': '默认展开以下工具:', 'settings.openchamber.visual.section.showToolsOpenedByDefault': '默认展开以下工具',
'settings.openchamber.visual.section.sessionAssistance': '会话辅助', 'settings.openchamber.visual.section.sessionAssistance': '会话辅助',
'settings.openchamber.visual.section.reasoning': '推理', 'settings.openchamber.visual.section.reasoning': '推理',
'settings.openchamber.visual.section.messageAppearance': '消息外观', 'settings.openchamber.visual.section.messageAppearance': '消息外观',
@@ -1806,8 +1859,11 @@ export const settingsDict = {
'settings.openchamber.visual.field.sendAnonymousUsageReports': '发送匿名使用报告', 'settings.openchamber.visual.field.sendAnonymousUsageReports': '发送匿名使用报告',
'settings.openchamber.visual.field.sendAnonymousUsageReportsHint': '帮助我们了解哪些应用版本正在被积极使用,以便优先改进。仅收集应用版本、平台和运行时信息,不收集个人数据或代码。', 'settings.openchamber.visual.field.sendAnonymousUsageReportsHint': '帮助我们了解哪些应用版本正在被积极使用,以便优先改进。仅收集应用版本、平台和运行时信息,不收集个人数据或代码。',
'settings.openchamber.visual.option.themeMode.system': '跟随系统', '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': '浅色',
'settings.openchamber.visual.option.themeMode.light.description': '始终使用浅色外观',
'settings.openchamber.visual.option.themeMode.dark': '深色', '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.label': '动态',
'settings.openchamber.visual.option.diffLayout.dynamic.description': '新增文件用行内视图,修改文件用并排视图。', 'settings.openchamber.visual.option.diffLayout.dynamic.description': '新增文件用行内视图,修改文件用并排视图。',
'settings.openchamber.visual.option.diffLayout.inline.label': '始终行内', 'settings.openchamber.visual.option.diffLayout.inline.label': '始终行内',
@@ -45,6 +45,10 @@
'settings.view.search.aria': '搜尋設定', 'settings.view.search.aria': '搜尋設定',
'settings.view.search.clear': '清除設定搜尋', 'settings.view.search.clear': '清除設定搜尋',
'settings.view.search.noResults': '沒有符合的設定', '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.projects.title': '專案',
'settings.page.remoteInstances.title': '遠端執行個體', 'settings.page.remoteInstances.title': '遠端執行個體',
'settings.page.providers.title': '供應商', 'settings.page.providers.title': '供應商',
@@ -56,13 +60,22 @@
'settings.page.skillsCatalog.title': 'Skills 目錄', 'settings.page.skillsCatalog.title': 'Skills 目錄',
'settings.page.git.title': 'Git', 'settings.page.git.title': 'Git',
'settings.page.appearance.title': '外觀', 'settings.page.appearance.title': '外觀',
'settings.page.appearance.description': '自訂 OpenChamber 的外觀與體驗。',
'settings.page.chat.title': '聊天', 'settings.page.chat.title': '聊天',
'settings.page.chat.description': '設定訊息與工具的顯示方式。',
'settings.page.shortcuts.title': '快速鍵', 'settings.page.shortcuts.title': '快速鍵',
'settings.page.shortcuts.description': '自訂鍵盤快速鍵。',
'settings.page.general.title': '一般',
'settings.page.general.description': '應用程式啟動、安全性、連線與隱私。',
'settings.page.sessions.title': '工作階段', 'settings.page.sessions.title': '工作階段',
'settings.page.sessions.description': '設定工作階段的預設值與保留期限。',
'settings.page.magicPrompts.title': '魔法提示詞', 'settings.page.magicPrompts.title': '魔法提示詞',
'settings.page.notifications.title': '通知', 'settings.page.notifications.title': '通知',
'settings.page.notifications.description': '選擇何時以及如何收到通知。',
'settings.page.voice.title': '語音', '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.about.title': '關於',
'settings.page.snippets.title': '程式片段', 'settings.page.snippets.title': '程式片段',
'settings.snippets.sidebar.title': '程式片段', 'settings.snippets.sidebar.title': '程式片段',
@@ -87,7 +100,7 @@
'settings.snippets.page.toast.contentRequired': '程式片段內容為必填項', 'settings.snippets.page.toast.contentRequired': '程式片段內容為必填項',
'settings.snippets.page.toast.saveFailed': '保存程式片段失敗', 'settings.snippets.page.toast.saveFailed': '保存程式片段失敗',
'settings.snippets.page.toast.saved': '程式片段已保存', '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.description': '透過快速連結或你自己的 Cloudflare managed remote tunnel 設定安全遠端存取。',
'settings.openchamber.tunnel.note.serverSideEnforced': '安全通道存取由伺服器端強制執行。', 'settings.openchamber.tunnel.note.serverSideEnforced': '安全通道存取由伺服器端強制執行。',
'settings.openchamber.tunnel.note.connectLinksOneTime': '連線連結為一次性,Tunnel 停止或連線連結 TTL 到期後會被撤銷。', 'settings.openchamber.tunnel.note.connectLinksOneTime': '連線連結為一次性,Tunnel 停止或連線連結 TTL 到期後會被撤銷。',
@@ -363,6 +376,7 @@
'settings.remoteInstances.page.logsDialog.selectedInstanceFallback': '所選執行個體', 'settings.remoteInstances.page.logsDialog.selectedInstanceFallback': '所選執行個體',
'settings.remoteInstances.page.patternDialog.descriptionWithHost': '{host} 需要一個具體目標地址。', 'settings.remoteInstances.page.patternDialog.descriptionWithHost': '{host} 需要一個具體目標地址。',
'settings.remoteInstances.page.patternDialog.description': '輸入目標地址。', 'settings.remoteInstances.page.patternDialog.description': '輸入目標地址。',
'settings.common.infoAria': '更多資訊',
'settings.common.actions.cancel': '取消', 'settings.common.actions.cancel': '取消',
'settings.common.actions.create': '建立', 'settings.common.actions.create': '建立',
'settings.common.actions.delete': '刪除', 'settings.common.actions.delete': '刪除',
@@ -373,6 +387,8 @@
'settings.common.actions.copyAll': '全部複製', 'settings.common.actions.copyAll': '全部複製',
'settings.common.actions.clear': '清除', 'settings.common.actions.clear': '清除',
'settings.common.actions.saving': '儲存中...', 'settings.common.actions.saving': '儲存中...',
'settings.common.status.saved': '已儲存',
'settings.common.status.saveFailed': '儲存失敗',
'settings.common.actions.saveChanges': '儲存變更', 'settings.common.actions.saveChanges': '儲存變更',
'settings.common.scope.global': '全域', 'settings.common.scope.global': '全域',
'settings.common.scope.project': '專案', 'settings.common.scope.project': '專案',
@@ -457,6 +473,33 @@
'settings.agents.page.section.identityRole': '身分與角色', 'settings.agents.page.section.identityRole': '身分與角色',
'settings.agents.page.section.modelParameters': '模型與參數', 'settings.agents.page.section.modelParameters': '模型與參數',
'settings.agents.page.section.systemPrompt': '系統提示詞', '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.section.toolPermissions': '工具權限',
'settings.agents.page.field.agentName': 'Agent 名稱', 'settings.agents.page.field.agentName': 'Agent 名稱',
'settings.agents.page.field.agentNamePlaceholder': 'agent-name', 'settings.agents.page.field.agentNamePlaceholder': 'agent-name',
@@ -492,6 +535,7 @@
'settings.agents.page.permissions.permissionPlaceholder': '權限...', 'settings.agents.page.permissions.permissionPlaceholder': '權限...',
'settings.agents.page.permissions.patternPlaceholder': '模式(例如 *)', 'settings.agents.page.permissions.patternPlaceholder': '模式(例如 *)',
'settings.page.behavior.title': '行為', 'settings.page.behavior.title': '行為',
'settings.page.behavior.description': '引導代理的回應方式。',
'settings.behavior.page.title': '行為', 'settings.behavior.page.title': '行為',
'settings.behavior.page.warning.title': '全域規則會與專案規則合併使用', 'settings.behavior.page.warning.title': '全域規則會與專案規則合併使用',
'settings.behavior.page.warning.description': '此處所做的變更會更新 {path}。當專案級 AGENTS.md 存在時,OpenCode 也會包含它。', 'settings.behavior.page.warning.description': '此處所做的變更會更新 {path}。當專案級 AGENTS.md 存在時,OpenCode 也會包含它。',
@@ -1496,6 +1540,7 @@
'settings.voice.page.section.playbackAndSummary': '播放', 'settings.voice.page.section.playbackAndSummary': '播放',
'settings.voice.page.field.provider': '提供方', 'settings.voice.page.field.provider': '提供方',
'settings.voice.page.provider.browser': '瀏覽器', 'settings.voice.page.provider.browser': '瀏覽器',
'settings.voice.page.provider.openai': 'OpenAI',
'settings.voice.page.provider.custom': '自訂', 'settings.voice.page.provider.custom': '自訂',
'settings.voice.page.provider.say': 'Say', 'settings.voice.page.provider.say': 'Say',
'settings.voice.page.provider.server': '伺服器', 'settings.voice.page.provider.server': '伺服器',
@@ -1575,16 +1620,22 @@
'settings.voice.page.field.ttsInputModeRaw': '原始 Markdown', 'settings.voice.page.field.ttsInputModeRaw': '原始 Markdown',
'settings.voice.page.field.ttsInputModeSummarized': '摘要', 'settings.voice.page.field.ttsInputModeSummarized': '摘要',
'settings.openchamber.visual.section.colorMode': '顏色模式', 'settings.openchamber.visual.section.colorMode': '顏色模式',
'settings.openchamber.visual.section.colorModeAndTheme': '色彩模式與主題',
'settings.openchamber.visual.section.localization': '在地化', 'settings.openchamber.visual.section.localization': '在地化',
'settings.openchamber.visual.section.spacingAndLayout': '間距與佈局', 'settings.openchamber.visual.section.spacingAndLayout': '間距與佈局',
'settings.openchamber.visual.section.densityAndType': '密度與字型',
'settings.openchamber.visual.section.appInstall': '應用程式安裝',
'settings.openchamber.visual.section.mobileLayout': '行動版版面', 'settings.openchamber.visual.section.mobileLayout': '行動版版面',
'settings.openchamber.visual.section.navigation': '導覽', 'settings.openchamber.visual.section.navigation': '導覽',
'settings.openchamber.visual.section.chatRenderMode': '聊天渲染模式', 'settings.openchamber.visual.section.chatRenderMode': '聊天渲染模式',
'settings.openchamber.visual.section.chatRenderModeAria': '聊天渲染模式', '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.messageStreamTransport': '訊息流傳輸方式',
'settings.openchamber.visual.section.activityDefault': '活動預設狀態', 'settings.openchamber.visual.section.activityDefault': '活動預設狀態',
'settings.openchamber.visual.section.activityDefaultAria': '活動預設模式', 'settings.openchamber.visual.section.activityDefaultAria': '活動預設模式',
'settings.openchamber.visual.section.showToolsOpenedByDefault': '預設展開以下工具:', 'settings.openchamber.visual.section.showToolsOpenedByDefault': '預設展開以下工具',
'settings.openchamber.visual.section.sessionAssistance': '工作階段輔助', 'settings.openchamber.visual.section.sessionAssistance': '工作階段輔助',
'settings.openchamber.visual.section.reasoning': '推理', 'settings.openchamber.visual.section.reasoning': '推理',
'settings.openchamber.visual.section.messageAppearance': '訊息外觀', 'settings.openchamber.visual.section.messageAppearance': '訊息外觀',
@@ -1717,8 +1768,11 @@
'settings.openchamber.visual.field.sendAnonymousUsageReports': '送出匿名使用報告', 'settings.openchamber.visual.field.sendAnonymousUsageReports': '送出匿名使用報告',
'settings.openchamber.visual.field.sendAnonymousUsageReportsHint': '協助我們了解哪些應用程式版本仍在被積極使用,以便優先改進。僅收集應用程式版本、平台與執行階段資訊,不收集個人資料或程式碼。', 'settings.openchamber.visual.field.sendAnonymousUsageReportsHint': '協助我們了解哪些應用程式版本仍在被積極使用,以便優先改進。僅收集應用程式版本、平台與執行階段資訊,不收集個人資料或程式碼。',
'settings.openchamber.visual.option.themeMode.system': '跟隨系統', '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': '淺色',
'settings.openchamber.visual.option.themeMode.light.description': '一律使用淺色外觀',
'settings.openchamber.visual.option.themeMode.dark': '深色', '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.label': '動態',
'settings.openchamber.visual.option.diffLayout.dynamic.description': '新增檔案用行內檢視,修改檔案用並排檢視。', 'settings.openchamber.visual.option.diffLayout.dynamic.description': '新增檔案用行內檢視,修改檔案用並排檢視。',
'settings.openchamber.visual.option.diffLayout.inline.label': '始終行內', 'settings.openchamber.visual.option.diffLayout.inline.label': '始終行內',
@@ -1877,6 +1931,8 @@
'settings.openchamber.desktopNetwork.field.keepAwakeAria': 'OpenChamber 執行時保持電腦喚醒', 'settings.openchamber.desktopNetwork.field.keepAwakeAria': 'OpenChamber 執行時保持電腦喚醒',
'settings.openchamber.desktopNetwork.field.keepAwake': 'OpenChamber 執行時保持電腦喚醒', 'settings.openchamber.desktopNetwork.field.keepAwake': 'OpenChamber 執行時保持電腦喚醒',
'settings.openchamber.desktopNetwork.field.keepAwakeDescription': '讓手機可以持續開啟此應用程式。螢幕仍可關閉。', '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.password': '桌面 UI 密碼',
'settings.openchamber.desktopPassword.field.passwordDescription': 'OpenChamber 會在重新啟動後要求輸入密碼,之後會在登入工作階段過期時再次要求:12 小時後,或選擇「信任此裝置」後 7 天。留空可停用登入。', 'settings.openchamber.desktopPassword.field.passwordDescription': 'OpenChamber 會在重新啟動後要求輸入密碼,之後會在登入工作階段過期時再次要求:12 小時後,或選擇「信任此裝置」後 7 天。留空可停用登入。',
'settings.openchamber.desktopPassword.field.passwordPlaceholder': '不需要密碼', 'settings.openchamber.desktopPassword.field.passwordPlaceholder': '不需要密碼',
+24 -1
View File
@@ -5,7 +5,14 @@ import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { startModelPrefsAutoSave } from '@/lib/modelPrefsAutoSave'; import { startModelPrefsAutoSave } from '@/lib/modelPrefsAutoSave';
import { startAppearanceAutoSave } from '@/lib/appearanceAutoSave'; import { startAppearanceAutoSave } from '@/lib/appearanceAutoSave';
import { useUIStore } from '@/stores/useUIStore'; 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'; import { switchRuntimeEndpoint } from './runtime-switch';
type TestWindow = { type TestWindow = {
@@ -216,6 +223,22 @@ describe('updateDesktopSettings', () => {
expect(secondResolved).toBe(true); 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 () => { test('drains a pending save to the previous runtime and ignores its stale response', async () => {
switchRuntimeEndpoint({ apiBaseUrl: 'https://settings-a.example', runtimeKey: 'settings-a' }); switchRuntimeEndpoint({ apiBaseUrl: 'https://settings-a.example', runtimeKey: 'settings-a' });
const saveResult = deferred<SettingsPayload>(); 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 })); 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 = { type PersistApi = {
hasHydrated?: () => boolean; hasHydrated?: () => boolean;
onFinishHydration?: (callback: () => void) => (() => void) | undefined; onFinishHydration?: (callback: () => void) => (() => void) | undefined;
@@ -1522,7 +1567,11 @@ async function _flushSettingsUpdate(): Promise<void> {
_settingsFlushTimer = null; _settingsFlushTimer = null;
_settingsFlushWaiters = []; _settingsFlushWaiters = [];
try { 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(); const runtimeSettings = getRuntimeSettingsAPI();
if (runtimeSettings) { if (runtimeSettings) {
@@ -1535,6 +1584,7 @@ async function _flushSettingsUpdate(): Promise<void> {
dispatchSettingsSynced(updated); dispatchSettingsSynced(updated);
_settingsCache = null; _settingsCache = null;
} }
dispatchSettingsSaveState(updated ? 'saved' : 'error');
return; return;
} catch (error) { } catch (error) {
if (!isSettingsRuntimeContextCurrent(context)) return; if (!isSettingsRuntimeContextCurrent(context)) return;
@@ -1556,6 +1606,7 @@ async function _flushSettingsUpdate(): Promise<void> {
if (!isSettingsRuntimeContextCurrent(context)) return; if (!isSettingsRuntimeContextCurrent(context)) return;
if (!response.ok) { if (!response.ok) {
console.warn('Failed to update shared settings via API:', response.status, response.statusText); console.warn('Failed to update shared settings via API:', response.status, response.statusText);
dispatchSettingsSaveState('error');
return; return;
} }
@@ -1565,11 +1616,17 @@ async function _flushSettingsUpdate(): Promise<void> {
persistToLocalStorage(updated); persistToLocalStorage(updated);
applyDesktopUiPreferences(updated); applyDesktopUiPreferences(updated);
dispatchSettingsSynced(updated); dispatchSettingsSynced(updated);
dispatchSettingsSaveState('saved');
// Invalidate GET cache so next read sees the fresh data // Invalidate GET cache so next read sees the fresh data
_settingsCache = null; _settingsCache = null;
} else {
dispatchSettingsSaveState('error');
} }
} catch (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 { } finally {
waiters.forEach((resolve) => resolve()); waiters.forEach((resolve) => resolve());
@@ -1590,6 +1647,7 @@ export const updateDesktopSettings = async (changes: Partial<DesktopSettings>):
_pendingSettingsChanges = { ...(_pendingSettingsChanges ?? {}), ...changes }; _pendingSettingsChanges = { ...(_pendingSettingsChanges ?? {}), ...changes };
_pendingSettingsContext = context; _pendingSettingsContext = context;
dispatchSettingsSaveState('saving');
if (_settingsFlushTimer) { if (_settingsFlushTimer) {
clearTimeout(_settingsFlushTimer); clearTimeout(_settingsFlushTimer);
+20 -16
View File
@@ -2,6 +2,7 @@ import type { SidebarSection } from '@/constants/sidebar';
export type SettingsPageSlug = export type SettingsPageSlug =
| 'home' | 'home'
| 'general'
| 'projects' | 'projects'
| 'remote-instances' | 'remote-instances'
| 'providers' | 'providers'
@@ -26,14 +27,10 @@ export type SettingsPageSlug =
| 'about'; | 'about';
type SettingsPageGroup = type SettingsPageGroup =
| 'appearance'
| 'projects'
| 'general' | 'general'
| 'projects'
| 'opencode' | 'opencode'
| 'git' | 'content';
| 'skills'
| 'usage'
| 'advanced';
export interface SettingsRuntimeContext { export interface SettingsRuntimeContext {
isVSCode: boolean; isVSCode: boolean;
@@ -61,6 +58,13 @@ export const SETTINGS_PAGE_METADATA: readonly SettingsPageMeta[] = [
description: 'Search and jump to common pages.', description: 'Search and jump to common pages.',
keywords: ['search', 'settings'], 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', slug: 'projects',
title: 'Projects', title: 'Projects',
@@ -86,7 +90,7 @@ export const SETTINGS_PAGE_METADATA: readonly SettingsPageMeta[] = [
{ {
slug: 'usage', slug: 'usage',
title: 'Usage', title: 'Usage',
group: 'usage', group: 'general',
kind: 'split', kind: 'split',
keywords: ['quota', 'billing', 'tokens', 'usage', 'limits'], keywords: ['quota', 'billing', 'tokens', 'usage', 'limits'],
}, },
@@ -128,21 +132,21 @@ export const SETTINGS_PAGE_METADATA: readonly SettingsPageMeta[] = [
{ {
slug: 'skills.installed', slug: 'skills.installed',
title: 'Skills', title: 'Skills',
group: 'skills', group: 'content',
kind: 'split', kind: 'split',
keywords: ['skill', 'skills', 'instructions', 'install', 'catalog'], keywords: ['skill', 'skills', 'instructions', 'install', 'catalog'],
}, },
{ {
slug: 'skills.catalog', slug: 'skills.catalog',
title: 'Skills Catalog', title: 'Skills Catalog',
group: 'skills', group: 'content',
kind: 'single', kind: 'single',
keywords: ['install', 'catalog', 'external', 'repository', 'skills catalog'], keywords: ['install', 'catalog', 'external', 'repository', 'skills catalog'],
}, },
{ {
slug: 'git', slug: 'git',
title: 'Git', title: 'Git',
group: 'git', group: 'projects',
kind: 'single', kind: 'single',
keywords: ['git', 'github', 'identity', 'identities', 'ssh', 'profiles', 'credentials', 'keys', 'commit', 'gitmoji', 'oauth', 'prs', 'issues'], keywords: ['git', 'github', 'identity', 'identities', 'ssh', 'profiles', 'credentials', 'keys', 'commit', 'gitmoji', 'oauth', 'prs', 'issues'],
isAvailable: (ctx) => !ctx.isVSCode, isAvailable: (ctx) => !ctx.isVSCode,
@@ -150,7 +154,7 @@ export const SETTINGS_PAGE_METADATA: readonly SettingsPageMeta[] = [
{ {
slug: 'appearance', slug: 'appearance',
title: 'Appearance', title: 'Appearance',
group: 'appearance', group: 'general',
kind: 'single', kind: 'single',
keywords: ['theme', 'font', 'spacing', 'padding', 'corner radius', 'radius', 'input bar', 'keyboard', 'viewport', 'mobile', 'terminal', 'pwa', 'install name', 'app shortcuts'], 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', slug: 'magic-prompts',
title: 'Magic Prompts', title: 'Magic Prompts',
group: 'general', group: 'content',
kind: 'split', kind: 'split',
keywords: ['prompts', 'templates', 'git', 'github', 'review', 'commit', 'pull request'], keywords: ['prompts', 'templates', 'git', 'github', 'review', 'commit', 'pull request'],
isAvailable: (ctx) => !ctx.isVSCode, isAvailable: (ctx) => !ctx.isVSCode,
@@ -187,15 +191,15 @@ export const SETTINGS_PAGE_METADATA: readonly SettingsPageMeta[] = [
{ {
slug: 'snippets', slug: 'snippets',
title: 'Snippets', title: 'Snippets',
group: 'general', group: 'content',
kind: 'split', kind: 'split',
keywords: ['prompt', 'templates', 'multi-run', 'strategy', 'approach'], keywords: ['prompt', 'templates', 'multi-run', 'strategy', 'approach'],
}, },
{ slug: 'notifications', title: 'Notifications', group: 'general', kind: 'single', keywords: ['alerts', 'native', 'summary', 'summarization'], }, { 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: 'voice', title: 'Voice', group: 'general', 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: '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: 'advanced', kind: 'single', keywords: ['about', 'version', 'updates', 'release', 'changelog'], isAvailable: (ctx) => ctx.isMobile }, { slug: 'about', title: 'About', group: 'general', kind: 'single', keywords: ['about', 'version', 'updates', 'release', 'changelog'], isAvailable: (ctx) => ctx.isMobile && !ctx.isVSCode },
] as const; ] as const;
const LEGACY_SIDEBAR_SECTION_TO_SETTINGS_SLUG: Record<SidebarSection, SettingsPageSlug> = { 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', id: 'appearance.terminal-shell',
page: 'appearance', page: 'general',
titleKey: 'settings.openchamber.visual.field.terminalShell', titleKey: 'settings.openchamber.visual.field.terminalShell',
descriptionKey: 'settings.openchamber.visual.field.terminalShellHint', descriptionKey: 'settings.openchamber.visual.field.terminalShellHint',
keywords: ['terminal', 'shell', 'bash', 'zsh', 'fish', 'pwsh', 'powershell'], keywords: ['terminal', 'shell', 'bash', 'zsh', 'fish', 'pwsh', 'powershell'],
@@ -142,22 +142,25 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
titleKey: 'settings.openchamber.visual.field.inputBarOffset', titleKey: 'settings.openchamber.visual.field.inputBarOffset',
descriptionKey: 'settings.openchamber.visual.field.inputBarOffsetTooltip', descriptionKey: 'settings.openchamber.visual.field.inputBarOffsetTooltip',
keywords: ['input', 'home bar', 'offset'], 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', id: 'appearance.expanded-editor-toolbar',
page: 'appearance', page: 'general',
titleKey: 'settings.openchamber.visual.field.expandedEditorToolbar', titleKey: 'settings.openchamber.visual.field.expandedEditorToolbar',
keywords: ['editor', 'toolbar', 'tabs', 'docked', 'files'], keywords: ['editor', 'toolbar', 'tabs', 'docked', 'files'],
isAvailable: (ctx) => !ctx.isVSCode,
}, },
{ {
id: 'appearance.file-editor-keymap', id: 'appearance.file-editor-keymap',
page: 'appearance', page: 'general',
titleKey: 'settings.openchamber.visual.field.fileEditorKeymap', titleKey: 'settings.openchamber.visual.field.fileEditorKeymap',
keywords: ['editor', 'vim', 'keymap'], keywords: ['editor', 'vim', 'keymap'],
}, },
{ {
id: 'appearance.terminal-quick-keys', id: 'appearance.terminal-quick-keys',
page: 'appearance', page: 'general',
titleKey: 'settings.openchamber.visual.field.terminalQuickKeys', titleKey: 'settings.openchamber.visual.field.terminalQuickKeys',
descriptionKey: 'settings.openchamber.visual.field.terminalQuickKeysTooltip', descriptionKey: 'settings.openchamber.visual.field.terminalQuickKeysTooltip',
keywords: ['terminal', 'keyboard', 'esc', 'ctrl', 'arrows'], keywords: ['terminal', 'keyboard', 'esc', 'ctrl', 'arrows'],
@@ -165,7 +168,7 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
}, },
{ {
id: 'appearance.usage-reports', id: 'appearance.usage-reports',
page: 'appearance', page: 'general',
titleKey: 'settings.openchamber.visual.field.sendAnonymousUsageReports', titleKey: 'settings.openchamber.visual.field.sendAnonymousUsageReports',
descriptionKey: 'settings.openchamber.visual.field.sendAnonymousUsageReportsHint', descriptionKey: 'settings.openchamber.visual.field.sendAnonymousUsageReportsHint',
keywords: ['telemetry', 'analytics'], keywords: ['telemetry', 'analytics'],
@@ -178,7 +181,7 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
}, },
{ {
id: 'chat.message-transport', id: 'chat.message-transport',
page: 'chat', page: 'general',
titleKey: 'settings.openchamber.visual.section.messageStreamTransport', titleKey: 'settings.openchamber.visual.section.messageStreamTransport',
keywords: ['streaming', 'sse', 'websocket'], keywords: ['streaming', 'sse', 'websocket'],
}, },
@@ -379,7 +382,7 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
}, },
{ {
id: 'sessions.desktop-launch-at-login', id: 'sessions.desktop-launch-at-login',
page: 'sessions', page: 'general',
titleKey: 'settings.openchamber.desktopNetwork.field.launchAtLogin', titleKey: 'settings.openchamber.desktopNetwork.field.launchAtLogin',
descriptionKey: 'settings.openchamber.desktopNetwork.field.launchAtLoginDescription', descriptionKey: 'settings.openchamber.desktopNetwork.field.launchAtLoginDescription',
keywords: ['desktop', 'startup', 'login'], keywords: ['desktop', 'startup', 'login'],
@@ -387,7 +390,7 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
}, },
{ {
id: 'sessions.desktop-window-controls-position', id: 'sessions.desktop-window-controls-position',
page: 'sessions', page: 'general',
titleKey: 'settings.openchamber.desktopNetwork.field.windowControlsPosition', titleKey: 'settings.openchamber.desktopNetwork.field.windowControlsPosition',
descriptionKey: 'settings.openchamber.desktopNetwork.field.windowControlsPositionDescription', descriptionKey: 'settings.openchamber.desktopNetwork.field.windowControlsPositionDescription',
keywords: ['desktop', 'window', 'controls', 'minimize', 'maximize', 'close', 'titlebar', 'linux', 'windows'], 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', id: 'sessions.desktop-minimize-to-tray',
page: 'sessions', page: 'general',
titleKey: 'settings.openchamber.desktopNetwork.field.minimizeToTray', titleKey: 'settings.openchamber.desktopNetwork.field.minimizeToTray',
descriptionKey: 'settings.openchamber.desktopNetwork.field.minimizeToTrayDescription', descriptionKey: 'settings.openchamber.desktopNetwork.field.minimizeToTrayDescription',
keywords: ['desktop', 'tray', 'system tray', 'minimize', 'close', 'background', 'windows'], keywords: ['desktop', 'tray', 'system tray', 'minimize', 'close', 'background', 'windows'],
@@ -403,7 +406,7 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
}, },
{ {
id: 'sessions.desktop-keep-awake', id: 'sessions.desktop-keep-awake',
page: 'sessions', page: 'general',
titleKey: 'settings.openchamber.desktopNetwork.field.keepAwake', titleKey: 'settings.openchamber.desktopNetwork.field.keepAwake',
descriptionKey: 'settings.openchamber.desktopNetwork.field.keepAwakeDescription', descriptionKey: 'settings.openchamber.desktopNetwork.field.keepAwakeDescription',
keywords: ['desktop', 'sleep', 'awake', 'server', 'mobile', 'phone'], keywords: ['desktop', 'sleep', 'awake', 'server', 'mobile', 'phone'],
@@ -411,7 +414,7 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
}, },
{ {
id: 'sessions.desktop-ui-password', id: 'sessions.desktop-ui-password',
page: 'sessions', page: 'general',
titleKey: 'settings.openchamber.desktopPassword.field.password', titleKey: 'settings.openchamber.desktopPassword.field.password',
descriptionKey: 'settings.openchamber.desktopPassword.field.passwordDescription', descriptionKey: 'settings.openchamber.desktopPassword.field.passwordDescription',
keywords: ['desktop', 'password', 'auth', 'login'], keywords: ['desktop', 'password', 'auth', 'login'],
@@ -419,7 +422,7 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
}, },
{ {
id: 'sessions.desktop-lan-access', id: 'sessions.desktop-lan-access',
page: 'sessions', page: 'general',
titleKey: 'settings.openchamber.desktopNetwork.field.allowLanAccess', titleKey: 'settings.openchamber.desktopNetwork.field.allowLanAccess',
descriptionKey: 'settings.openchamber.desktopNetwork.field.allowLanAccessDescription', descriptionKey: 'settings.openchamber.desktopNetwork.field.allowLanAccessDescription',
keywords: ['desktop', 'lan', 'network', 'phone', 'tablet'], keywords: ['desktop', 'lan', 'network', 'phone', 'tablet'],
@@ -427,14 +430,14 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
}, },
{ {
id: 'sessions.opencode-binary', id: 'sessions.opencode-binary',
page: 'sessions', page: 'general',
titleKey: 'settings.openchamber.opencodeCli.field.binaryPath', titleKey: 'settings.openchamber.opencodeCli.field.binaryPath',
keywords: ['opencode', 'cli', 'binary', 'path'], keywords: ['opencode', 'cli', 'binary', 'path'],
isAvailable: (ctx) => !ctx.isVSCode, isAvailable: (ctx) => !ctx.isVSCode,
}, },
{ {
id: 'sessions.opencode-update-notifications', id: 'sessions.opencode-update-notifications',
page: 'sessions', page: 'general',
titleKey: 'settings.openchamber.opencodeCli.field.showUpdateNotifications', titleKey: 'settings.openchamber.opencodeCli.field.showUpdateNotifications',
keywords: ['opencode', 'cli', 'updates'], keywords: ['opencode', 'cli', 'updates'],
isAvailable: (ctx) => !ctx.isVSCode, isAvailable: (ctx) => !ctx.isVSCode,
@@ -564,6 +564,8 @@ const sidebarBaseRgb = hexToRgb(theme.colors.surface.muted);
vars.push(` --text-meta: ${typography.meta};`); vars.push(` --text-meta: ${typography.meta};`);
vars.push(' /* Micro text - badges, shortcuts, indicators */'); vars.push(' /* Micro text - badges, shortcuts, indicators */');
vars.push(` --text-micro: ${typography.micro};`); 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(' /* Heading line height and letter spacing */');
vars.push(' --h1-line-height: 1.25rem;'); vars.push(' --h1-line-height: 1.25rem;');
+3
View File
@@ -5,6 +5,8 @@ export const SEMANTIC_TYPOGRAPHY = {
uiLabel: '0.8750rem', uiLabel: '0.8750rem',
meta: '0.875rem', meta: '0.875rem',
micro: '0.875rem', micro: '0.875rem',
/** Settings page / detail-pane title — larger than section headers */
settingsPageTitle: '1.125rem',
} as const; } as const;
export const VSCODE_TYPOGRAPHY = { export const VSCODE_TYPOGRAPHY = {
@@ -15,6 +17,7 @@ export const VSCODE_TYPOGRAPHY = {
uiLabel: '0.8438rem', uiLabel: '0.8438rem',
meta: '0.8438rem', meta: '0.8438rem',
micro: '0.7813rem', micro: '0.7813rem',
settingsPageTitle: '1.0625rem',
} as const; } as const;
export type SemanticTypographyKey = keyof typeof SEMANTIC_TYPOGRAPHY; export type SemanticTypographyKey = keyof typeof SEMANTIC_TYPOGRAPHY;
+1 -1
View File
@@ -39,7 +39,7 @@ const getCurrentDirectory = (): string | null => {
return null; return null;
}; };
const getConfigDirectory = (): string | null => { export const getConfigDirectory = (): string | null => {
try { try {
const projectsStore = useProjectsStore.getState(); const projectsStore = useProjectsStore.getState();
const activeProject = projectsStore.getActiveProject?.(); const activeProject = projectsStore.getActiveProject?.();
@@ -10,7 +10,7 @@ import {
discoverGitCredentials, discoverGitCredentials,
getGlobalGitIdentity getGlobalGitIdentity
} from "@/lib/gitApi"; } from "@/lib/gitApi";
import { updateDesktopSettings } from "@/lib/persistence"; import { reportSettingsSaveState, updateDesktopSettings } from "@/lib/persistence";
import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry"; import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry";
import { runtimeFetch } from "@/lib/runtime-fetch"; import { runtimeFetch } from "@/lib/runtime-fetch";
@@ -206,11 +206,14 @@ export const useGitIdentitiesStore = create<GitIdentitiesStore>()(
icon: profileData.icon || 'branch' icon: profileData.icon || 'branch'
}; };
reportSettingsSaveState('saving');
await createGitIdentity(profile); await createGitIdentity(profile);
reportSettingsSaveState('saved');
await get().loadProfiles(); await get().loadProfiles();
return true; return true;
} catch (error) { } catch (error) {
reportSettingsSaveState('error');
console.error("Failed to create git identity profile:", error); console.error("Failed to create git identity profile:", error);
return false; return false;
} }
@@ -225,11 +228,14 @@ export const useGitIdentitiesStore = create<GitIdentitiesStore>()(
} }
const updated = { ...existing, ...updates }; const updated = { ...existing, ...updates };
reportSettingsSaveState('saving');
await updateGitIdentity(id, updated); await updateGitIdentity(id, updated);
reportSettingsSaveState('saved');
await get().loadProfiles(); await get().loadProfiles();
return true; return true;
} catch (error) { } catch (error) {
reportSettingsSaveState('error');
console.error("Failed to update git identity profile:", error); console.error("Failed to update git identity profile:", error);
return false; return false;
} }
@@ -237,7 +243,9 @@ export const useGitIdentitiesStore = create<GitIdentitiesStore>()(
deleteProfile: async (id) => { deleteProfile: async (id) => {
try { try {
reportSettingsSaveState('saving');
await deleteGitIdentity(id); await deleteGitIdentity(id);
reportSettingsSaveState('saved');
if (get().selectedProfileId === id) { if (get().selectedProfileId === id) {
set({ selectedProfileId: null }); set({ selectedProfileId: null });
@@ -246,6 +254,7 @@ export const useGitIdentitiesStore = create<GitIdentitiesStore>()(
await get().loadProfiles(); await get().loadProfiles();
return true; return true;
} catch (error) { } catch (error) {
reportSettingsSaveState('error');
console.error("Failed to delete git identity profile:", error); console.error("Failed to delete git identity profile:", error);
return false; return false;
} }
+31 -8
View File
@@ -24,6 +24,7 @@
--text-ui-label: 0.8750rem; --text-ui-label: 0.8750rem;
--text-meta: 0.875rem; --text-meta: 0.875rem;
--text-micro: 0.875rem; --text-micro: 0.875rem;
--text-settings-page-title: 1.125rem;
/* Default Light Theme */ /* Default Light Theme */
--background: oklch(0.97 0.02 85); /* Warm sand background */ --background: oklch(0.97 0.02 85); /* Warm sand background */
@@ -45,7 +46,7 @@
--border: oklch(0.85 0.02 70); /* Warm border */ --border: oklch(0.85 0.02 70); /* Warm border */
--input: oklch(0.88 0.02 75); /* Input background */ --input: oklch(0.88 0.02 75); /* Input background */
--ring: oklch(0.65 0.2 55); /* Focus ring color */ --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-1: oklch(0.58 0.15 230); /* Blue */
--chart-2: oklch(0.58 0.15 145); /* Oasis green */ --chart-2: oklch(0.58 0.15 145); /* Oasis green */
--chart-3: oklch(0.65 0.2 55); /* Orange */ --chart-3: oklch(0.65 0.2 55); /* Orange */
@@ -189,26 +190,48 @@
display: list-item !important; display: list-item !important;
} }
/* Remove focus rings globally (except inputs/textareas/dropdowns/selects) */ /* 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"]) { *: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; outline: none !important;
box-shadow: 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; outline: none !important;
box-shadow: 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 { @theme inline {
--radius-none: 0; --radius-none: 0;
--radius-sm: 0.25rem; --radius-sm: 0.25rem; /* 4px */
--radius-md: 0.5rem; --radius-md: 0.4375rem; /* 7px */
--radius-lg: 0.625rem; --radius-lg: 0.5625rem; /* 9px */
--radius-xl: 0.75rem; --radius-xl: 0.75rem; /* 12px */
--radius-full: 9999px; --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 */ /* Override Tailwind spacing scale with custom properties that respect --padding-scale */
--spacing-0: 0; --spacing-0: 0;
--spacing-0\.5: calc(0.125rem * var(--padding-scale, 1)); --spacing-0\.5: calc(0.125rem * var(--padding-scale, 1));

Some files were not shown because too many files have changed in this diff Show More