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:
committed by
GitHub
co-authored by
Serhii Dziupin
Bohdan Triapitsyn
parent
1b402fcb67
commit
17f1b24709
@@ -131,6 +131,7 @@ export const iconSpriteData = {
|
||||
"heart": `<path d="M12.001 4.52853C14.35 2.42 17.98 2.49 20.2426 4.75736C22.5053 7.02472 22.583 10.637 20.4786 12.993L11.9999 21.485L3.52138 12.993C1.41705 10.637 1.49571 7.01901 3.75736 4.75736C6.02157 2.49315 9.64519 2.41687 12.001 4.52853ZM18.827 6.1701C17.3279 4.66794 14.9076 4.60701 13.337 6.01687L12.0019 7.21524L10.6661 6.01781C9.09098 4.60597 6.67506 4.66808 5.17157 6.17157C3.68183 7.66131 3.60704 10.0473 4.97993 11.6232L11.9999 18.6543L19.0201 11.6232C20.3935 10.0467 20.319 7.66525 18.827 6.1701Z" fill="currentColor"/>`,
|
||||
"history": `<path d="M12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12H4C4 16.4183 7.58172 20 12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C9.25022 4 6.82447 5.38734 5.38451 7.50024L8 7.5V9.5H2V3.5H4L3.99989 5.99918C5.82434 3.57075 8.72873 2 12 2ZM13 7L12.9998 11.585L16.2426 14.8284L14.8284 16.2426L10.9998 12.413L11 7H13Z" fill="currentColor"/>`,
|
||||
"home": `<path d="M21 20C21 20.5523 20.5523 21 20 21H4C3.44772 21 3 20.5523 3 20V9.48907C3 9.18048 3.14247 8.88917 3.38606 8.69972L11.3861 2.47749C11.7472 2.19663 12.2528 2.19663 12.6139 2.47749L20.6139 8.69972C20.8575 8.88917 21 9.18048 21 9.48907V20ZM19 19V9.97815L12 4.53371L5 9.97815V19H19Z" fill="currentColor"/>`,
|
||||
"home-office": `<path d="M12.6727 1.61162 20.7999 9H17.8267L12 3.70302 6 9.15757V19.0001H11V21.0001H5C4.44772 21.0001 4 20.5524 4 20.0001V11.0001L1 11.0001 11.3273 1.61162C11.7087 1.26488 12.2913 1.26488 12.6727 1.61162ZM14 11H23V18H14V11ZM16 13V16H21V13H16ZM24 21H13V19H24V21Z" fill="currentColor"/>`,
|
||||
"hourglass-fill": `<path d="M6 4H4V2H20V4H18V6C18 7.61543 17.1838 8.91468 16.1561 9.97667C15.4532 10.703 14.598 11.372 13.7309 12C14.598 12.628 15.4532 13.297 16.1561 14.0233C17.1838 15.0853 18 16.3846 18 18V20H20V22H4V20H6V18C6 16.3846 6.81616 15.0853 7.8439 14.0233C8.54682 13.297 9.40202 12.628 10.2691 12C9.40202 11.372 8.54682 10.703 7.8439 9.97667C6.81616 8.91468 6 7.61543 6 6V4ZM8 4V6C8 6.68514 8.26026 7.33499 8.77131 8H15.2287C15.7397 7.33499 16 6.68514 16 6V4H8ZM12 13.2219C10.9548 13.9602 10.008 14.663 9.2811 15.4142C9.09008 15.6116 8.92007 15.8064 8.77131 16H15.2287C15.0799 15.8064 14.9099 15.6116 14.7189 15.4142C13.992 14.663 13.0452 13.9602 12 13.2219Z" fill="currentColor"/>`,
|
||||
"hourglass": `<path d="M6 4H4V2H20V4H18V6C18 7.61543 17.1838 8.91468 16.1561 9.97667C15.4532 10.703 14.598 11.372 13.7309 12C14.598 12.628 15.4532 13.297 16.1561 14.0233C17.1838 15.0853 18 16.3846 18 18V20H20V22H4V20H6V18C6 16.3846 6.81616 15.0853 7.8439 14.0233C8.54682 13.297 9.40202 12.628 10.2691 12C9.40202 11.372 8.54682 10.703 7.8439 9.97667C6.81616 8.91468 6 7.61543 6 6V4ZM8 4V6C8 6.88457 8.43384 7.71032 9.2811 8.58583C10.008 9.33699 10.9548 10.0398 12 10.7781C13.0452 10.0398 13.992 9.33699 14.7189 8.58583C15.5662 7.71032 16 6.88457 16 6V4H8ZM12 13.2219C10.9548 13.9602 10.008 14.663 9.2811 15.4142C8.43384 16.2897 8 17.1154 8 18V20H16V18C16 17.1154 15.5662 16.2897 14.7189 15.4142C13.992 14.663 13.0452 13.9602 12 13.2219Z" fill="currentColor"/>`,
|
||||
"image-download": `<path d="M21 15V19H24L20 23L16 19H19V15H21ZM21.0078 3C21.5555 3 21.9999 3.44482 22 3.99316V13H20V5H4V18.999L14 9L17 12V14.8291L14 11.8281L6.82715 19H14V21H2.99219C2.44451 21 2.00013 20.5552 2 20.0068V3.99316C2.00013 3.44463 2.45577 3 2.99219 3H21.0078ZM8 7C9.10457 7 10 7.89543 10 9C10 10.1046 9.10457 11 8 11C6.89543 11 6 10.1046 6 9C6 7.89543 6.89543 7 8 7Z" fill="currentColor"/>`,
|
||||
|
||||
@@ -92,10 +92,7 @@ export const AgentSelector: React.FC<AgentSelectorProps> = ({
|
||||
<SelectTrigger
|
||||
id={id}
|
||||
size="lg"
|
||||
className={cn(
|
||||
'max-w-full typography-meta text-foreground !border-border/80 !bg-[var(--surface-subtle)] hover:!bg-[var(--interactive-hover)]/70 data-[popup-open]:!bg-[var(--interactive-active)]/70',
|
||||
className,
|
||||
)}
|
||||
className={cn('max-w-full', className)}
|
||||
>
|
||||
<SelectValue placeholder={t('multirun.agentSelector.placeholder')} />
|
||||
</SelectTrigger>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useGitStore, useGitBranches, useGitLoadingBranches, useGitLoadingStatus, useIsGitRepo } from '@/stores/useGitStore';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
|
||||
@@ -171,7 +172,7 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
|
||||
<SelectTrigger
|
||||
id={id}
|
||||
size="lg"
|
||||
className={className ?? 'w-fit typography-meta text-foreground'}
|
||||
className={cn('min-w-0 max-w-full *:data-[slot=select-value]:truncate', className ?? 'w-fit')}
|
||||
>
|
||||
<SelectValue placeholder={isLoading ? t('multiRun.branchSelector.status.loadingBranches') : t('multiRun.branchSelector.placeholder.selectSourceBranch')} />
|
||||
</SelectTrigger>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { ProviderLogo } from '@/components/ui/ProviderLogo';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
@@ -229,14 +229,13 @@ export const ModelMultiSelect: React.FC<ModelMultiSelectProps> = ({
|
||||
<div className="flex flex-wrap gap-1.5 items-center">
|
||||
{/* Add model button (dropdown trigger) */}
|
||||
<div className={cn('relative', containerClassName)} ref={dropdownRef}>
|
||||
<Button
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-popup-open={isOpen ? '' : undefined}
|
||||
className={cn(
|
||||
CHIP_HEIGHT_CLASS,
|
||||
'!border-border/80 !bg-[var(--surface-subtle)] hover:!bg-[var(--interactive-hover)]/70',
|
||||
dropdownTriggerVariants({ size: 'default' }),
|
||||
'w-fit',
|
||||
addButtonClassName,
|
||||
)}
|
||||
disabled={!canAddModel}
|
||||
@@ -246,7 +245,7 @@ export const ModelMultiSelect: React.FC<ModelMultiSelectProps> = ({
|
||||
>
|
||||
{triggerIcon ?? <Icon name="add" className="h-3.5 w-3.5 mr-1" />}
|
||||
{addButtonLabel ?? t('multirun.modelMultiSelect.actions.addModel')}
|
||||
</Button>
|
||||
</button>
|
||||
|
||||
{isOpen ? (
|
||||
<div
|
||||
@@ -324,8 +323,8 @@ export const ModelMultiSelect: React.FC<ModelMultiSelectProps> = ({
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="chip"
|
||||
className="px-2 gap-1.5 rounded-md !border-border/80 !bg-[var(--surface-subtle)] hover:!bg-[var(--interactive-hover)]/70 typography-meta font-medium text-foreground"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Icon name="brain-ai-3"
|
||||
className={cn(
|
||||
|
||||
@@ -211,7 +211,7 @@ export function MultiRunFusionDialog({
|
||||
maxModels={1}
|
||||
addButtonLabel={selectedModelLabel}
|
||||
showChips={false}
|
||||
addButtonClassName="h-8 w-fit max-w-[min(28rem,calc(100vw-8rem))] justify-start rounded-[9px] [corner-shape:squircle] supports-[corner-shape:squircle]:rounded-[50px] px-3 py-1.5"
|
||||
addButtonClassName="w-fit max-w-[min(28rem,calc(100vw-8rem))] justify-start"
|
||||
dropdownSide="bottom"
|
||||
dropdownClassName="w-[min(28rem,calc(100vw-8rem))]"
|
||||
triggerIcon={providerID ? <ProviderLogo providerId={providerID} className="h-3.5 w-3.5 mr-1" /> : undefined}
|
||||
@@ -220,7 +220,7 @@ export function MultiRunFusionDialog({
|
||||
|
||||
{variantKeys.length > 0 ? (
|
||||
<Select value={variant || '__default__'} onValueChange={(value) => setVariant(value === '__default__' ? '' : value)}>
|
||||
<SelectTrigger size="lg" className="h-8 w-fit rounded-[9px] [corner-shape:squircle] supports-[corner-shape:squircle]:rounded-[50px] !border-border/80 !bg-[var(--surface-subtle)] hover:!bg-[var(--interactive-hover)]/70 typography-meta font-medium text-foreground px-3 py-1.5">
|
||||
<SelectTrigger size="lg" className="w-fit">
|
||||
<Icon name="brain-ai-3" className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<SelectValue>{(value) => value === '__default__' ? t('multirun.modelMultiSelect.variant.default') : value}</SelectValue>
|
||||
</SelectTrigger>
|
||||
@@ -231,7 +231,7 @@ export function MultiRunFusionDialog({
|
||||
</Select>
|
||||
) : null}
|
||||
|
||||
<AgentSelector value={agent} onChange={setAgent} portalToBody className="h-8 rounded-[9px] [corner-shape:squircle] supports-[corner-shape:squircle]:rounded-[50px] px-3 py-1.5" />
|
||||
<AgentSelector value={agent} onChange={setAgent} portalToBody />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { parsePermissionConfig, serializePermissionModel } from './agentPermissionModel';
|
||||
|
||||
const roundTrip = (config: unknown) => serializePermissionModel(parsePermissionConfig(config));
|
||||
|
||||
describe('agent permission source round-trip', () => {
|
||||
test('null and empty configs stay unset', () => {
|
||||
expect(roundTrip(null)).toBeNull();
|
||||
expect(roundTrip(undefined)).toBeNull();
|
||||
expect(roundTrip({})).toBeNull();
|
||||
});
|
||||
|
||||
test('bare action string becomes the * key', () => {
|
||||
expect(roundTrip('ask')).toEqual({ '*': 'ask' });
|
||||
});
|
||||
|
||||
test('flat per-key actions survive verbatim', () => {
|
||||
const config = { '*': 'allow', bash: 'ask', edit: 'deny' };
|
||||
expect(roundTrip(config)).toEqual(config);
|
||||
});
|
||||
|
||||
test('nested pattern maps survive verbatim, including wildcard inside', () => {
|
||||
const config = {
|
||||
'*': 'ask',
|
||||
bash: { '*': 'ask', 'rm -rf *': 'deny', 'git status': 'allow' },
|
||||
external_directory: { '/tmp/**': 'allow' },
|
||||
};
|
||||
expect(roundTrip(config)).toEqual(config);
|
||||
});
|
||||
|
||||
test('pattern-only key without wildcard stays pattern-only (no synthesized default)', () => {
|
||||
const config = { read: { '/secret/**': 'deny' } };
|
||||
expect(roundTrip(config)).toEqual(config);
|
||||
});
|
||||
|
||||
test('explicit allow is preserved — not conflated with unset', () => {
|
||||
expect(roundTrip({ bash: 'allow' })).toEqual({ bash: 'allow' });
|
||||
expect(roundTrip({ '*': 'allow' })).toEqual({ '*': 'allow' });
|
||||
});
|
||||
|
||||
test('unknown junk values are dropped, valid siblings kept', () => {
|
||||
expect(roundTrip({ bash: 'ask', broken: 42, worse: ['deny'] })).toEqual({ bash: 'ask' });
|
||||
});
|
||||
|
||||
test('clearing everything serializes to null (key removed from config)', () => {
|
||||
const model = parsePermissionConfig({ bash: 'ask' });
|
||||
model.keys = {};
|
||||
model.global = null;
|
||||
expect(serializePermissionModel(model)).toBeNull();
|
||||
});
|
||||
|
||||
test('blank patterns are not persisted', () => {
|
||||
const model = parsePermissionConfig({});
|
||||
model.keys.bash = { action: 'ask', patterns: [{ pattern: ' ', action: 'deny' }] };
|
||||
expect(serializePermissionModel(model)).toEqual({ bash: 'ask' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,504 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { toast } from '@/components/ui';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import {
|
||||
useAgentsStore,
|
||||
getConfigDirectory,
|
||||
type AgentWithExtras,
|
||||
} from '@/stores/useAgentsStore';
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsChipGroup,
|
||||
SETTINGS_FIELD_LABEL_CLASS,
|
||||
SETTINGS_HELPER_CLASS,
|
||||
} from '@/components/sections/shared/SettingsSection';
|
||||
import { SettingsInfoHint } from '@/components/sections/shared/SettingsInfoHint';
|
||||
import {
|
||||
ACTIONS,
|
||||
cloneModel,
|
||||
emptyModel,
|
||||
isAction,
|
||||
modelsEqual,
|
||||
parsePermissionConfig,
|
||||
serializePermissionModel,
|
||||
type Action,
|
||||
type EffectiveRule,
|
||||
type PermissionModel,
|
||||
} from './agentPermissionModel';
|
||||
|
||||
/**
|
||||
* Source-of-truth permissions editor.
|
||||
*
|
||||
* This component edits EXACTLY the agent's own `permission` map as stored in
|
||||
* its markdown frontmatter / opencode.json entry — never the resolved rules
|
||||
* that `/agent` returns (those already include global config and one-off
|
||||
* session grants, and writing them back is what used to corrupt configs).
|
||||
*
|
||||
* - "Inherit" means the key is absent from the agent's config; the effective
|
||||
* action (from the resolved view) is shown as a hint.
|
||||
* - Saving PATCHes only `{ permission }`, and the server writes it verbatim.
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* Permission keys that exist beyond plain tool ids (virtual capabilities).
|
||||
* Shown so they are discoverable; nothing is written unless set explicitly.
|
||||
*/
|
||||
const VIRTUAL_PERMISSION_KEYS = [
|
||||
'edit',
|
||||
'external_directory',
|
||||
'doom_loop',
|
||||
'plan_enter',
|
||||
'plan_exit',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Keys where opencode matches pattern rules (per docs: these accept either a
|
||||
* bare action or a pattern map). Everything else is action-only — the pattern
|
||||
* UI is hidden unless the config already contains patterns for the key.
|
||||
*/
|
||||
const PATTERN_CAPABLE_KEYS = new Set([
|
||||
'read',
|
||||
'edit',
|
||||
'glob',
|
||||
'grep',
|
||||
'list',
|
||||
'bash',
|
||||
'task',
|
||||
'external_directory',
|
||||
'lsp',
|
||||
'skill',
|
||||
]);
|
||||
|
||||
/** Tool ids folded into broader permission keys — never shown standalone. */
|
||||
const FOLDED_TOOL_IDS = new Set(['write', 'patch', 'apply_patch', 'multiedit', 'invalid']);
|
||||
|
||||
const formatKeyLabel = (key: string): string =>
|
||||
key
|
||||
.split(/[_-]/)
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ');
|
||||
|
||||
interface AgentPermissionsEditorProps {
|
||||
agent: AgentWithExtras;
|
||||
}
|
||||
|
||||
export const AgentPermissionsEditor: React.FC<AgentPermissionsEditorProps> = ({ agent }) => {
|
||||
const { t } = useI18n();
|
||||
const updateAgent = useAgentsStore((state) => state.updateAgent);
|
||||
|
||||
const [baseline, setBaseline] = React.useState<PermissionModel>(emptyModel);
|
||||
const [model, setModel] = React.useState<PermissionModel>(emptyModel);
|
||||
const [isLoading, setIsLoading] = React.useState(true);
|
||||
const [loadFailed, setLoadFailed] = React.useState(false);
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
const [expandedKeys, setExpandedKeys] = React.useState<Record<string, boolean>>({});
|
||||
const [toolIds, setToolIds] = React.useState<string[]>([]);
|
||||
const [customKeyDraft, setCustomKeyDraft] = React.useState('');
|
||||
const [reloadToken, setReloadToken] = React.useState(0);
|
||||
|
||||
const agentName = agent.name;
|
||||
|
||||
// --- Load the SOURCE permission map (the agent's own config file). ---
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
setIsLoading(true);
|
||||
setLoadFailed(false);
|
||||
void (async () => {
|
||||
try {
|
||||
const directory = getConfigDirectory();
|
||||
const query = directory ? `?directory=${encodeURIComponent(directory)}` : '';
|
||||
const response = await runtimeFetch(`/api/config/agents/${encodeURIComponent(agentName)}/config${query}`, {
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
...(directory ? { 'x-opencode-directory': directory } : {}),
|
||||
},
|
||||
});
|
||||
if (!response.ok) throw new Error(String(response.status));
|
||||
const data = (await response.json().catch(() => null)) as { config?: { permission?: unknown } } | null;
|
||||
if (cancelled) return;
|
||||
const parsed = parsePermissionConfig(data?.config?.permission);
|
||||
setBaseline(cloneModel(parsed));
|
||||
setModel(parsed);
|
||||
} catch {
|
||||
if (!cancelled) setLoadFailed(true);
|
||||
} finally {
|
||||
if (!cancelled) setIsLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [agentName, reloadToken]);
|
||||
|
||||
// --- Known tool ids for the key list (display only). ---
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const ids = await opencodeClient.listToolIds({ directory: getConfigDirectory() });
|
||||
if (!cancelled && Array.isArray(ids)) {
|
||||
setToolIds(ids.filter((id) => typeof id === 'string' && !FOLDED_TOOL_IDS.has(id)));
|
||||
}
|
||||
} catch {
|
||||
// tool ids are additive display data — the editor works without them
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [agentName]);
|
||||
|
||||
// --- Effective rules from the resolved view (read-only hints). ---
|
||||
const effectiveRules = React.useMemo<EffectiveRule[]>(() => {
|
||||
const raw = (agent as { permission?: unknown }).permission;
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const rules: EffectiveRule[] = [];
|
||||
for (const entry of raw) {
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
const { permission, pattern, action } = entry as Record<string, unknown>;
|
||||
if (typeof permission === 'string' && typeof pattern === 'string' && isAction(action)) {
|
||||
rules.push({ permission, pattern, action });
|
||||
}
|
||||
}
|
||||
return rules;
|
||||
}, [agent]);
|
||||
|
||||
const effectiveFor = React.useCallback((key: string): Action | null => {
|
||||
const exact = effectiveRules.find((rule) => rule.permission === key && rule.pattern === '*');
|
||||
if (exact) return exact.action;
|
||||
const wildcard = effectiveRules.find((rule) => rule.permission === '*' && rule.pattern === '*');
|
||||
return wildcard ? wildcard.action : null;
|
||||
}, [effectiveRules]);
|
||||
|
||||
/** Session/runtime-granted rules that are NOT part of the saved config. */
|
||||
const runtimeRulesFor = React.useCallback((key: string): EffectiveRule[] => {
|
||||
const saved = model.keys[key]?.patterns ?? [];
|
||||
const savedPatterns = new Set(saved.map((rule) => rule.pattern));
|
||||
return effectiveRules.filter(
|
||||
(rule) => rule.permission === key && rule.pattern !== '*' && !savedPatterns.has(rule.pattern),
|
||||
);
|
||||
}, [effectiveRules, model.keys]);
|
||||
|
||||
// --- Displayed key list: tools + virtual keys + anything set in the config. ---
|
||||
const displayKeys = React.useMemo(() => {
|
||||
const keys = new Set<string>();
|
||||
for (const id of toolIds) keys.add(id);
|
||||
for (const key of VIRTUAL_PERMISSION_KEYS) keys.add(key);
|
||||
for (const key of Object.keys(model.keys)) keys.add(key);
|
||||
// `edit` covers write/edit/apply_patch — the folded ids never show.
|
||||
for (const folded of FOLDED_TOOL_IDS) keys.delete(folded);
|
||||
return Array.from(keys).sort((a, b) => a.localeCompare(b));
|
||||
}, [toolIds, model.keys]);
|
||||
|
||||
const isDirty = React.useMemo(() => !modelsEqual(model, baseline), [model, baseline]);
|
||||
|
||||
// --- Mutators ---
|
||||
const setGlobal = (action: Action | null) => {
|
||||
setModel((current) => ({ ...current, global: action }));
|
||||
};
|
||||
|
||||
const setKeyAction = (key: string, action: Action | null) => {
|
||||
setModel((current) => {
|
||||
const next = cloneModel(current);
|
||||
const state = next.keys[key] ?? { action: null, patterns: [] };
|
||||
state.action = action;
|
||||
if (state.action === null && state.patterns.length === 0) {
|
||||
delete next.keys[key];
|
||||
} else {
|
||||
next.keys[key] = state;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const setPattern = (key: string, index: number, pattern: string, action: Action) => {
|
||||
setModel((current) => {
|
||||
const next = cloneModel(current);
|
||||
const state = next.keys[key] ?? { action: null, patterns: [] };
|
||||
state.patterns[index] = { pattern, action };
|
||||
next.keys[key] = state;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const addPattern = (key: string) => {
|
||||
setModel((current) => {
|
||||
const next = cloneModel(current);
|
||||
const state = next.keys[key] ?? { action: null, patterns: [] };
|
||||
state.patterns.push({ pattern: '', action: 'allow' });
|
||||
next.keys[key] = state;
|
||||
return next;
|
||||
});
|
||||
setExpandedKeys((current) => ({ ...current, [key]: true }));
|
||||
};
|
||||
|
||||
const removePattern = (key: string, index: number) => {
|
||||
setModel((current) => {
|
||||
const next = cloneModel(current);
|
||||
const state = next.keys[key];
|
||||
if (!state) return current;
|
||||
state.patterns.splice(index, 1);
|
||||
if (state.action === null && state.patterns.length === 0) {
|
||||
delete next.keys[key];
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const addCustomKey = () => {
|
||||
const key = customKeyDraft.trim();
|
||||
if (!key || key === '*') return;
|
||||
setModel((current) => {
|
||||
if (current.keys[key]) return current;
|
||||
const next = cloneModel(current);
|
||||
next.keys[key] = { action: 'ask', patterns: [] };
|
||||
return next;
|
||||
});
|
||||
setExpandedKeys((current) => ({ ...current, [key]: true }));
|
||||
setCustomKeyDraft('');
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const permission = serializePermissionModel(model);
|
||||
const result = await updateAgent(agentName, { permission });
|
||||
if (result.ok) {
|
||||
setBaseline(cloneModel(model));
|
||||
toast.success(
|
||||
result.requiresManualRestart
|
||||
? t('settings.agents.page.permissionsEditor.toast.savedRestartRequired')
|
||||
: t('settings.agents.page.permissionsEditor.toast.saved'),
|
||||
);
|
||||
} else {
|
||||
toast.error(t('settings.agents.page.permissionsEditor.toast.saveFailed'));
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscard = () => {
|
||||
setModel(cloneModel(baseline));
|
||||
};
|
||||
|
||||
const actionLabel = (action: Action): string => t(
|
||||
action === 'allow'
|
||||
? 'settings.agents.page.permissionsEditor.action.allow'
|
||||
: action === 'ask'
|
||||
? 'settings.agents.page.permissionsEditor.action.ask'
|
||||
: 'settings.agents.page.permissionsEditor.action.deny',
|
||||
);
|
||||
|
||||
const inheritLabel = t('settings.agents.page.permissionsEditor.action.inherit');
|
||||
const defaultChipLabel = t('settings.agents.page.permissionsEditor.action.default');
|
||||
|
||||
const chipOptions = (unsetLabel?: string) => [
|
||||
...(unsetLabel ? [{ value: 'inherit', label: unsetLabel }] : []),
|
||||
...ACTIONS.map((action) => ({ value: action, label: actionLabel(action) })),
|
||||
];
|
||||
|
||||
const renderActionChips = (
|
||||
value: Action | null,
|
||||
onChange: (action: Action | null) => void,
|
||||
ariaLabel: string,
|
||||
unsetLabel: string = inheritLabel,
|
||||
) => (
|
||||
<SettingsChipGroup
|
||||
value={value ?? 'inherit'}
|
||||
options={chipOptions(unsetLabel)}
|
||||
onChange={(next) => onChange(next === 'inherit' ? null : (next as Action))}
|
||||
aria-label={ariaLabel}
|
||||
/>
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<SettingsSection title={t('settings.agents.page.section.toolPermissions')}>
|
||||
<p className={SETTINGS_HELPER_CLASS}>{t('common.loading')}</p>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
if (loadFailed) {
|
||||
return (
|
||||
<SettingsSection title={t('settings.agents.page.section.toolPermissions')}>
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="typography-meta text-[var(--status-error)]">
|
||||
{t('settings.agents.page.permissionsEditor.state.loadFailed')}
|
||||
</p>
|
||||
<Button variant="outline" size="xs" onClick={() => setReloadToken((token) => token + 1)}>
|
||||
{t('settings.agents.page.permissionsEditor.actions.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t('settings.agents.page.section.toolPermissions')}
|
||||
settingsItem="agents.permissions"
|
||||
info={t('settings.agents.page.permissionsEditor.sectionInfo')}
|
||||
headerAction={isDirty ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="xs" className="!font-normal" onClick={handleDiscard} disabled={isSaving}>
|
||||
{t('settings.agents.page.permissionsEditor.actions.discard')}
|
||||
</Button>
|
||||
<Button size="xs" className="!font-normal" onClick={() => void handleSave()} disabled={isSaving}>
|
||||
{isSaving ? t('settings.common.actions.saving') : t('settings.agents.page.permissionsEditor.actions.save')}
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined}
|
||||
contentClassName="space-y-4"
|
||||
>
|
||||
{/* Agent default (the `*` key) */}
|
||||
<div className="flex flex-col gap-2 pb-2 @xl:flex-row @xl:items-center @xl:justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={SETTINGS_FIELD_LABEL_CLASS}>
|
||||
{t('settings.agents.page.permissionsEditor.defaultLabel')}
|
||||
</span>
|
||||
<SettingsInfoHint>{t('settings.agents.page.permissionsEditor.defaultInfo')}</SettingsInfoHint>
|
||||
{model.global === null && (
|
||||
<span className="typography-micro text-muted-foreground">
|
||||
{t('settings.agents.page.permissionsEditor.effectiveHint', { action: actionLabel(effectiveFor('*') ?? 'allow') })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{renderActionChips(model.global, setGlobal, t('settings.agents.page.permissionsEditor.defaultAria'), defaultChipLabel)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{displayKeys.map((key) => {
|
||||
const state = model.keys[key] ?? { action: null, patterns: [] };
|
||||
const runtimeRules = runtimeRulesFor(key);
|
||||
const supportsPatterns = PATTERN_CAPABLE_KEYS.has(key) || state.patterns.length > 0;
|
||||
const hasDetails = supportsPatterns || runtimeRules.length > 0;
|
||||
const isExpanded = expandedKeys[key] === true;
|
||||
const effective = effectiveFor(key);
|
||||
|
||||
return (
|
||||
<div key={key} className="border-t border-border/40 py-2">
|
||||
<div className="flex flex-col gap-2 @xl:flex-row @xl:items-center @xl:justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={hasDetails ? () => setExpandedKeys((current) => ({ ...current, [key]: !isExpanded })) : undefined}
|
||||
className={cn('flex min-w-0 items-center gap-1.5 text-left', !hasDetails && 'cursor-default')}
|
||||
aria-expanded={hasDetails ? isExpanded : undefined}
|
||||
>
|
||||
<Icon
|
||||
name={isExpanded && hasDetails ? 'arrow-down-s' : 'arrow-right-s'}
|
||||
className={cn('h-3.5 w-3.5 shrink-0 text-muted-foreground', !hasDetails && 'opacity-0')}
|
||||
/>
|
||||
<span className={SETTINGS_FIELD_LABEL_CLASS}>{formatKeyLabel(key)}</span>
|
||||
<span className="typography-micro font-mono text-muted-foreground/70">{key}</span>
|
||||
{state.action === null && effective !== null && (
|
||||
<span className="typography-micro text-muted-foreground">
|
||||
{t('settings.agents.page.permissionsEditor.effectiveHint', { action: actionLabel(effective) })}
|
||||
</span>
|
||||
)}
|
||||
{state.patterns.length > 0 && (
|
||||
<span className="typography-micro rounded bg-muted px-1 text-muted-foreground">
|
||||
{t('settings.agents.page.permissionsEditor.ruleCount', { count: String(state.patterns.length) })}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{renderActionChips(
|
||||
state.action,
|
||||
(action) => setKeyAction(key, action),
|
||||
t('settings.agents.page.permissionsEditor.keyAria', { key }),
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isExpanded && hasDetails && (
|
||||
<div className="mt-2 space-y-2 pl-5">
|
||||
{state.patterns.map((rule, index) => (
|
||||
<div key={index} className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
value={rule.pattern}
|
||||
onChange={(event) => setPattern(key, index, event.target.value, rule.action)}
|
||||
placeholder={t('settings.agents.page.permissionsEditor.patternPlaceholder')}
|
||||
className="h-8 w-full max-w-[24rem] min-w-0 flex-1 font-mono text-xs"
|
||||
/>
|
||||
<SettingsChipGroup
|
||||
value={rule.action}
|
||||
options={chipOptions()}
|
||||
onChange={(next) => setPattern(key, index, rule.pattern, next as Action)}
|
||||
aria-label={t('settings.agents.page.permissionsEditor.patternActionAria', { key })}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={() => removePattern(key, index)}
|
||||
aria-label={t('settings.agents.page.permissionsEditor.actions.removeRuleAria')}
|
||||
>
|
||||
<Icon name="close" className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{supportsPatterns && (
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => addPattern(key)}>
|
||||
<Icon name="add" className="mr-1 h-3.5 w-3.5" />
|
||||
{t('settings.agents.page.permissionsEditor.actions.addRule')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{runtimeRules.length > 0 && (
|
||||
<div className="space-y-1 pt-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="typography-micro font-medium text-muted-foreground">
|
||||
{t('settings.agents.page.permissionsEditor.sessionRulesTitle')}
|
||||
</span>
|
||||
<SettingsInfoHint>
|
||||
{t('settings.agents.page.permissionsEditor.sessionRulesInfo')}
|
||||
</SettingsInfoHint>
|
||||
</div>
|
||||
{runtimeRules.map((rule) => (
|
||||
<div key={`${rule.pattern}-${rule.action}`} className="flex items-center gap-2">
|
||||
<span className="typography-micro min-w-0 flex-1 truncate font-mono text-muted-foreground/70">
|
||||
{rule.pattern}
|
||||
</span>
|
||||
<span className="typography-micro text-muted-foreground">{actionLabel(rule.action)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Custom permission key */}
|
||||
<div className="flex flex-wrap items-center gap-2 border-t border-border/40 pt-3">
|
||||
<Input
|
||||
value={customKeyDraft}
|
||||
onChange={(event) => setCustomKeyDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
addCustomKey();
|
||||
}
|
||||
}}
|
||||
placeholder={t('settings.agents.page.permissionsEditor.customKeyPlaceholder')}
|
||||
className="h-8 w-full max-w-[16rem] font-mono text-xs"
|
||||
/>
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={addCustomKey} disabled={!customKeyDraft.trim()}>
|
||||
<Icon name="add" className="mr-1 h-3.5 w-3.5" />
|
||||
{t('settings.agents.page.permissionsEditor.actions.addKey')}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,7 @@ import { SettingsProjectSelector } from '@/components/sections/shared/SettingsPr
|
||||
import { SidebarGroup } from '@/components/sections/shared/SidebarGroup';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { SETTINGS_PANEL_TITLE_CLASS } from '@/components/sections/shared/SettingsSection';
|
||||
|
||||
interface AgentsSidebarProps {
|
||||
onItemSelect?: () => void;
|
||||
@@ -350,7 +351,7 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
|
||||
return (
|
||||
<div className={cn('flex h-full flex-col', bgClass)}>
|
||||
<div className="border-b px-3 pt-4 pb-3">
|
||||
<h2 className="text-base font-semibold text-foreground mb-3">{t('settings.agents.sidebar.title')}</h2>
|
||||
<h2 className={`${SETTINGS_PANEL_TITLE_CLASS} mb-3`}>{t('settings.agents.sidebar.title')}</h2>
|
||||
<SettingsProjectSelector className="mb-3" />
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.agents.sidebar.total', { count: visibleAgents.length })}</span>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useOpenCodeReadiness } from '@/hooks/useOpenCodeReadiness';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { ModelPickerList, type ModelPickerEntry, type ModelPickerProvider } from '@/components/model-picker/ModelPickerList';
|
||||
@@ -90,7 +91,15 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
||||
}), [placeholder, t]);
|
||||
|
||||
const selectedModel = providerId && modelId ? { providerID: providerId, modelID: modelId } : null;
|
||||
const triggerLabel = providerId && modelId ? `${providerId}/${modelId}` : (placeholder || t('settings.agents.modelSelector.notSelected'));
|
||||
// Show the model's display name (as in the picker list), not the raw provider/model id.
|
||||
const triggerLabel = React.useMemo(() => {
|
||||
if (!providerId || !modelId) {
|
||||
return placeholder || t('settings.agents.modelSelector.notSelected');
|
||||
}
|
||||
const provider = providers.find((entry) => entry.id === providerId);
|
||||
const model = provider?.models?.find((entry) => entry.id === modelId);
|
||||
return (typeof model?.name === 'string' && model.name.trim()) || modelId;
|
||||
}, [modelId, placeholder, providerId, providers, t]);
|
||||
|
||||
const picker = (
|
||||
<ModelPickerList
|
||||
@@ -123,8 +132,8 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
||||
onClick={isReady ? () => setIsMobilePanelOpen(true) : undefined}
|
||||
disabled={!isReady}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between gap-2 rounded-lg border border-border/40 bg-[var(--surface-elevated)] px-2 py-1.5 text-left',
|
||||
!isReady && 'opacity-60 cursor-not-allowed',
|
||||
dropdownTriggerVariants(),
|
||||
'w-full',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -158,7 +167,8 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
||||
<DropdownMenu open={isReady && isDropdownOpen} onOpenChange={isReady ? setIsDropdownOpen : undefined}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div className={cn(
|
||||
'border-input data-[placeholder]:text-muted-foreground flex min-w-0 items-center justify-between gap-2 rounded-lg border bg-transparent px-2 py-2 typography-ui-label whitespace-nowrap shadow-none outline-none hover:bg-interactive-hover data-[popup-open]:bg-interactive-active h-6 w-fit',
|
||||
dropdownTriggerVariants({ size: 'sm' }),
|
||||
'min-w-0 w-fit',
|
||||
!isReady && 'opacity-60 cursor-not-allowed',
|
||||
className,
|
||||
)}>
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { PermissionConfig } from '@opencode-ai/sdk/v2';
|
||||
|
||||
/**
|
||||
* Editor model for an agent's OWN `permission` map (the source config), with
|
||||
* lossless parse/serialize between the persisted shape and the model.
|
||||
* "null" action = key/pattern not set → inherited from global config/defaults.
|
||||
*/
|
||||
export type Action = 'allow' | 'ask' | 'deny';
|
||||
|
||||
export const ACTIONS: Action[] = ['allow', 'ask', 'deny'];
|
||||
|
||||
export interface KeyState {
|
||||
/** Explicit action for the key's `*` pattern; null = not set (inherit). */
|
||||
action: Action | null;
|
||||
/** Non-wildcard pattern rules, in stable order. */
|
||||
patterns: Array<{ pattern: string; action: Action }>;
|
||||
}
|
||||
|
||||
export interface PermissionModel {
|
||||
/** Explicit agent-level default (the `*` key); null = inherit. */
|
||||
global: Action | null;
|
||||
keys: Record<string, KeyState>;
|
||||
}
|
||||
|
||||
/** Effective (resolved) rule as returned by /agent. */
|
||||
export interface EffectiveRule {
|
||||
permission: string;
|
||||
pattern: string;
|
||||
action: Action;
|
||||
}
|
||||
|
||||
export const isAction = (value: unknown): value is Action =>
|
||||
value === 'allow' || value === 'ask' || value === 'deny';
|
||||
|
||||
export const emptyModel = (): PermissionModel => ({ global: null, keys: {} });
|
||||
|
||||
/** Parse the persisted PermissionConfig into the editor model, verbatim. */
|
||||
export const parsePermissionConfig = (config: unknown): PermissionModel => {
|
||||
const model = emptyModel();
|
||||
if (config == null) return model;
|
||||
if (typeof config === 'string') {
|
||||
if (isAction(config)) model.global = config;
|
||||
return model;
|
||||
}
|
||||
if (typeof config !== 'object' || Array.isArray(config)) return model;
|
||||
|
||||
for (const [key, value] of Object.entries(config as Record<string, unknown>)) {
|
||||
if (key === '*') {
|
||||
if (isAction(value)) model.global = value;
|
||||
continue;
|
||||
}
|
||||
const state: KeyState = { action: null, patterns: [] };
|
||||
if (isAction(value)) {
|
||||
state.action = value;
|
||||
} else if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
for (const [pattern, action] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (!isAction(action)) continue;
|
||||
if (pattern === '*') {
|
||||
state.action = action;
|
||||
} else {
|
||||
state.patterns.push({ pattern, action });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
model.keys[key] = state;
|
||||
}
|
||||
return model;
|
||||
};
|
||||
|
||||
/** Serialize the editor model back to the persisted shape (canonical form). */
|
||||
export const serializePermissionModel = (model: PermissionModel): PermissionConfig | null => {
|
||||
const result: Record<string, Action | Record<string, Action>> = {};
|
||||
if (model.global !== null) {
|
||||
result['*'] = model.global;
|
||||
}
|
||||
for (const [key, state] of Object.entries(model.keys)) {
|
||||
const patterns = state.patterns.filter((rule) => rule.pattern.trim().length > 0);
|
||||
if (state.action !== null && patterns.length === 0) {
|
||||
result[key] = state.action;
|
||||
} else if (patterns.length > 0) {
|
||||
const nested: Record<string, Action> = {};
|
||||
if (state.action !== null) nested['*'] = state.action;
|
||||
for (const rule of patterns) nested[rule.pattern] = rule.action;
|
||||
result[key] = nested;
|
||||
}
|
||||
// action === null && no patterns → key omitted entirely (inherit)
|
||||
}
|
||||
return Object.keys(result).length > 0 ? (result as PermissionConfig) : null;
|
||||
};
|
||||
|
||||
export const modelsEqual = (a: PermissionModel, b: PermissionModel): boolean =>
|
||||
JSON.stringify(serializePermissionModel(a)) === JSON.stringify(serializePermissionModel(b));
|
||||
|
||||
export const cloneModel = (model: PermissionModel): PermissionModel =>
|
||||
JSON.parse(JSON.stringify(model)) as PermissionModel;
|
||||
@@ -1,11 +1,9 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { toast } from '@/components/ui';
|
||||
import { useI18n, type I18nKey } from '@/lib/i18n';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { reportSettingsSaveState } from '@/lib/persistence';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -13,7 +11,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import {
|
||||
getResponseStylePresetInstructions,
|
||||
isResponseStylePreset,
|
||||
@@ -22,6 +19,14 @@ import {
|
||||
} from '@/lib/responseStyle';
|
||||
import type { DesktopSettings } from '@/lib/desktop';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsCheckboxRow,
|
||||
SettingsFieldRow,
|
||||
SETTINGS_SELECT_ROW_TRIGGER_CLASS,
|
||||
SETTINGS_SELECT_SIZE,
|
||||
} from '@/components/sections/shared/SettingsSection';
|
||||
|
||||
const AGENTS_MD_PATH = '~/.config/opencode/AGENTS.md';
|
||||
|
||||
@@ -70,17 +75,24 @@ const RESPONSE_STYLE_OPTION_LABEL_KEYS: Record<ResponseStylePreset, I18nKey> = {
|
||||
};
|
||||
|
||||
const saveBehaviorSetting = async (settings: Partial<DesktopSettings>, fallbackError: string) => {
|
||||
const response = await runtimeFetch('/api/config/settings', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
reportSettingsSaveState('saving');
|
||||
try {
|
||||
const response = await runtimeFetch('/api/config/settings', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await readApiError(response, fallbackError));
|
||||
if (!response.ok) {
|
||||
throw new Error(await readApiError(response, fallbackError));
|
||||
}
|
||||
reportSettingsSaveState('saved');
|
||||
} catch (error) {
|
||||
reportSettingsSaveState('error');
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -235,126 +247,101 @@ export const BehaviorPage: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollableOverlay outerClassName="h-full" className="w-full">
|
||||
<div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8 space-y-6">
|
||||
<div className="space-y-1">
|
||||
<h2 className="typography-ui-header font-semibold text-foreground">
|
||||
{t('settings.behavior.page.title')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div data-settings-item="behavior.system-prompt">
|
||||
<div className="mb-1 px-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
{t('settings.behavior.page.section.systemPrompt')}
|
||||
</h3>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium text-foreground">
|
||||
{t('settings.behavior.page.warning.title')}
|
||||
</p>
|
||||
<p>
|
||||
{t('settings.behavior.page.warning.description', { path: AGENTS_MD_PATH })}
|
||||
</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<SettingsPageLayout
|
||||
title={t('settings.behavior.page.title')}
|
||||
description={t('settings.page.behavior.description')}
|
||||
showSaveStatus
|
||||
>
|
||||
<SettingsSection
|
||||
title={t('settings.behavior.page.section.systemPrompt')}
|
||||
info={(
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium text-foreground">
|
||||
{t('settings.behavior.page.warning.title')}
|
||||
</p>
|
||||
<p>
|
||||
{t('settings.behavior.page.warning.description', { path: AGENTS_MD_PATH })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
divider={false}
|
||||
settingsItem="behavior.system-prompt"
|
||||
contentClassName="space-y-3"
|
||||
>
|
||||
<Textarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder={t('settings.behavior.page.field.systemPromptPlaceholder')}
|
||||
rows={12}
|
||||
disabled={isLoading}
|
||||
outerClassName="min-h-[160px] max-h-[70vh]"
|
||||
className="w-full font-mono typography-meta bg-transparent"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || !isPromptDirty || isLoading}
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
>
|
||||
{isSaving ? t('settings.common.actions.saving') : t('settings.common.actions.saveChanges')}
|
||||
</Button>
|
||||
</SettingsSection>
|
||||
|
||||
<section className="px-2 pb-2 pt-0 space-y-3">
|
||||
<Textarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder={t('settings.behavior.page.field.systemPromptPlaceholder')}
|
||||
rows={12}
|
||||
disabled={isLoading}
|
||||
outerClassName="min-h-[160px] max-h-[70vh]"
|
||||
className="w-full font-mono typography-meta bg-transparent"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || !isPromptDirty || isLoading}
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
>
|
||||
{isSaving ? t('settings.common.actions.saving') : t('settings.common.actions.saveChanges')}
|
||||
</Button>
|
||||
</section>
|
||||
</div>
|
||||
<SettingsSection
|
||||
title={t('settings.behavior.page.section.responseStyle')}
|
||||
info={t('settings.behavior.page.responseStyle.tooltip')}
|
||||
settingsItem="behavior.response-style"
|
||||
contentClassName="space-y-3"
|
||||
>
|
||||
<SettingsCheckboxRow
|
||||
checked={responseStyleEnabled}
|
||||
onChange={setResponseStyleEnabled}
|
||||
disabled={isLoading}
|
||||
label={t('settings.behavior.page.responseStyle.enable')}
|
||||
ariaLabel={t('settings.behavior.page.responseStyle.enableAria')}
|
||||
/>
|
||||
|
||||
<div data-settings-item="behavior.response-style">
|
||||
<div className="mb-1 px-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
{t('settings.behavior.page.section.responseStyle')}
|
||||
</h3>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
{t('settings.behavior.page.responseStyle.tooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="px-2 pb-2 pt-0 space-y-3">
|
||||
<label className="flex items-center gap-2 typography-ui-label text-foreground">
|
||||
<Checkbox
|
||||
checked={responseStyleEnabled}
|
||||
onChange={setResponseStyleEnabled}
|
||||
disabled={isLoading}
|
||||
ariaLabel={t('settings.behavior.page.responseStyle.enableAria')}
|
||||
/>
|
||||
{t('settings.behavior.page.responseStyle.enable')}
|
||||
</label>
|
||||
|
||||
<Select<ResponseStyleValue>
|
||||
value={responseStylePreset}
|
||||
onValueChange={(value) => setResponseStylePreset(value)}
|
||||
disabled={isLoading || !responseStyleEnabled}
|
||||
>
|
||||
<SelectTrigger className="w-full sm:w-56" size="lg">
|
||||
<SelectValue>
|
||||
{(value) => {
|
||||
if (value === 'custom') return t('settings.behavior.page.responseStyle.option.custom');
|
||||
if (isResponseStylePreset(value)) return t(RESPONSE_STYLE_OPTION_LABEL_KEYS[value]);
|
||||
return null;
|
||||
}}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{RESPONSE_STYLE_PRESETS.map((preset) => (
|
||||
<SelectItem key={preset} value={preset}>
|
||||
{t(RESPONSE_STYLE_OPTION_LABEL_KEYS[preset])}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="custom">
|
||||
{t('settings.behavior.page.responseStyle.option.custom')}
|
||||
<SettingsFieldRow
|
||||
label={t('settings.behavior.page.responseStyle.preset')}
|
||||
alignEnd={false}
|
||||
>
|
||||
<Select<ResponseStyleValue>
|
||||
value={responseStylePreset}
|
||||
onValueChange={(value) => setResponseStylePreset(value)}
|
||||
disabled={isLoading || !responseStyleEnabled}
|
||||
>
|
||||
<SelectTrigger size={SETTINGS_SELECT_SIZE} className={SETTINGS_SELECT_ROW_TRIGGER_CLASS}>
|
||||
<SelectValue>
|
||||
{(value) => {
|
||||
if (value === 'custom') return t('settings.behavior.page.responseStyle.option.custom');
|
||||
if (isResponseStylePreset(value)) return t(RESPONSE_STYLE_OPTION_LABEL_KEYS[value]);
|
||||
return null;
|
||||
}}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{RESPONSE_STYLE_PRESETS.map((preset) => (
|
||||
<SelectItem key={preset} value={preset}>
|
||||
{t(RESPONSE_STYLE_OPTION_LABEL_KEYS[preset])}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
))}
|
||||
<SelectItem value="custom">
|
||||
{t('settings.behavior.page.responseStyle.option.custom')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingsFieldRow>
|
||||
|
||||
<Textarea
|
||||
value={responseStylePreview}
|
||||
onChange={(event) => setResponseStyleCustomInstructions(event.target.value)}
|
||||
placeholder={t('settings.behavior.page.responseStyle.customPlaceholder')}
|
||||
rows={5}
|
||||
disabled={isLoading || !responseStyleEnabled || responseStylePreset !== 'custom'}
|
||||
outerClassName="min-h-[120px]"
|
||||
className="w-full font-mono typography-meta bg-transparent"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</ScrollableOverlay>
|
||||
<Textarea
|
||||
value={responseStylePreview}
|
||||
onChange={(event) => setResponseStyleCustomInstructions(event.target.value)}
|
||||
placeholder={t('settings.behavior.page.responseStyle.customPlaceholder')}
|
||||
rows={5}
|
||||
disabled={isLoading || !responseStyleEnabled || responseStylePreset !== 'custom'}
|
||||
outerClassName="min-h-[120px]"
|
||||
className="w-full font-mono typography-meta bg-transparent"
|
||||
/>
|
||||
</SettingsSection>
|
||||
</SettingsPageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger';
|
||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
@@ -132,8 +133,8 @@ export const AgentSelector: React.FC<AgentSelectorProps> = ({
|
||||
onClick={isReady ? () => setIsMobilePanelOpen(true) : undefined}
|
||||
disabled={!isReady}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between gap-2 rounded-lg border border-border/40 bg-background/95 px-2 py-1.5 text-left',
|
||||
!isReady && 'opacity-60 cursor-not-allowed',
|
||||
dropdownTriggerVariants(),
|
||||
'w-full',
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -156,7 +157,8 @@ export const AgentSelector: React.FC<AgentSelectorProps> = ({
|
||||
</button>
|
||||
) : !isReady ? (
|
||||
<div className={cn(
|
||||
'flex items-center gap-2 px-2 rounded-lg bg-interactive-selection/20 border border-border/20 h-6 w-fit opacity-60',
|
||||
dropdownTriggerVariants({ size: 'sm' }),
|
||||
'w-fit opacity-60',
|
||||
className
|
||||
)}>
|
||||
<Icon name="loader-4" className="h-3 w-3 animate-spin text-muted-foreground flex-shrink-0" />
|
||||
@@ -168,11 +170,12 @@ export const AgentSelector: React.FC<AgentSelectorProps> = ({
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div className={cn(
|
||||
'flex items-center gap-2 px-2 rounded-lg bg-interactive-selection/20 border border-border/20 cursor-pointer hover:bg-interactive-hover/30 h-6 w-fit',
|
||||
dropdownTriggerVariants({ size: 'sm' }),
|
||||
'w-fit cursor-pointer',
|
||||
className
|
||||
)}>
|
||||
<Icon name="robot-2" className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
|
||||
<span className="typography-micro font-medium whitespace-nowrap">
|
||||
<span className="typography-micro min-w-0 flex-1 truncate text-left font-medium">
|
||||
{agentName || t('settings.commands.agentSelector.notSelected')}
|
||||
</span>
|
||||
<Icon name="arrow-down-s" className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useCommandsStore, type CommandConfig, type CommandScope } from '@/store
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { ModelSelector } from '../agents/ModelSelector';
|
||||
import { AgentSelector } from './AgentSelector';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -18,6 +17,13 @@ import {
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { parseModelIdentifier } from '@/lib/modelIdentifier';
|
||||
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsFieldRow,
|
||||
SETTINGS_SELECT_SIZE,
|
||||
SETTINGS_CUSTOM_TRIGGER_CLASS,
|
||||
} from '@/components/sections/shared/SettingsSection';
|
||||
|
||||
export const CommandsPage: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
@@ -189,158 +195,117 @@ export const CommandsPage: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollableOverlay outerClassName="h-full" className="w-full">
|
||||
<div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8">
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-4 flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<h2 className="typography-ui-header font-semibold text-foreground truncate">
|
||||
{isNewCommand ? t('settings.commands.page.title.new') : `/${selectedCommandName}`}
|
||||
</h2>
|
||||
<p className="typography-meta text-muted-foreground truncate">
|
||||
{isNewCommand ? t('settings.commands.page.subtitle.new') : t('settings.commands.page.subtitle.edit')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Identity */}
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
{t('settings.commands.page.section.identity')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<section className="px-2 pb-2 pt-0 space-y-0">
|
||||
|
||||
{isNewCommand && (
|
||||
<div data-settings-item="commands.name" className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
|
||||
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
|
||||
<span className="typography-ui-label text-foreground">{t('settings.commands.page.field.commandName')}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial">
|
||||
<div className="flex items-center">
|
||||
<span className="typography-ui-label text-muted-foreground mr-1">/</span>
|
||||
<Input
|
||||
value={draftName}
|
||||
onChange={(e) => setDraftName(e.target.value)}
|
||||
placeholder={t('settings.commands.page.field.commandNamePlaceholder')}
|
||||
className="h-7 w-40 px-2"
|
||||
/>
|
||||
<SettingsPageLayout
|
||||
title={isNewCommand ? t('settings.commands.page.title.new') : `/${selectedCommandName}`}
|
||||
description={isNewCommand ? t('settings.commands.page.subtitle.new') : t('settings.commands.page.subtitle.edit')}
|
||||
showSaveStatus={false}
|
||||
>
|
||||
<SettingsSection
|
||||
title={t('settings.commands.page.section.identity')}
|
||||
divider={false}
|
||||
contentClassName="space-y-0"
|
||||
>
|
||||
{isNewCommand && (
|
||||
<SettingsFieldRow
|
||||
settingsItem="commands.name"
|
||||
label={t('settings.commands.page.field.commandName')}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<span className="typography-ui-label text-muted-foreground mr-1">/</span>
|
||||
<Input
|
||||
value={draftName}
|
||||
onChange={(e) => setDraftName(e.target.value)}
|
||||
placeholder={t('settings.commands.page.field.commandNamePlaceholder')}
|
||||
className="h-7 w-40 px-2"
|
||||
/>
|
||||
</div>
|
||||
<Select value={draftScope} onValueChange={(v) => setDraftScope(v as CommandScope)}>
|
||||
<SelectTrigger size={SETTINGS_SELECT_SIZE} className="w-fit min-w-[100px]">
|
||||
<SelectValue placeholder={t('settings.agents.page.field.scopePlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectItem value="user">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon name="user-3" className="h-3.5 w-3.5" />
|
||||
<span>{t('settings.common.scope.global')}</span>
|
||||
</div>
|
||||
<Select value={draftScope} onValueChange={(v) => setDraftScope(v as CommandScope)}>
|
||||
<SelectTrigger className="w-fit min-w-[100px]">
|
||||
<SelectValue placeholder={t('settings.agents.page.field.scopePlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectItem value="user">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon name="user-3" className="h-3.5 w-3.5" />
|
||||
<span>{t('settings.common.scope.global')}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="project">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon name="folder" className="h-3.5 w-3.5" />
|
||||
<span>{t('settings.common.scope.project')}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SelectItem>
|
||||
<SelectItem value="project">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon name="folder" className="h-3.5 w-3.5" />
|
||||
<span>{t('settings.common.scope.project')}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingsFieldRow>
|
||||
)}
|
||||
|
||||
<div className="py-1.5">
|
||||
<span className="typography-ui-label text-foreground">{t('settings.common.field.description')}</span>
|
||||
<div className="mt-1.5">
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={t('settings.commands.page.field.descriptionPlaceholder')}
|
||||
rows={2}
|
||||
className="w-full resize-none min-h-[60px] bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Execution Context */}
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
{t('settings.commands.page.section.executionContext')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<section className="px-2 pb-2 pt-0 space-y-0">
|
||||
|
||||
<div data-settings-item="commands.agent" className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
|
||||
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
|
||||
<span className="typography-ui-label text-foreground">{t('settings.commands.page.field.overrideAgent')}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial">
|
||||
<AgentSelector
|
||||
agentName={agent}
|
||||
onChange={(agentName: string) => setAgent(agentName)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-settings-item="commands.model" className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
|
||||
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
|
||||
<span className="typography-ui-label text-foreground">{t('settings.agents.page.field.overrideModel')}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial">
|
||||
<ModelSelector
|
||||
providerId={parseModelIdentifier(model)?.providerId ?? ''}
|
||||
modelId={parseModelIdentifier(model)?.modelId ?? ''}
|
||||
onChange={(providerId: string, modelId: string) => {
|
||||
if (providerId && modelId) {
|
||||
setModel(`${providerId}/${modelId}`);
|
||||
} else {
|
||||
setModel('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Command Template */}
|
||||
<div data-settings-item="commands.template" className="mb-2">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
{t('settings.commands.page.section.template')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<section className="px-2 pb-2 pt-0">
|
||||
<div className="py-1.5">
|
||||
<span className="typography-ui-label text-foreground">{t('settings.common.field.description')}</span>
|
||||
<div className="mt-1.5">
|
||||
<Textarea
|
||||
value={template}
|
||||
onChange={(e) => setTemplate(e.target.value)}
|
||||
placeholder={t('settings.commands.page.field.templatePlaceholder')}
|
||||
rows={12}
|
||||
className="w-full font-mono typography-meta min-h-[160px] max-h-[60vh] bg-transparent resize-y"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={t('settings.commands.page.field.descriptionPlaceholder')}
|
||||
rows={2}
|
||||
className="w-full resize-none min-h-[60px] bg-transparent"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div className="mt-2 px-2">
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
<code className="text-foreground">$ARGUMENTS</code> {t('settings.commands.page.templateHint.userInput')} ·{' '}
|
||||
<code className="text-foreground">!`cmd`</code> {t('settings.commands.page.templateHint.shellOutput')} ·{' '}
|
||||
<code className="text-foreground">@file</code> {t('settings.commands.page.templateHint.fileContents')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Save action */}
|
||||
<div className="px-2 py-1">
|
||||
<SettingsSection
|
||||
title={t('settings.commands.page.section.executionContext')}
|
||||
contentClassName="space-y-0"
|
||||
>
|
||||
<SettingsFieldRow
|
||||
settingsItem="commands.agent"
|
||||
label={t('settings.commands.page.field.overrideAgent')}
|
||||
>
|
||||
<AgentSelector
|
||||
agentName={agent}
|
||||
onChange={(agentName: string) => setAgent(agentName)}
|
||||
className={SETTINGS_CUSTOM_TRIGGER_CLASS}
|
||||
/>
|
||||
</SettingsFieldRow>
|
||||
|
||||
<SettingsFieldRow
|
||||
settingsItem="commands.model"
|
||||
label={t('settings.agents.page.field.overrideModel')}
|
||||
>
|
||||
<ModelSelector
|
||||
providerId={parseModelIdentifier(model)?.providerId ?? ''}
|
||||
modelId={parseModelIdentifier(model)?.modelId ?? ''}
|
||||
onChange={(providerId: string, modelId: string) => {
|
||||
if (providerId && modelId) {
|
||||
setModel(`${providerId}/${modelId}`);
|
||||
} else {
|
||||
setModel('');
|
||||
}
|
||||
}}
|
||||
className={SETTINGS_CUSTOM_TRIGGER_CLASS}
|
||||
/>
|
||||
</SettingsFieldRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title={t('settings.commands.page.section.template')}
|
||||
settingsItem="commands.template"
|
||||
>
|
||||
<Textarea
|
||||
value={template}
|
||||
onChange={(e) => setTemplate(e.target.value)}
|
||||
placeholder={t('settings.commands.page.field.templatePlaceholder')}
|
||||
rows={12}
|
||||
className="w-full font-mono typography-meta min-h-[160px] max-h-[60vh] bg-transparent resize-y"
|
||||
/>
|
||||
<p className="mt-2 typography-meta text-muted-foreground">
|
||||
<code className="text-foreground">$ARGUMENTS</code> {t('settings.commands.page.templateHint.userInput')} ·{' '}
|
||||
<code className="text-foreground">!`cmd`</code> {t('settings.commands.page.templateHint.shellOutput')} ·{' '}
|
||||
<code className="text-foreground">@file</code> {t('settings.commands.page.templateHint.fileContents')}
|
||||
</p>
|
||||
<div className="pt-3">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || !isDirty}
|
||||
@@ -350,8 +315,7 @@ export const CommandsPage: React.FC = () => {
|
||||
{isSaving ? t('settings.common.actions.saving') : t('settings.common.actions.saveChanges')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</ScrollableOverlay>
|
||||
</SettingsSection>
|
||||
</SettingsPageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -26,6 +26,7 @@ import { cn } from '@/lib/utils';
|
||||
import { SettingsProjectSelector } from '@/components/sections/shared/SettingsProjectSelector';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { SETTINGS_PANEL_TITLE_CLASS } from '@/components/sections/shared/SettingsSection';
|
||||
|
||||
interface CommandsSidebarProps {
|
||||
onItemSelect?: () => void;
|
||||
@@ -227,7 +228,7 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
|
||||
return (
|
||||
<div className={cn('flex h-full flex-col', bgClass)}>
|
||||
<div className="border-b px-3 pt-4 pb-3">
|
||||
<h2 className="text-base font-semibold text-foreground mb-3">{t('settings.commands.sidebar.title')}</h2>
|
||||
<h2 className={`${SETTINGS_PANEL_TITLE_CLASS} mb-3`}>{t('settings.commands.sidebar.title')}</h2>
|
||||
<SettingsProjectSelector className="mb-3" />
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.commands.sidebar.total', { count: commandOnlyItems.length })}</span>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { SettingsCheckboxRow, SETTINGS_FIELD_LABEL_CLASS } from '@/components/sections/shared/SettingsSection';
|
||||
import { SettingsInfoHint } from '@/components/sections/shared/SettingsInfoHint';
|
||||
import { toast } from '@/components/ui';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -11,7 +12,6 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import type { IconName } from "@/components/icon/icons";
|
||||
import { useGitIdentitiesStore, type GitIdentityProfile, type GitIdentityAuthType } from '@/stores/useGitIdentitiesStore';
|
||||
@@ -235,7 +235,7 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
|
||||
{!isGlobalProfile && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="typography-ui-label text-foreground block mb-1.5">{t('settings.gitIdentities.editor.field.profileName')}</label>
|
||||
<label className={`${SETTINGS_FIELD_LABEL_CLASS} block mb-1.5`}>{t('settings.gitIdentities.editor.field.profileName')}</label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
@@ -302,16 +302,11 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<label className="typography-ui-label text-foreground">{t('settings.gitIdentities.editor.field.userName')}</label>
|
||||
<label className={SETTINGS_FIELD_LABEL_CLASS}>{t('settings.gitIdentities.editor.field.userName')}</label>
|
||||
{!isGlobalProfile && <span className="text-[var(--status-error)] text-xs">*</span>}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
{t('settings.gitIdentities.editor.field.userNameTooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<SettingsInfoHint contentClassName="max-w-xs">
|
||||
{t('settings.gitIdentities.editor.field.userNameTooltip')}
|
||||
</SettingsInfoHint>
|
||||
</div>
|
||||
<Input
|
||||
value={userName}
|
||||
@@ -326,16 +321,11 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<label className="typography-ui-label text-foreground">{t('settings.gitIdentities.editor.field.emailAddress')}</label>
|
||||
<label className={SETTINGS_FIELD_LABEL_CLASS}>{t('settings.gitIdentities.editor.field.emailAddress')}</label>
|
||||
{!isGlobalProfile && <span className="text-[var(--status-error)] text-xs">*</span>}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
{t('settings.gitIdentities.editor.field.emailAddressTooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<SettingsInfoHint contentClassName="max-w-xs">
|
||||
{t('settings.gitIdentities.editor.field.emailAddressTooltip')}
|
||||
</SettingsInfoHint>
|
||||
</div>
|
||||
<Input
|
||||
type="email"
|
||||
@@ -380,15 +370,10 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
|
||||
{authType === 'ssh' && (
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<label className="typography-ui-label text-foreground">{t('settings.gitIdentities.editor.field.sshKeyPath')}</label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
{t('settings.gitIdentities.editor.field.sshKeyPathTooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<label className={SETTINGS_FIELD_LABEL_CLASS}>{t('settings.gitIdentities.editor.field.sshKeyPath')}</label>
|
||||
<SettingsInfoHint contentClassName="max-w-xs">
|
||||
{t('settings.gitIdentities.editor.field.sshKeyPathTooltip')}
|
||||
</SettingsInfoHint>
|
||||
</div>
|
||||
<Input
|
||||
value={sshKey}
|
||||
@@ -400,25 +385,17 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
|
||||
)}
|
||||
|
||||
<div className="border-t border-border/40 pt-3 space-y-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<Checkbox
|
||||
checked={signCommits}
|
||||
onChange={setSignCommits}
|
||||
ariaLabel={t('settings.gitIdentities.editor.field.signCommits')}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="typography-ui-label text-foreground">
|
||||
{t('settings.gitIdentities.editor.field.signCommits')}
|
||||
</div>
|
||||
<div className="typography-micro text-muted-foreground/70">
|
||||
{t('settings.gitIdentities.editor.section.commitSigning')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsCheckboxRow
|
||||
checked={signCommits}
|
||||
onChange={setSignCommits}
|
||||
label={t('settings.gitIdentities.editor.field.signCommits')}
|
||||
info={t('settings.gitIdentities.editor.section.commitSigning')}
|
||||
ariaLabel={t('settings.gitIdentities.editor.field.signCommits')}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<label className="typography-ui-label text-foreground">
|
||||
<label className={SETTINGS_FIELD_LABEL_CLASS}>
|
||||
{t('settings.gitIdentities.editor.field.signingKey')}
|
||||
</label>
|
||||
</div>
|
||||
@@ -435,16 +412,11 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
|
||||
{authType === 'token' && (
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<label className="typography-ui-label text-foreground">{t('settings.gitIdentities.editor.field.host')}</label>
|
||||
<label className={SETTINGS_FIELD_LABEL_CLASS}>{t('settings.gitIdentities.editor.field.host')}</label>
|
||||
<span className="text-[var(--status-error)] text-xs">*</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
{t('settings.gitIdentities.editor.field.hostTooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<SettingsInfoHint contentClassName="max-w-xs">
|
||||
{t('settings.gitIdentities.editor.field.hostTooltip')}
|
||||
</SettingsInfoHint>
|
||||
</div>
|
||||
<Input
|
||||
value={host}
|
||||
|
||||
@@ -21,11 +21,12 @@ import { useShallow } from 'zustand/react/shallow';
|
||||
import { GitSettings } from '@/components/sections/openchamber/GitSettings';
|
||||
import { GitHubSettings } from '@/components/sections/openchamber/GitHubSettings';
|
||||
import { GitIdentityEditorDialog } from './GitIdentityEditorDialog';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import type { IconName } from "@/components/icon/icons";
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
|
||||
import { SettingsSection } from '@/components/sections/shared/SettingsSection';
|
||||
|
||||
const ICON_MAP: Record<string, IconName> = {
|
||||
branch: 'git-branch',
|
||||
@@ -116,82 +117,79 @@ export const GitPage: React.FC = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScrollableOverlay outerClassName="h-full" className="w-full bg-background">
|
||||
<div className="mx-auto w-full max-w-3xl space-y-6 p-3 sm:p-6 sm:pt-8">
|
||||
<div data-settings-item="git.github-account">
|
||||
<GitHubSettings />
|
||||
</div>
|
||||
<SettingsPageLayout
|
||||
title={t('settings.page.git.title')}
|
||||
showSaveStatus
|
||||
>
|
||||
<GitHubSettings />
|
||||
|
||||
{/* Identities Section */}
|
||||
<div data-settings-item="git.identities" className="border-t border-border/40 pt-6">
|
||||
<div className="mb-3 px-1 flex items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="typography-ui-header font-semibold text-foreground">{t('settings.gitIdentities.page.section.title')}</h3>
|
||||
<SettingsSection
|
||||
title={t('settings.gitIdentities.page.section.title')}
|
||||
headerAction={(
|
||||
<Button size="sm" variant="outline" onClick={() => openEditor('new')}>
|
||||
<Icon name="add" className="w-3.5 h-3.5 mr-1" /> {t('settings.common.badge.new')}
|
||||
</Button>
|
||||
)}
|
||||
settingsItem="git.identities"
|
||||
>
|
||||
<div className="rounded-lg bg-[var(--surface-elevated)]/70 overflow-hidden flex flex-col">
|
||||
{/* Global identity */}
|
||||
{globalIdentity && (
|
||||
<IdentityRow
|
||||
profile={globalIdentity}
|
||||
isDefault={defaultGitIdentityId === 'global'}
|
||||
onEdit={() => openEditor('global')}
|
||||
onToggleDefault={() => handleToggleDefault('global')}
|
||||
isReadOnly
|
||||
hasBorder={profiles.length > 0 || unimportedCredentials.length > 0}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Custom profiles */}
|
||||
{profiles.map((profile, i) => (
|
||||
<IdentityRow
|
||||
key={profile.id}
|
||||
profile={profile}
|
||||
isDefault={defaultGitIdentityId === profile.id}
|
||||
onEdit={() => openEditor(profile.id)}
|
||||
onToggleDefault={() => handleToggleDefault(profile.id)}
|
||||
onDelete={() => setDeleteDialogProfile(profile)}
|
||||
hasBorder={i < profiles.length - 1 || unimportedCredentials.length > 0}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Empty state */}
|
||||
{!globalIdentity && profiles.length === 0 && unimportedCredentials.length === 0 && (
|
||||
<div className="py-8 px-4 text-center text-muted-foreground">
|
||||
<Icon name="shield-keyhole" className="mx-auto mb-2 h-8 w-8 opacity-40" />
|
||||
<p className="typography-ui-label">{t('settings.gitIdentities.page.empty.title')}</p>
|
||||
<p className="typography-meta mt-1 opacity-75">{t('settings.gitIdentities.page.empty.description')}</p>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={() => openEditor('new')}>
|
||||
<Icon name="add" className="w-3.5 h-3.5 mr-1" /> {t('settings.common.badge.new')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg bg-[var(--surface-elevated)]/70 overflow-hidden flex flex-col">
|
||||
{/* Global identity */}
|
||||
{globalIdentity && (
|
||||
<IdentityRow
|
||||
profile={globalIdentity}
|
||||
isDefault={defaultGitIdentityId === 'global'}
|
||||
onEdit={() => openEditor('global')}
|
||||
onToggleDefault={() => handleToggleDefault('global')}
|
||||
isReadOnly
|
||||
hasBorder={profiles.length > 0 || unimportedCredentials.length > 0}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Custom profiles */}
|
||||
{profiles.map((profile, i) => (
|
||||
<IdentityRow
|
||||
key={profile.id}
|
||||
profile={profile}
|
||||
isDefault={defaultGitIdentityId === profile.id}
|
||||
onEdit={() => openEditor(profile.id)}
|
||||
onToggleDefault={() => handleToggleDefault(profile.id)}
|
||||
onDelete={() => setDeleteDialogProfile(profile)}
|
||||
hasBorder={i < profiles.length - 1 || unimportedCredentials.length > 0}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Empty state */}
|
||||
{!globalIdentity && profiles.length === 0 && unimportedCredentials.length === 0 && (
|
||||
<div className="py-8 px-4 text-center text-muted-foreground">
|
||||
<Icon name="shield-keyhole" className="mx-auto mb-2 h-8 w-8 opacity-40" />
|
||||
<p className="typography-ui-label">{t('settings.gitIdentities.page.empty.title')}</p>
|
||||
<p className="typography-meta mt-1 opacity-75">{t('settings.gitIdentities.page.empty.description')}</p>
|
||||
{/* Discovered credentials */}
|
||||
{unimportedCredentials.length > 0 && (
|
||||
<>
|
||||
<div className="px-4 py-2 border-t border-[var(--surface-subtle)]">
|
||||
<span className="typography-micro text-muted-foreground">
|
||||
{t('settings.gitIdentities.page.discoveredCredentials.title')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Discovered credentials */}
|
||||
{unimportedCredentials.length > 0 && (
|
||||
<>
|
||||
<div className="px-4 py-2 border-t border-[var(--surface-subtle)]">
|
||||
<span className="typography-micro text-muted-foreground">
|
||||
{t('settings.gitIdentities.page.discoveredCredentials.title')}
|
||||
</span>
|
||||
</div>
|
||||
{unimportedCredentials.map((cred, i) => (
|
||||
<DiscoveredRow
|
||||
key={`${cred.host}-${cred.username}`}
|
||||
credential={cred}
|
||||
onImport={() => openEditor('new', { host: cred.host, username: cred.username })}
|
||||
hasBorder={i < unimportedCredentials.length - 1}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{unimportedCredentials.map((cred, i) => (
|
||||
<DiscoveredRow
|
||||
key={`${cred.host}-${cred.username}`}
|
||||
credential={cred}
|
||||
onImport={() => openEditor('new', { host: cred.host, username: cred.username })}
|
||||
hasBorder={i < unimportedCredentials.length - 1}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<GitSettings />
|
||||
</div>
|
||||
</ScrollableOverlay>
|
||||
<GitSettings />
|
||||
</SettingsPageLayout>
|
||||
|
||||
{/* Editor dialog */}
|
||||
<GitIdentityEditorDialog
|
||||
|
||||
@@ -2,8 +2,6 @@ import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { toast } from '@/components/ui';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import {
|
||||
fetchMagicPromptOverrides,
|
||||
getDefaultMagicPromptTemplate,
|
||||
@@ -15,6 +13,9 @@ import {
|
||||
} from '@/lib/magicPrompts';
|
||||
import { useMagicPromptsStore } from '@/stores/useMagicPromptsStore';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
|
||||
import { SettingsSection } from '@/components/sections/shared/SettingsSection';
|
||||
import { SettingsInfoHint } from '@/components/sections/shared/SettingsInfoHint';
|
||||
|
||||
type PromptBlock = {
|
||||
id: MagicPromptId;
|
||||
@@ -317,114 +318,92 @@ export const MagicPromptsPage: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
<div className="mx-auto w-full max-w-4xl px-6 py-6 space-y-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="typography-ui-header font-semibold text-foreground">{tUnsafe(pageConfig.titleKey)}</h2>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
{tUnsafe(pageConfig.descriptionKey)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
data-settings-item="magic-prompts.reset-overrides"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void handleResetAll();
|
||||
}}
|
||||
disabled={resettingAll || Object.keys(overrides).length === 0}
|
||||
<SettingsPageLayout
|
||||
title={tUnsafe(pageConfig.titleKey)}
|
||||
titleAccessory={(
|
||||
<SettingsInfoHint contentClassName="max-w-xs">{tUnsafe(pageConfig.descriptionKey)}</SettingsInfoHint>
|
||||
)}
|
||||
headerEnd={(
|
||||
<Button
|
||||
data-settings-item="magic-prompts.reset-overrides"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void handleResetAll();
|
||||
}}
|
||||
disabled={resettingAll || Object.keys(overrides).length === 0}
|
||||
>
|
||||
{resettingAll ? t('settings.magicPrompts.page.actions.resetting') : t('settings.magicPrompts.page.actions.resetAllOverrides')}
|
||||
</Button>
|
||||
)}
|
||||
showSaveStatus={false}
|
||||
>
|
||||
{pageConfig.blocks.map((block, index) => {
|
||||
const definition = getMagicPromptDefinition(block.id);
|
||||
const baseline = getBaseline(block.id);
|
||||
const draft = getDraft(block.id);
|
||||
const isOverridden = hasOwn(overrides, block.id);
|
||||
const isDirty = draft !== baseline;
|
||||
const isInvalidEmptyVisiblePrompt = isVisiblePromptId(block.id) && draft.trim().length === 0;
|
||||
const saving = savingIds[block.id] === true;
|
||||
const resetting = resettingIds[block.id] === true;
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
key={block.id}
|
||||
title={tUnsafe(block.titleKey)}
|
||||
info={definition.description}
|
||||
description={
|
||||
definition.placeholders && definition.placeholders.length > 0
|
||||
? `${t('settings.magicPrompts.page.placeholdersLabel')} ${definition.placeholders.map((item) => `{{${item.key}}}`).join(', ')}`
|
||||
: undefined
|
||||
}
|
||||
divider={index > 0}
|
||||
settingsItem={isVisiblePromptId(block.id) ? 'magic-prompts.visible-prompt' : 'magic-prompts.instructions'}
|
||||
contentClassName="space-y-3"
|
||||
>
|
||||
{resettingAll ? t('settings.magicPrompts.page.actions.resetting') : t('settings.magicPrompts.page.actions.resetAllOverrides')}
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(block.id, event.target.value)}
|
||||
className="min-h-[220px] font-mono text-sm"
|
||||
/>
|
||||
{isInvalidEmptyVisiblePrompt && (
|
||||
<div className="typography-micro text-[var(--status-error)]">{t('settings.magicPrompts.page.validation.visiblePromptRequired')}</div>
|
||||
)}
|
||||
|
||||
{pageConfig.blocks.map((block, index) => {
|
||||
const definition = getMagicPromptDefinition(block.id);
|
||||
const baseline = getBaseline(block.id);
|
||||
const draft = getDraft(block.id);
|
||||
const isOverridden = hasOwn(overrides, block.id);
|
||||
const isDirty = draft !== baseline;
|
||||
const isInvalidEmptyVisiblePrompt = isVisiblePromptId(block.id) && draft.trim().length === 0;
|
||||
const saving = savingIds[block.id] === true;
|
||||
const resetting = resettingIds[block.id] === true;
|
||||
|
||||
return (
|
||||
<section
|
||||
key={block.id}
|
||||
data-settings-item={isVisiblePromptId(block.id) ? 'magic-prompts.visible-prompt' : 'magic-prompts.instructions'}
|
||||
className={index > 0 ? 'space-y-3 pt-5 border-t border-border' : 'space-y-3'}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="typography-ui-label text-foreground">{tUnsafe(block.titleKey)}</h3>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
{definition.description}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{definition.placeholders && definition.placeholders.length > 0 && (
|
||||
<div className="typography-micro text-muted-foreground">
|
||||
{t('settings.magicPrompts.page.placeholdersLabel')}{' '}
|
||||
{definition.placeholders.map((item) => `{{${item.key}}}`).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="typography-micro text-muted-foreground">
|
||||
{isDirty
|
||||
? t('settings.magicPrompts.page.status.unsavedChanges')
|
||||
: isOverridden
|
||||
? t('settings.magicPrompts.page.status.usingSavedOverride')
|
||||
: t('settings.magicPrompts.page.status.usingBuiltinDefault')}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void resetPrompt(block.id);
|
||||
}}
|
||||
disabled={!isOverridden || saving || resetting}
|
||||
>
|
||||
{resetting ? t('settings.magicPrompts.page.actions.resetting') : t('settings.magicPrompts.page.actions.resetToDefault')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void savePrompt(block.id);
|
||||
}}
|
||||
disabled={!isDirty || saving || resetting || isInvalidEmptyVisiblePrompt}
|
||||
>
|
||||
{saving ? t('settings.common.actions.saving') : t('settings.magicPrompts.page.actions.save')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(block.id, event.target.value)}
|
||||
className="min-h-[220px] font-mono text-sm"
|
||||
/>
|
||||
{isInvalidEmptyVisiblePrompt && (
|
||||
<div className="typography-micro text-[var(--status-error)]">{t('settings.magicPrompts.page.validation.visiblePromptRequired')}</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="typography-micro text-muted-foreground">
|
||||
{isDirty
|
||||
? t('settings.magicPrompts.page.status.unsavedChanges')
|
||||
: isOverridden
|
||||
? t('settings.magicPrompts.page.status.usingSavedOverride')
|
||||
: t('settings.magicPrompts.page.status.usingBuiltinDefault')}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void resetPrompt(block.id);
|
||||
}}
|
||||
disabled={!isOverridden || saving || resetting}
|
||||
>
|
||||
{resetting ? t('settings.magicPrompts.page.actions.resetting') : t('settings.magicPrompts.page.actions.resetToDefault')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void savePrompt(block.id);
|
||||
}}
|
||||
disabled={!isDirty || saving || resetting || isInvalidEmptyVisiblePrompt}
|
||||
>
|
||||
{saving ? t('settings.common.actions.saving') : t('settings.magicPrompts.page.actions.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
})}
|
||||
</SettingsPageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { useMagicPromptsStore } from '@/stores/useMagicPromptsStore';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { SETTINGS_PANEL_TITLE_CLASS } from '@/components/sections/shared/SettingsSection';
|
||||
|
||||
interface MagicPromptsSidebarProps {
|
||||
onItemSelect?: () => void;
|
||||
@@ -62,7 +63,7 @@ export const MagicPromptsSidebar: React.FC<MagicPromptsSidebarProps> = ({ onItem
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-background">
|
||||
<div className="border-b px-3 pt-4 pb-3">
|
||||
<h2 className="text-base font-semibold text-foreground">{t('settings.magicPrompts.sidebar.title')}</h2>
|
||||
<h2 className={SETTINGS_PANEL_TITLE_CLASS}>{t('settings.magicPrompts.sidebar.title')}</h2>
|
||||
<p className="typography-meta mt-1 text-muted-foreground">{t('settings.magicPrompts.sidebar.description')}</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Button } from '@/components/ui/button';
|
||||
import { useMcpStore } from '@/stores/useMcpStore';
|
||||
import { parseMcpOAuthCallbackContext, parseMcpOAuthCallbackStateKey } from '@/components/sections/mcp/mcpOAuth';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { SETTINGS_PAGE_TITLE_CLASS } from '@/components/sections/shared/SettingsSection';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const parseQueryParam = (params: URLSearchParams, key: string): string | null => {
|
||||
const value = params.get(key);
|
||||
@@ -94,14 +96,21 @@ export const McpOAuthCallbackPage: React.FC = () => {
|
||||
<div className="flex min-h-screen items-center justify-center bg-background px-6 py-12 text-foreground">
|
||||
<div className="w-full max-w-xl rounded-xl border border-[var(--interactive-border)] bg-[var(--surface-elevated)] p-8 shadow-sm">
|
||||
<div className="space-y-3 text-center">
|
||||
<div
|
||||
className={status === 'error' ? 'text-[var(--status-error)]' : status === 'success' ? 'text-[var(--status-success)]' : 'text-[var(--status-info)]'}
|
||||
<h1 className={SETTINGS_PAGE_TITLE_CLASS}>
|
||||
{status === 'working' ? 'Completing Authorization' : status === 'success' ? 'Authorization Complete' : 'Authorization Failed'}
|
||||
</h1>
|
||||
<p
|
||||
className={cn(
|
||||
'typography-body',
|
||||
status === 'error'
|
||||
? 'text-[var(--status-error)]'
|
||||
: status === 'success'
|
||||
? 'text-[var(--status-success)]'
|
||||
: 'text-[var(--status-info)]',
|
||||
)}
|
||||
>
|
||||
<h1 className="typography-hero font-semibold">
|
||||
{status === 'working' ? 'Completing Authorization' : status === 'success' ? 'Authorization Complete' : 'Authorization Failed'}
|
||||
</h1>
|
||||
</div>
|
||||
<p className="typography-body text-muted-foreground">{message}</p>
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{status !== 'working' && (
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { toast } from '@/components/ui';
|
||||
@@ -23,7 +22,18 @@ import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsFieldRow,
|
||||
SettingsCheckboxRow,
|
||||
SettingsStackedField,
|
||||
SettingsChipGroup,
|
||||
SettingsGroupTitle,
|
||||
SETTINGS_SELECT_SIZE,
|
||||
SETTINGS_FIELD_LABEL_CLASS,
|
||||
} from '@/components/sections/shared/SettingsSection';
|
||||
import { SettingsInfoHint } from '@/components/sections/shared/SettingsInfoHint';
|
||||
import { MCP_OAUTH_CALLBACK_PATH, parseMcpOAuthCallbackContext, parseMcpOAuthCallbackStateKey } from '@/components/sections/mcp/mcpOAuth';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import {
|
||||
@@ -1323,88 +1333,78 @@ export const McpPage: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollableOverlay outerClassName="h-full" className="w-full">
|
||||
<div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8">
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-4">
|
||||
<div className="min-w-0">
|
||||
{isNewServer ? (
|
||||
<h2 className="typography-ui-header font-semibold text-foreground truncate">{t('settings.mcp.page.header.newServer')}</h2>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<h2 className="typography-ui-header font-semibold text-foreground truncate">{selectedMcpName}</h2>
|
||||
<StatusBadge status={effectiveRuntimeStatus?.status} enabled={enabled} getStatusLabel={getStatusLabel} variant="pill" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<p className="typography-meta text-muted-foreground truncate">
|
||||
{isNewServer
|
||||
? t('settings.mcp.page.header.configureNewServer')
|
||||
: t('settings.mcp.page.header.transport', { type: mcpType === 'local' ? t('settings.mcp.page.transport.local') : t('settings.mcp.page.transport.remote') })}
|
||||
</p>
|
||||
{!isNewServer && (
|
||||
<>
|
||||
<Button
|
||||
variant={isConnected ? 'outline' : 'default'}
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={handleToggleConnect}
|
||||
disabled={isConnecting || !enabled}
|
||||
>
|
||||
{isConnecting ? t('settings.mcp.page.actions.working') : isConnected ? t('settings.mcp.page.actions.disconnect') : t('settings.mcp.page.actions.connect')}
|
||||
</Button>
|
||||
{mcpType === 'remote' && (
|
||||
<>
|
||||
<Button
|
||||
variant={needsAuthorization ? 'default' : 'outline'}
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => void handleStartAuthorization()}
|
||||
disabled={isAuthorizing || !enabled}
|
||||
>
|
||||
{isAuthorizing
|
||||
? t('settings.mcp.page.actions.starting')
|
||||
: needsAuthorization
|
||||
? t('settings.mcp.page.actions.authorize')
|
||||
: t('settings.mcp.page.actions.reauthorize')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="!font-normal gap-1 text-muted-foreground"
|
||||
onClick={() => void handleClearAuthorization()}
|
||||
disabled={isClearingAuth || !enabled}
|
||||
>
|
||||
{isClearingAuth ? t('settings.mcp.page.actions.clearing') : t('settings.mcp.page.actions.clearAuth')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{isConnected && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="!font-normal gap-1 text-muted-foreground"
|
||||
onClick={() => void handleTestConnection()}
|
||||
disabled={isTestingConnection || !enabled}
|
||||
>
|
||||
{isTestingConnection ? t('settings.mcp.page.actions.testing') : t('settings.mcp.page.actions.test')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
<SettingsPageLayout
|
||||
title={isNewServer ? t('settings.mcp.page.header.newServer') : selectedMcpName}
|
||||
titleAccessory={!isNewServer ? (
|
||||
<StatusBadge status={effectiveRuntimeStatus?.status} enabled={enabled} getStatusLabel={getStatusLabel} variant="pill" />
|
||||
) : undefined}
|
||||
description={isNewServer
|
||||
? t('settings.mcp.page.header.configureNewServer')
|
||||
: t('settings.mcp.page.header.transport', { type: mcpType === 'local' ? t('settings.mcp.page.transport.local') : t('settings.mcp.page.transport.remote') })}
|
||||
headerEnd={!isNewServer ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant={isConnected ? 'outline' : 'default'}
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={handleToggleConnect}
|
||||
disabled={isConnecting || !enabled}
|
||||
>
|
||||
{isConnecting ? t('settings.mcp.page.actions.working') : isConnected ? t('settings.mcp.page.actions.disconnect') : t('settings.mcp.page.actions.connect')}
|
||||
</Button>
|
||||
{mcpType === 'remote' && (
|
||||
<>
|
||||
<Button
|
||||
variant={needsAuthorization ? 'default' : 'outline'}
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => void handleStartAuthorization()}
|
||||
disabled={isAuthorizing || !enabled}
|
||||
>
|
||||
{isAuthorizing
|
||||
? t('settings.mcp.page.actions.starting')
|
||||
: needsAuthorization
|
||||
? t('settings.mcp.page.actions.authorize')
|
||||
: t('settings.mcp.page.actions.reauthorize')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="!font-normal gap-1 text-muted-foreground"
|
||||
onClick={() => void handleClearAuthorization()}
|
||||
disabled={isClearingAuth || !enabled}
|
||||
>
|
||||
{isClearingAuth ? t('settings.mcp.page.actions.clearing') : t('settings.mcp.page.actions.clearAuth')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{isConnected && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="!font-normal gap-1 text-muted-foreground"
|
||||
onClick={() => void handleTestConnection()}
|
||||
disabled={isTestingConnection || !enabled}
|
||||
>
|
||||
{isTestingConnection ? t('settings.mcp.page.actions.testing') : t('settings.mcp.page.actions.test')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : undefined}
|
||||
showSaveStatus={false}
|
||||
>
|
||||
|
||||
|
||||
|
||||
{/* Runtime Status - Simplified for connected, expanded for errors */}
|
||||
{!isNewServer && shouldShowFullStatusCard(effectiveRuntimeStatus?.status, authUrl, needsAuthorization, isAuthPolling) && (
|
||||
<div className="mb-6 px-2">
|
||||
<SettingsSection divider={false}>
|
||||
<div className={cn('rounded-lg border p-3', statusCardClass(effectiveRuntimeStatus?.status))}>
|
||||
<div className="space-y-4">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="typography-ui-label text-foreground">{t('settings.mcp.page.status.runtimeStatus')}</span>
|
||||
<span className={SETTINGS_FIELD_LABEL_CLASS}>{t('settings.mcp.page.status.runtimeStatus')}</span>
|
||||
<StatusBadge status={effectiveRuntimeStatus?.status} enabled={enabled} getStatusLabel={getStatusLabel} />
|
||||
</div>
|
||||
<p className="typography-meta text-muted-foreground">{runtimeDescription}</p>
|
||||
@@ -1452,7 +1452,7 @@ export const McpPage: React.FC = () => {
|
||||
<div className="rounded-md border border-[var(--interactive-border)] bg-[var(--surface-background)] px-3 py-3">
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<div className="typography-ui-label text-foreground">{t('settings.mcp.page.auth.manualFallbackTitle')}</div>
|
||||
<SettingsGroupTitle as="div">{t('settings.mcp.page.auth.manualFallbackTitle')}</SettingsGroupTitle>
|
||||
<p className="mt-1 typography-micro text-muted-foreground">
|
||||
{t('settings.mcp.page.auth.manualFallbackDescription')}
|
||||
</p>
|
||||
@@ -1489,23 +1489,18 @@ export const McpPage: React.FC = () => {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{/* Server Identity */}
|
||||
<div data-settings-item="mcp.server" className="mb-6">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.mcp.page.server.title')}</h3>
|
||||
</div>
|
||||
|
||||
<section className="px-2 pb-2 pt-0 space-y-0">
|
||||
<SettingsSection
|
||||
title={t('settings.mcp.page.server.title')}
|
||||
divider={false}
|
||||
settingsItem="mcp.server"
|
||||
contentClassName="space-y-0"
|
||||
>
|
||||
|
||||
{isNewServer && (
|
||||
<div className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
|
||||
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
|
||||
<span className="typography-ui-label text-foreground">{t('settings.mcp.page.server.name')}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial">
|
||||
<SettingsFieldRow label={t('settings.mcp.page.server.name')}>
|
||||
<Input
|
||||
value={draftName}
|
||||
onChange={(e) => setDraftName(e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, '-'))}
|
||||
@@ -1514,7 +1509,7 @@ export const McpPage: React.FC = () => {
|
||||
autoFocus
|
||||
/>
|
||||
<Select value={draftScope} onValueChange={(value) => setDraftScope(value as McpScope)}>
|
||||
<SelectTrigger className="!h-7 !w-7 !min-w-0 !px-0 !py-0 justify-center [&>svg:last-child]:hidden" title={draftScope === 'user' ? t('settings.common.scope.global') : t('settings.common.scope.project')}>
|
||||
<SelectTrigger size={SETTINGS_SELECT_SIZE} className="!h-7 !w-7 !min-w-0 !px-0 !py-0 justify-center [&>svg:last-child]:hidden" title={draftScope === 'user' ? t('settings.common.scope.global') : t('settings.common.scope.project')}>
|
||||
{draftScope === 'user' ? <Icon name="user-3" className="h-3.5 w-3.5" /> : <Icon name="folder" className="h-3.5 w-3.5" />}
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
@@ -1532,8 +1527,7 @@ export const McpPage: React.FC = () => {
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsFieldRow>
|
||||
)}
|
||||
|
||||
{/* Import JSON - prominent placement for new servers */}
|
||||
@@ -1553,65 +1547,31 @@ export const McpPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="group flex cursor-pointer items-center gap-2 py-1.5"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={enabled}
|
||||
onClick={() => setEnabled(!enabled)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
setEnabled(!enabled);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={enabled}
|
||||
onChange={setEnabled}
|
||||
ariaLabel={t('settings.mcp.page.server.enableAria')}
|
||||
<SettingsCheckboxRow
|
||||
checked={enabled}
|
||||
onChange={setEnabled}
|
||||
label={t('settings.mcp.page.server.enable')}
|
||||
ariaLabel={t('settings.mcp.page.server.enableAria')}
|
||||
/>
|
||||
|
||||
<SettingsStackedField label={t('settings.mcp.page.server.transportMode')}>
|
||||
<SettingsChipGroup
|
||||
aria-label={t('settings.mcp.page.server.transportMode')}
|
||||
value={mcpType}
|
||||
onChange={setMcpType}
|
||||
options={[
|
||||
{ value: 'local', label: t('settings.mcp.page.transport.local') },
|
||||
{ value: 'remote', label: t('settings.mcp.page.transport.remote') },
|
||||
]}
|
||||
/>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.mcp.page.server.enable')}</span>
|
||||
</div>
|
||||
</SettingsStackedField>
|
||||
|
||||
<div className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
|
||||
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
|
||||
<span className="typography-ui-label text-foreground">{t('settings.mcp.page.server.transportMode')}</span>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<Button
|
||||
variant="chip"
|
||||
size="xs"
|
||||
aria-pressed={mcpType === 'local'}
|
||||
onClick={() => setMcpType('local')}
|
||||
className="!font-normal"
|
||||
>
|
||||
{t('settings.mcp.page.transport.local')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="chip"
|
||||
size="xs"
|
||||
aria-pressed={mcpType === 'remote'}
|
||||
onClick={() => setMcpType('remote')}
|
||||
className="!font-normal"
|
||||
>
|
||||
{t('settings.mcp.page.transport.remote')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Connection */}
|
||||
<div data-settings-item="mcp.command" className="mb-6">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
{mcpType === 'local' ? t('settings.mcp.page.connection.command') : t('settings.mcp.page.connection.serverUrl')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<section className="px-2 pb-2 pt-0">
|
||||
<SettingsSection
|
||||
title={mcpType === 'local' ? t('settings.mcp.page.connection.command') : t('settings.mcp.page.connection.serverUrl')}
|
||||
settingsItem="mcp.command"
|
||||
>
|
||||
{mcpType === 'local' ? (
|
||||
<CommandTextarea
|
||||
value={command}
|
||||
@@ -1630,16 +1590,13 @@ export const McpPage: React.FC = () => {
|
||||
className="font-mono typography-meta"
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{mcpType === 'remote' && (
|
||||
<div data-settings-item="mcp.advanced" className="mb-6">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.mcp.page.advanced.title')}</h3>
|
||||
</div>
|
||||
|
||||
<section className="px-2 pb-2 pt-0">
|
||||
<SettingsSection
|
||||
title={t('settings.mcp.page.advanced.title')}
|
||||
settingsItem="mcp.advanced"
|
||||
>
|
||||
<Collapsible
|
||||
open={isAdvancedRemoteOptionsOpen}
|
||||
onOpenChange={setIsAdvancedRemoteOptionsOpen}
|
||||
@@ -1660,9 +1617,10 @@ export const McpPage: React.FC = () => {
|
||||
<CollapsibleContent className="pt-2">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-8">
|
||||
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
|
||||
<span className="typography-ui-label text-foreground">{t('settings.mcp.page.advanced.timeoutMs')}</span>
|
||||
<div className="flex flex-col gap-2 @xl:flex-row @xl:items-center @xl:gap-8">
|
||||
<div className="flex min-w-0 flex-row items-center gap-1 @xl:w-56 shrink-0">
|
||||
<span className={SETTINGS_FIELD_LABEL_CLASS}>{t('settings.mcp.page.advanced.timeoutMs')}</span>
|
||||
<SettingsInfoHint>{t('settings.mcp.page.advanced.timeoutHint')}</SettingsInfoHint>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
@@ -1678,18 +1636,15 @@ export const McpPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="typography-micro text-muted-foreground sm:pl-64">
|
||||
{t('settings.mcp.page.advanced.timeoutHint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 typography-ui-label text-foreground">
|
||||
<SettingsGroupTitle as="div" className="mb-2">
|
||||
{t('settings.mcp.page.advanced.requestHeaders')}
|
||||
{headerEntries.length > 0 && (
|
||||
<span className="ml-1.5 typography-micro text-muted-foreground font-normal">({headerEntries.length})</span>
|
||||
)}
|
||||
</div>
|
||||
</SettingsGroupTitle>
|
||||
<EnvEditor
|
||||
value={headerEntries}
|
||||
onChange={setHeaderEntries}
|
||||
@@ -1713,24 +1668,15 @@ export const McpPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div
|
||||
className="group flex cursor-pointer items-center gap-2 py-1.5"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={oauthEnabled}
|
||||
onClick={() => setOauthEnabled(!oauthEnabled)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
setOauthEnabled(!oauthEnabled);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={oauthEnabled} onChange={setOauthEnabled} ariaLabel={t('settings.mcp.page.advanced.oauthAutoDetectionAria')} />
|
||||
<span className="typography-ui-label text-foreground">{t('settings.mcp.page.advanced.oauthAutoDetection')}</span>
|
||||
</div>
|
||||
<SettingsCheckboxRow
|
||||
checked={oauthEnabled}
|
||||
onChange={setOauthEnabled}
|
||||
label={t('settings.mcp.page.advanced.oauthAutoDetection')}
|
||||
ariaLabel={t('settings.mcp.page.advanced.oauthAutoDetectionAria')}
|
||||
info={t('settings.mcp.page.advanced.oauthHint')}
|
||||
/>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="grid gap-3 @xl:grid-cols-2">
|
||||
<Input
|
||||
value={oauthClientId}
|
||||
onChange={(e) => setOauthClientId(e.target.value)}
|
||||
@@ -1769,9 +1715,6 @@ export const McpPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
{t('settings.mcp.page.advanced.oauthHint')}
|
||||
</p>
|
||||
{suggestedRedirectUri && (
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
{t('settings.mcp.page.advanced.oauthCallbackHint')}
|
||||
@@ -1782,24 +1725,20 @@ export const McpPage: React.FC = () => {
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</section>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{/* Environment Variables */}
|
||||
<div data-settings-item="mcp.environment" className="mb-2">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
{t('settings.mcp.page.env.title')}
|
||||
{envEntries.length > 0 && (
|
||||
<span className="ml-1.5 typography-micro text-muted-foreground font-normal">
|
||||
({envEntries.length})
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<section className="px-2 pb-2 pt-0">
|
||||
<SettingsSection
|
||||
title={t('settings.mcp.page.env.title')}
|
||||
titleAccessory={
|
||||
envEntries.length > 0 ? (
|
||||
<span className="typography-micro text-muted-foreground font-normal">
|
||||
({envEntries.length})
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
settingsItem="mcp.environment"
|
||||
>
|
||||
{envEntries.length === 0 ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -1830,11 +1769,9 @@ export const McpPage: React.FC = () => {
|
||||
removeVariableAria={t('settings.mcp.page.env.removeVariableAria')}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 px-2 py-1">
|
||||
<div className="flex items-center gap-2 pb-8">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || (!isDirty && !isNewServer)}
|
||||
@@ -1853,8 +1790,8 @@ export const McpPage: React.FC = () => {
|
||||
{t('settings.common.actions.delete')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div> </SettingsPageLayout>
|
||||
|
||||
|
||||
{/* Import JSON dialog */}
|
||||
<Dialog
|
||||
@@ -1962,6 +1899,7 @@ export const McpPage: React.FC = () => {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</ScrollableOverlay>
|
||||
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { SETTINGS_PANEL_TITLE_CLASS } from '@/components/sections/shared/SettingsSection';
|
||||
|
||||
interface McpSidebarProps {
|
||||
onItemSelect?: () => void;
|
||||
@@ -183,7 +184,7 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
|
||||
<div className={cn('flex h-full flex-col', bgClass)}>
|
||||
<div className="border-b px-3 pt-4 pb-3">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<h2 className="text-base font-semibold text-foreground">{t('settings.mcp.sidebar.title')}</h2>
|
||||
<h2 className={SETTINGS_PANEL_TITLE_CLASS}>{t('settings.mcp.sidebar.title')}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
|
||||
@@ -9,6 +9,11 @@ import { Icon } from "@/components/icon/Icon";
|
||||
import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import {
|
||||
SettingsSection,
|
||||
SETTINGS_BRAND_TITLE_CLASS,
|
||||
SETTINGS_FIELD_LABEL_CLASS,
|
||||
} from '@/components/sections/shared/SettingsSection';
|
||||
|
||||
const GITHUB_URL = 'https://github.com/openchamber/openchamber';
|
||||
const DISCORD_URL = 'https://discord.gg/ZYRSdnwwKA';
|
||||
@@ -125,7 +130,7 @@ export const AboutSettings: React.FC<AboutSettingsProps> = ({ initialUpdateDialo
|
||||
<div className="w-full space-y-6 pb-2">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<OpenChamberLogo width={72} height={72} />
|
||||
<h2 className="mt-4 typography-ui-header font-semibold text-foreground">OpenChamber</h2>
|
||||
<h2 className={`mt-4 ${SETTINGS_BRAND_TITLE_CLASS}`}>OpenChamber</h2>
|
||||
<div className="mt-2 space-y-1 typography-ui text-muted-foreground">
|
||||
<p>{t('aboutDialog.openChamberVersionLabel', { version: currentVersion })}</p>
|
||||
<p>{t('aboutDialog.openCodeVersionLabel', { version: openCodeVersion || t('settings.openchamber.about.state.unknown') })}</p>
|
||||
@@ -221,23 +226,17 @@ export const AboutSettings: React.FC<AboutSettingsProps> = ({ initialUpdateDialo
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop layout (redesigned)
|
||||
// Desktop layout
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div className="mb-3 px-1">
|
||||
<h3 className="typography-ui-header font-semibold text-foreground">
|
||||
{t('settings.openchamber.about.title')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<SettingsSection divider={false}>
|
||||
<div className="rounded-lg bg-[var(--surface-elevated)]/70 overflow-hidden flex flex-col">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 px-4 py-3 border-b border-[var(--surface-subtle)]">
|
||||
<div className="flex flex-col @xl:flex-row @xl:items-center justify-between gap-4 px-4 py-3 border-b border-border/40">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.about.field.version')}</span>
|
||||
<span className={SETTINGS_FIELD_LABEL_CLASS}>{t('settings.openchamber.about.field.version')}</span>
|
||||
<span className="typography-meta text-muted-foreground font-mono">{currentVersion}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.about.field.openCodeVersion')}</span>
|
||||
<span className={SETTINGS_FIELD_LABEL_CLASS}>{t('settings.openchamber.about.field.openCodeVersion')}</span>
|
||||
<span className="typography-meta text-muted-foreground font-mono">{openCodeVersion || t('settings.openchamber.about.state.unknown')}</span>
|
||||
</div>
|
||||
|
||||
@@ -274,7 +273,7 @@ export const AboutSettings: React.FC<AboutSettingsProps> = ({ initialUpdateDialo
|
||||
</div>
|
||||
|
||||
{updateStore.error && (
|
||||
<div className="px-3 py-2 border-b border-[var(--surface-subtle)]">
|
||||
<div className="px-3 py-2 border-b border-border/40">
|
||||
<p className="typography-meta text-[var(--status-error)]">{updateStore.error}</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -314,6 +313,6 @@ export const AboutSettings: React.FC<AboutSettingsProps> = ({ initialUpdateDialo
|
||||
onRestart={updateStore.restartToUpdate}
|
||||
runtimeType={updateStore.runtimeType}
|
||||
/>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,12 +2,22 @@ import React from 'react';
|
||||
import { ModelSelector } from '@/components/sections/agents/ModelSelector';
|
||||
import { AgentSelector } from '@/components/sections/commands/AgentSelector';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsFieldRow,
|
||||
SettingsCheckboxRow,
|
||||
SettingsInset,
|
||||
SettingsGroupTitle,
|
||||
SETTINGS_CUSTOM_TRIGGER_CLASS,
|
||||
SETTINGS_SELECT_ROW_TRIGGER_CLASS,
|
||||
SETTINGS_SELECT_SIZE,
|
||||
SETTINGS_OPTION_STACK_CLASS,
|
||||
} from '@/components/sections/shared/SettingsSection';
|
||||
import { SettingsInfoHint } from '@/components/sections/shared/SettingsInfoHint';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { parseModelIdentifier } from '@/lib/modelIdentifier';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
@@ -277,143 +287,118 @@ export const DefaultsSettings: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<div className="mb-0.5 px-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.defaults.title')}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="px-2 pb-2 pt-0 space-y-0">
|
||||
<div className="mt-0 mb-1 typography-meta text-muted-foreground">
|
||||
{t('settings.openchamber.defaults.summaryPrefix')}
|
||||
{' '}
|
||||
{parsedModel.providerId ? (
|
||||
<span className="text-foreground">
|
||||
{parsedModel.providerId}/{parsedModel.modelId}
|
||||
{supportsVariants ? ` (${defaultVariant ?? t('settings.openchamber.defaults.option.defaultLowercase')})` : ''}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-foreground">{t('settings.openchamber.defaults.summaryOpenCodeDefault')}</span>
|
||||
)}
|
||||
{defaultAgent && (
|
||||
<>
|
||||
{' / '}
|
||||
<span className="text-foreground">{defaultAgent}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div data-settings-item="sessions.default-model" className={cn('flex flex-col gap-2 py-1 sm:flex-row sm:items-center sm:gap-8')}>
|
||||
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.defaults.field.defaultModel')}</span>
|
||||
<>
|
||||
<SettingsSection title={t('settings.openchamber.defaults.title')} divider={false}>
|
||||
<div className="space-y-0">
|
||||
<div className="mt-0 mb-1 typography-meta text-muted-foreground">
|
||||
{t('settings.openchamber.defaults.summaryPrefix')}
|
||||
{' '}
|
||||
{parsedModel.providerId ? (
|
||||
<span className="text-foreground">
|
||||
{parsedModel.providerId}/{parsedModel.modelId}
|
||||
{supportsVariants ? ` (${defaultVariant ?? t('settings.openchamber.defaults.option.defaultLowercase')})` : ''}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-foreground">{t('settings.openchamber.defaults.summaryOpenCodeDefault')}</span>
|
||||
)}
|
||||
{defaultAgent && (
|
||||
<>
|
||||
{' / '}
|
||||
<span className="text-foreground">{defaultAgent}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial">
|
||||
<ModelSelector providerId={parsedModel.providerId} modelId={parsedModel.modelId} onChange={handleModelChange} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-settings-item="sessions.default-thinking" className="flex flex-col gap-2 py-1 sm:flex-row sm:items-center sm:gap-8">
|
||||
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.defaults.field.defaultThinking')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 sm:w-fit">
|
||||
<Select value={defaultVariant ?? DEFAULT_VARIANT_VALUE} onValueChange={handleVariantChange} disabled={!supportsVariants}>
|
||||
<SelectTrigger className="w-fit min-w-[120px]">
|
||||
<SelectValue placeholder={t('settings.openchamber.defaults.field.thinkingPlaceholder')}>
|
||||
{formatVariantLabel(defaultVariant ?? DEFAULT_VARIANT_VALUE)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={DEFAULT_VARIANT_VALUE}>{t('settings.openchamber.defaults.option.default')}</SelectItem>
|
||||
{availableVariants.map((variant) => (
|
||||
<SelectItem key={variant} value={variant}>
|
||||
{formatVariantLabel(variant)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-settings-item="sessions.default-agent" className="flex flex-col gap-2 py-1 sm:flex-row sm:items-center sm:gap-8">
|
||||
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.defaults.field.defaultAgent')}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial">
|
||||
<AgentSelector agentName={defaultAgent || ''} onChange={handleAgentChange} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-settings-item="sessions.deletion-dialog"
|
||||
className="group flex cursor-pointer items-center gap-2 py-1"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={showDeletionDialog}
|
||||
onClick={() => setShowDeletionDialog(!showDeletionDialog)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
setShowDeletionDialog(!showDeletionDialog);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={showDeletionDialog} onChange={setShowDeletionDialog} ariaLabel={t('settings.openchamber.defaults.field.showDeletionDialogAria')} />
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.defaults.field.showDeletionDialog')}</span>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<div className="mt-6 mb-0.5 px-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.defaults.smallModel.title')}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="px-2 pb-2 pt-0 space-y-0">
|
||||
<div className="mt-0 mb-1 typography-meta text-muted-foreground">
|
||||
{t('settings.openchamber.defaults.smallModel.description')}
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-settings-item="sessions.small-model"
|
||||
className="group flex cursor-pointer items-center gap-2 py-1"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={smallModelUseDefault}
|
||||
onClick={() => void handleSmallModelUseDefaultChange(!smallModelUseDefault)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
void handleSmallModelUseDefaultChange(!smallModelUseDefault);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={smallModelUseDefault}
|
||||
onChange={(checked) => void handleSmallModelUseDefaultChange(checked)}
|
||||
ariaLabel={t('settings.openchamber.defaults.smallModel.useDefaultAria')}
|
||||
/>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.defaults.smallModel.useDefault')}</span>
|
||||
</div>
|
||||
|
||||
{!smallModelUseDefault ? (
|
||||
<div className="flex flex-col gap-2 py-1 sm:flex-row sm:items-center sm:gap-8">
|
||||
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.defaults.smallModel.overrideModel')}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial">
|
||||
<div>
|
||||
<SettingsFieldRow
|
||||
settingsItem="sessions.default-model"
|
||||
label={t('settings.openchamber.defaults.field.defaultModel')}
|
||||
>
|
||||
<ModelSelector
|
||||
providerId={parsedSmallModel.providerId}
|
||||
modelId={parsedSmallModel.modelId}
|
||||
onChange={handleSmallModelOverrideChange}
|
||||
allowedProviderIds={smallModelProviders}
|
||||
providerId={parsedModel.providerId}
|
||||
modelId={parsedModel.modelId}
|
||||
onChange={handleModelChange}
|
||||
className={SETTINGS_CUSTOM_TRIGGER_CLASS}
|
||||
/>
|
||||
</div>
|
||||
</SettingsFieldRow>
|
||||
|
||||
<SettingsFieldRow
|
||||
settingsItem="sessions.default-thinking"
|
||||
label={t('settings.openchamber.defaults.field.defaultThinking')}
|
||||
>
|
||||
<Select value={defaultVariant ?? DEFAULT_VARIANT_VALUE} onValueChange={handleVariantChange} disabled={!supportsVariants}>
|
||||
<SelectTrigger size={SETTINGS_SELECT_SIZE} className={SETTINGS_SELECT_ROW_TRIGGER_CLASS}>
|
||||
<SelectValue placeholder={t('settings.openchamber.defaults.field.thinkingPlaceholder')}>
|
||||
{formatVariantLabel(defaultVariant ?? DEFAULT_VARIANT_VALUE)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={DEFAULT_VARIANT_VALUE}>{t('settings.openchamber.defaults.option.default')}</SelectItem>
|
||||
{availableVariants.map((variant) => (
|
||||
<SelectItem key={variant} value={variant}>
|
||||
{formatVariantLabel(variant)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingsFieldRow>
|
||||
|
||||
<SettingsFieldRow
|
||||
settingsItem="sessions.default-agent"
|
||||
label={t('settings.openchamber.defaults.field.defaultAgent')}
|
||||
>
|
||||
<AgentSelector
|
||||
agentName={defaultAgent || ''}
|
||||
onChange={handleAgentChange}
|
||||
className={SETTINGS_CUSTOM_TRIGGER_CLASS}
|
||||
/>
|
||||
</SettingsFieldRow>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<SettingsInset className={SETTINGS_OPTION_STACK_CLASS}>
|
||||
<SettingsCheckboxRow
|
||||
settingsItem="sessions.deletion-dialog"
|
||||
checked={showDeletionDialog}
|
||||
onChange={setShowDeletionDialog}
|
||||
label={t('settings.openchamber.defaults.field.showDeletionDialog')}
|
||||
ariaLabel={t('settings.openchamber.defaults.field.showDeletionDialogAria')}
|
||||
/>
|
||||
</SettingsInset>
|
||||
|
||||
<div className="space-y-3 pt-6">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<SettingsGroupTitle>
|
||||
{t('settings.openchamber.defaults.smallModel.title')}
|
||||
</SettingsGroupTitle>
|
||||
<SettingsInfoHint>
|
||||
{t('settings.openchamber.defaults.smallModel.description')}
|
||||
</SettingsInfoHint>
|
||||
</div>
|
||||
|
||||
<SettingsCheckboxRow
|
||||
settingsItem="sessions.small-model"
|
||||
checked={smallModelUseDefault}
|
||||
onChange={(checked) => {
|
||||
void handleSmallModelUseDefaultChange(checked);
|
||||
}}
|
||||
label={t('settings.openchamber.defaults.smallModel.useDefault')}
|
||||
ariaLabel={t('settings.openchamber.defaults.smallModel.useDefaultAria')}
|
||||
/>
|
||||
|
||||
{!smallModelUseDefault ? (
|
||||
<SettingsFieldRow label={t('settings.openchamber.defaults.smallModel.overrideModel')}>
|
||||
<ModelSelector
|
||||
providerId={parsedSmallModel.providerId}
|
||||
modelId={parsedSmallModel.modelId}
|
||||
onChange={handleSmallModelOverrideChange}
|
||||
allowedProviderIds={smallModelProviders}
|
||||
className={SETTINGS_CUSTOM_TRIGGER_CLASS}
|
||||
/>
|
||||
</SettingsFieldRow>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
getDesktopLanAddress,
|
||||
@@ -22,7 +22,15 @@ import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsCheckboxRow,
|
||||
SettingsChipGroup,
|
||||
SettingsFieldRow,
|
||||
SETTINGS_OPTION_STACK_CLASS,
|
||||
SettingsStackedField,
|
||||
SETTINGS_ICON_BUTTON_CLASS,
|
||||
} from '@/components/sections/shared/SettingsSection';
|
||||
|
||||
const WINDOW_CONTROLS_POSITION_OPTIONS: Array<{ id: DesktopWindowControlsPosition; labelKey: string }> = [
|
||||
{ id: 'auto', labelKey: 'settings.openchamber.desktopNetwork.option.windowControlsAuto' },
|
||||
@@ -41,6 +49,7 @@ export const DesktopNetworkSettings: React.FC = () => {
|
||||
const [draftValue, setDraftValue] = React.useState(false);
|
||||
const [savedPassword, setSavedPassword] = React.useState('');
|
||||
const [draftPassword, setDraftPassword] = React.useState('');
|
||||
const [showPassword, setShowPassword] = React.useState(false);
|
||||
const [lanAccessActive, setLanAccessActive] = React.useState(false);
|
||||
const [lanAccessBlockedReason, setLanAccessBlockedReason] = React.useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = React.useState(true);
|
||||
@@ -215,10 +224,6 @@ export const DesktopNetworkSettings: React.FC = () => {
|
||||
const lanBlockedByMissingPassword = savedValue && !lanAccessActive && lanAccessBlockedReason === 'missing-password';
|
||||
const saveDisabled = isLoading || isSaving || !isDirty || lanRequiresPassword;
|
||||
|
||||
const handleToggle = React.useCallback(() => {
|
||||
setDraftValue((current) => !current);
|
||||
}, []);
|
||||
|
||||
const handlePasswordChange = React.useCallback((value: string) => {
|
||||
setDraftPassword(value);
|
||||
if (!value.trim()) {
|
||||
@@ -349,223 +354,164 @@ export const DesktopNetworkSettings: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<>
|
||||
{showWindowControlsPosition ? (
|
||||
<>
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.desktopNetwork.field.windowControlsPosition')}</h3>
|
||||
</div>
|
||||
<section className="space-y-2 px-2 pb-2 pt-0">
|
||||
<div data-settings-item="sessions.desktop-window-controls-position" className="space-y-1 py-1.5">
|
||||
<div className="typography-micro text-muted-foreground/70">
|
||||
{t('settings.openchamber.desktopNetwork.field.windowControlsPositionDescription')}
|
||||
</div>
|
||||
<div
|
||||
className="mt-1 flex flex-wrap items-center gap-1"
|
||||
role="group"
|
||||
aria-label={t('settings.openchamber.desktopNetwork.field.windowControlsPositionAria')}
|
||||
>
|
||||
{WINDOW_CONTROLS_POSITION_OPTIONS.map((option) => {
|
||||
const selected = desktopWindowControlsPosition === option.id;
|
||||
return (
|
||||
<Button
|
||||
key={option.id}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className={cn(
|
||||
'!font-normal',
|
||||
selected ? 'border-[var(--primary-base)] text-[var(--primary-base)] bg-[var(--primary-base)]/10' : 'text-foreground',
|
||||
)}
|
||||
aria-pressed={selected}
|
||||
onClick={() => handleWindowControlsPositionChange(option.id)}
|
||||
>
|
||||
{tUnsafe(option.labelKey)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
<SettingsSection title={t('settings.openchamber.desktopNetwork.field.windowControlsPosition')}>
|
||||
<SettingsFieldRow
|
||||
settingsItem="sessions.desktop-window-controls-position"
|
||||
label={t('settings.openchamber.desktopNetwork.field.windowControlsPositionDescription')}
|
||||
alignEnd={false}
|
||||
controlClassName="flex-col items-stretch"
|
||||
>
|
||||
<SettingsChipGroup
|
||||
value={desktopWindowControlsPosition}
|
||||
options={WINDOW_CONTROLS_POSITION_OPTIONS.map((option) => ({
|
||||
value: option.id,
|
||||
label: tUnsafe(option.labelKey),
|
||||
}))}
|
||||
onChange={handleWindowControlsPositionChange}
|
||||
aria-label={t('settings.openchamber.desktopNetwork.field.windowControlsPositionAria')}
|
||||
/>
|
||||
</SettingsFieldRow>
|
||||
</SettingsSection>
|
||||
) : null}
|
||||
|
||||
{!isLocalDesktop ? null : (
|
||||
<>
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.desktopNetwork.title')}</h3>
|
||||
</div>
|
||||
{isLocalDesktop ? (
|
||||
<SettingsSection title={t('settings.openchamber.desktopNetwork.title')}>
|
||||
<div className="space-y-3">
|
||||
{(launchAtLoginSupported || minimizeToTraySupported || keepAwakeSupported) ? (
|
||||
<div className={SETTINGS_OPTION_STACK_CLASS}>
|
||||
{launchAtLoginSupported ? (
|
||||
<SettingsCheckboxRow
|
||||
settingsItem="sessions.desktop-launch-at-login"
|
||||
checked={launchAtLoginEnabled}
|
||||
onChange={(checked) => {
|
||||
if (checked === launchAtLoginEnabled) return;
|
||||
void handleLaunchAtLoginToggle();
|
||||
}}
|
||||
disabled={isSavingLaunchAtLogin}
|
||||
label={t('settings.openchamber.desktopNetwork.field.launchAtLogin')}
|
||||
info={t('settings.openchamber.desktopNetwork.field.launchAtLoginDescription')}
|
||||
ariaLabel={t('settings.openchamber.desktopNetwork.field.launchAtLoginAria')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<section className="space-y-2 px-2 pb-2 pt-0">
|
||||
{launchAtLoginSupported ? (
|
||||
<div
|
||||
data-settings-item="sessions.desktop-launch-at-login"
|
||||
className="group flex cursor-pointer items-start gap-2 py-1.5"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={handleLaunchAtLoginToggle}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
handleLaunchAtLoginToggle();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={launchAtLoginEnabled}
|
||||
onChange={handleLaunchAtLoginToggle}
|
||||
ariaLabel={t('settings.openchamber.desktopNetwork.field.launchAtLoginAria')}
|
||||
disabled={isSavingLaunchAtLogin}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="typography-ui-label text-foreground">{t('settings.openchamber.desktopNetwork.field.launchAtLogin')}</div>
|
||||
<div className="typography-micro text-muted-foreground/70">
|
||||
{t('settings.openchamber.desktopNetwork.field.launchAtLoginDescription')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{minimizeToTraySupported ? (
|
||||
<SettingsCheckboxRow
|
||||
settingsItem="sessions.desktop-minimize-to-tray"
|
||||
checked={minimizeToTrayEnabled}
|
||||
onChange={(checked) => {
|
||||
if (checked === minimizeToTrayEnabled) return;
|
||||
void handleMinimizeToTrayToggle();
|
||||
}}
|
||||
disabled={isSavingMinimizeToTray}
|
||||
label={t('settings.openchamber.desktopNetwork.field.minimizeToTray')}
|
||||
info={t('settings.openchamber.desktopNetwork.field.minimizeToTrayDescription')}
|
||||
ariaLabel={t('settings.openchamber.desktopNetwork.field.minimizeToTrayAria')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{minimizeToTraySupported ? (
|
||||
<div
|
||||
data-settings-item="sessions.desktop-minimize-to-tray"
|
||||
className="group flex cursor-pointer items-start gap-2 py-1.5"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={handleMinimizeToTrayToggle}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
handleMinimizeToTrayToggle();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={minimizeToTrayEnabled}
|
||||
onChange={handleMinimizeToTrayToggle}
|
||||
ariaLabel={t('settings.openchamber.desktopNetwork.field.minimizeToTrayAria')}
|
||||
disabled={isSavingMinimizeToTray}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="typography-ui-label text-foreground">{t('settings.openchamber.desktopNetwork.field.minimizeToTray')}</div>
|
||||
<div className="typography-micro text-muted-foreground/70">
|
||||
{t('settings.openchamber.desktopNetwork.field.minimizeToTrayDescription')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{keepAwakeSupported ? (
|
||||
<div
|
||||
data-settings-item="sessions.desktop-keep-awake"
|
||||
className="group flex cursor-pointer items-start gap-2 py-1.5"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={handleKeepAwakeToggle}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
handleKeepAwakeToggle();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={keepAwakeEnabled}
|
||||
onChange={handleKeepAwakeToggle}
|
||||
ariaLabel={t('settings.openchamber.desktopNetwork.field.keepAwakeAria')}
|
||||
disabled={isSavingKeepAwake}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="typography-ui-label text-foreground">{t('settings.openchamber.desktopNetwork.field.keepAwake')}</div>
|
||||
<div className="typography-micro text-muted-foreground/70">
|
||||
{t('settings.openchamber.desktopNetwork.field.keepAwakeDescription')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div data-settings-item="sessions.desktop-ui-password" className="space-y-1 py-1.5">
|
||||
<label className="typography-ui-label text-foreground" htmlFor="desktop-ui-password">
|
||||
{t('settings.openchamber.desktopPassword.field.password')}
|
||||
</label>
|
||||
<Input
|
||||
id="desktop-ui-password"
|
||||
type="password"
|
||||
className="h-7 max-w-sm"
|
||||
value={draftPassword}
|
||||
onChange={(event) => handlePasswordChange(event.target.value)}
|
||||
placeholder={t('settings.openchamber.desktopPassword.field.passwordPlaceholder')}
|
||||
disabled={isLoading || isSaving}
|
||||
required={draftValue}
|
||||
aria-invalid={lanRequiresPassword}
|
||||
/>
|
||||
<div className="typography-micro text-muted-foreground/70">
|
||||
{t('settings.openchamber.desktopPassword.field.passwordDescription')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-settings-item="sessions.desktop-lan-access"
|
||||
className="group flex cursor-pointer items-start gap-2 py-1.5"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={handleToggle}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
handleToggle();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={draftValue}
|
||||
onChange={handleToggle}
|
||||
ariaLabel={t('settings.openchamber.desktopNetwork.field.allowLanAccessAria')}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="typography-ui-label text-foreground">{t('settings.openchamber.desktopNetwork.field.allowLanAccess')}</div>
|
||||
<div className="typography-micro text-muted-foreground/70">
|
||||
{t('settings.openchamber.desktopNetwork.field.allowLanAccessDescription')}
|
||||
</div>
|
||||
<div className="typography-micro text-[var(--status-warning)]/85">
|
||||
{t('settings.openchamber.desktopNetwork.field.warning')}
|
||||
</div>
|
||||
{lanRequiresPassword || lanBlockedByMissingPassword ? (
|
||||
<div className="typography-micro text-[var(--status-warning)]/85">
|
||||
{t('settings.openchamber.desktopNetwork.field.passwordRequiredWarning')}
|
||||
{keepAwakeSupported ? (
|
||||
<SettingsCheckboxRow
|
||||
settingsItem="sessions.desktop-keep-awake"
|
||||
checked={keepAwakeEnabled}
|
||||
onChange={(checked) => {
|
||||
if (checked === keepAwakeEnabled) return;
|
||||
void handleKeepAwakeToggle();
|
||||
}}
|
||||
disabled={isSavingKeepAwake}
|
||||
label={t('settings.openchamber.desktopNetwork.field.keepAwake')}
|
||||
info={t('settings.openchamber.desktopNetwork.field.keepAwakeDescription')}
|
||||
ariaLabel={t('settings.openchamber.desktopNetwork.field.keepAwakeAria')}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<SettingsStackedField
|
||||
settingsItem="sessions.desktop-ui-password"
|
||||
label={(
|
||||
<label htmlFor="desktop-ui-password">
|
||||
{t('settings.openchamber.desktopPassword.field.password')}
|
||||
</label>
|
||||
)}
|
||||
info={t('settings.openchamber.desktopPassword.field.passwordDescription')}
|
||||
>
|
||||
<Input
|
||||
id="desktop-ui-password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
className="h-8 min-w-0 flex-1"
|
||||
value={draftPassword}
|
||||
onChange={(event) => handlePasswordChange(event.target.value)}
|
||||
placeholder={t('settings.openchamber.desktopPassword.field.passwordPlaceholder')}
|
||||
disabled={isLoading || isSaving}
|
||||
required={draftValue}
|
||||
aria-invalid={lanRequiresPassword}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => setShowPassword((current: boolean) => !current)}
|
||||
className={SETTINGS_ICON_BUTTON_CLASS}
|
||||
aria-label={t(showPassword ? 'settings.openchamber.desktopPassword.actions.hidePassword' : 'settings.openchamber.desktopPassword.actions.showPassword')}
|
||||
aria-pressed={showPassword}
|
||||
>
|
||||
<Icon name={showPassword ? 'eye-off' : 'eye'} className="h-4 w-4" />
|
||||
</Button>
|
||||
</SettingsStackedField>
|
||||
|
||||
<div className={SETTINGS_OPTION_STACK_CLASS}>
|
||||
<SettingsCheckboxRow
|
||||
settingsItem="sessions.desktop-lan-access"
|
||||
checked={draftValue}
|
||||
onChange={setDraftValue}
|
||||
disabled={isLoading || isSaving}
|
||||
label={t('settings.openchamber.desktopNetwork.field.allowLanAccess')}
|
||||
info={t('settings.openchamber.desktopNetwork.field.allowLanAccessDescription')}
|
||||
description={(
|
||||
<>
|
||||
<span className="block text-[var(--status-warning)]/85">
|
||||
{t('settings.openchamber.desktopNetwork.field.warning')}
|
||||
</span>
|
||||
{lanRequiresPassword || lanBlockedByMissingPassword ? (
|
||||
<span className="block text-[var(--status-warning)]/85">
|
||||
{t('settings.openchamber.desktopNetwork.field.passwordRequiredWarning')}
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
ariaLabel={t('settings.openchamber.desktopNetwork.field.allowLanAccessAria')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="typography-micro text-[var(--status-error)]">{error}</div>
|
||||
) : null}
|
||||
|
||||
{lanUrl ? (
|
||||
<div className="typography-micro text-muted-foreground/80">
|
||||
{isDirty && !savedValue
|
||||
? t('settings.openchamber.desktopNetwork.hint.openAfterRestart')
|
||||
: t('settings.openchamber.desktopNetwork.hint.openNow')}
|
||||
<span className="font-mono text-foreground">{lanUrl}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex justify-start py-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
onClick={handleSaveAndRestart}
|
||||
disabled={saveDisabled}
|
||||
className="shrink-0 !font-normal"
|
||||
>
|
||||
{isSaving ? t('settings.common.actions.saving') : t('settings.openchamber.desktopNetwork.actions.saveAndRestart')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="px-2 typography-micro text-[var(--status-error)]">{error}</div>
|
||||
) : null}
|
||||
|
||||
{lanUrl ? (
|
||||
<div className="px-2 typography-micro text-muted-foreground/80">
|
||||
{isDirty && !savedValue
|
||||
? t('settings.openchamber.desktopNetwork.hint.openAfterRestart')
|
||||
: t('settings.openchamber.desktopNetwork.hint.openNow')}
|
||||
<span className="font-mono text-foreground">{lanUrl}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex justify-start py-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
onClick={handleSaveAndRestart}
|
||||
disabled={saveDisabled}
|
||||
className="shrink-0 !font-normal"
|
||||
>
|
||||
{isSaving ? t('settings.common.actions.saving') : t('settings.openchamber.desktopNetwork.actions.saveAndRestart')}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,9 +8,9 @@ import { useDeviceInfo } from '@/lib/device';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { openExternalUrl } from '@/lib/url';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { SettingsSection, SettingsGroupTitle } from '@/components/sections/shared/SettingsSection';
|
||||
|
||||
type GitHubUser = {
|
||||
login: string;
|
||||
@@ -270,21 +270,13 @@ export const GitHubSettings: React.FC = () => {
|
||||
: t('settings.github.page.accountSource.oauth');
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div className="mb-3 px-1 flex items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="typography-ui-header font-semibold text-foreground">{t('settings.github.page.oauth.title')}</h3>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
{t('settings.github.page.tooltip.connectAccount')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<>
|
||||
<SettingsSection
|
||||
title={t('settings.github.page.oauth.title')}
|
||||
divider={false}
|
||||
settingsItem="git.github-account"
|
||||
info={t('settings.github.page.tooltip.connectAccount')}
|
||||
>
|
||||
<div className="rounded-lg bg-[var(--surface-elevated)]/70 overflow-hidden flex flex-col">
|
||||
{connected ? (
|
||||
<div className={cn("px-4 py-3", isMobile ? "flex flex-col gap-3" : "flex items-center justify-between gap-4")}>
|
||||
@@ -422,7 +414,7 @@ export const GitHubSettings: React.FC = () => {
|
||||
{flow && (
|
||||
<div className="mt-4 rounded-lg bg-[var(--surface-elevated)]/70 p-4 border border-[var(--interactive-border)]">
|
||||
<div className="space-y-1">
|
||||
<h4 className="typography-ui-label text-foreground">{t('settings.github.page.flow.title')}</h4>
|
||||
<SettingsGroupTitle>{t('settings.github.page.flow.title')}</SettingsGroupTitle>
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
{t('settings.github.page.flow.description')}
|
||||
</p>
|
||||
@@ -453,11 +445,10 @@ export const GitHubSettings: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
</SettingsSection>
|
||||
|
||||
{ghCli?.available && !ghCli?.active && (!ghCli.user || ghCli.disabled) && (
|
||||
<div className="mt-6">
|
||||
<h3 className="typography-ui-header font-semibold text-foreground mb-3 px-1">
|
||||
{t('settings.github.page.ghCli.title')}
|
||||
</h3>
|
||||
<SettingsSection title={t('settings.github.page.ghCli.title')}>
|
||||
<div className="rounded-lg bg-[var(--surface-elevated)]/70 overflow-hidden">
|
||||
<div className={cn("px-4 py-3", isMobile ? "flex flex-col gap-3" : "flex items-center justify-between gap-4")}>
|
||||
<div className={cn("flex min-w-0 items-center gap-4", isMobile ? "w-full" : undefined)}>
|
||||
@@ -508,8 +499,8 @@ export const GitHubSettings: React.FC = () => {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import React from 'react';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Radio } from '@/components/ui/radio';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
@@ -8,6 +6,14 @@ import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { setFilesViewShowGitignored, useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsControlGroup,
|
||||
SettingsRadioGroup,
|
||||
SettingsRadioOption,
|
||||
SettingsCheckboxRow,
|
||||
SETTINGS_OPTION_STACK_CLASS,
|
||||
} from '@/components/sections/shared/SettingsSection';
|
||||
|
||||
export const GitSettings: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
@@ -116,94 +122,45 @@ export const GitSettings: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.git.title')}</h3>
|
||||
<SettingsSection title={t('settings.openchamber.git.title')}>
|
||||
<div className={SETTINGS_OPTION_STACK_CLASS}>
|
||||
<SettingsControlGroup
|
||||
settingsItem="git.changes-view"
|
||||
title={t('settings.openchamber.git.changesViewTitle')}
|
||||
>
|
||||
<SettingsRadioGroup aria-label={t('settings.openchamber.git.changesViewAria')}>
|
||||
{viewOptions.map((option) => (
|
||||
<SettingsRadioOption
|
||||
key={option.id}
|
||||
selected={gitChangesViewMode === option.id}
|
||||
onSelect={() => {
|
||||
handleGitChangesViewModeChange(option.id);
|
||||
}}
|
||||
label={option.label}
|
||||
ariaLabel={t('settings.openchamber.git.optionAria', { option: option.label })}
|
||||
/>
|
||||
))}
|
||||
</SettingsRadioGroup>
|
||||
</SettingsControlGroup>
|
||||
|
||||
<SettingsCheckboxRow
|
||||
settingsItem="git.gitmoji"
|
||||
checked={settingsGitmojiEnabled}
|
||||
onChange={(checked) => {
|
||||
void handleGitmojiChange(checked);
|
||||
}}
|
||||
label={t('settings.openchamber.git.enableGitmoji')}
|
||||
ariaLabel={t('settings.openchamber.git.enableGitmojiAria')}
|
||||
/>
|
||||
|
||||
<SettingsCheckboxRow
|
||||
settingsItem="git.gitignored-files"
|
||||
checked={showGitignored}
|
||||
onChange={setFilesViewShowGitignored}
|
||||
label={t('settings.openchamber.git.showGitignored')}
|
||||
ariaLabel={t('settings.openchamber.git.showGitignoredAria')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="px-2 pb-2 pt-0 space-y-0.5">
|
||||
<div data-settings-item="git.changes-view" className="pt-1 pb-1">
|
||||
<h4 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.git.changesViewTitle')}</h4>
|
||||
<div role="radiogroup" aria-label={t('settings.openchamber.git.changesViewAria')} className="mt-0.5 space-y-0">
|
||||
{viewOptions.map((option) => {
|
||||
const selected = gitChangesViewMode === option.id;
|
||||
return (
|
||||
<div
|
||||
key={option.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={selected}
|
||||
onClick={() => { handleGitChangesViewModeChange(option.id); }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
handleGitChangesViewModeChange(option.id);
|
||||
}
|
||||
}}
|
||||
className="flex w-full items-center gap-2 py-0 text-left"
|
||||
>
|
||||
<Radio
|
||||
checked={selected}
|
||||
onChange={() => { handleGitChangesViewModeChange(option.id); }}
|
||||
ariaLabel={t('settings.openchamber.git.optionAria', { option: option.label })}
|
||||
/>
|
||||
<span className={selected ? 'typography-ui-label font-normal text-foreground' : 'typography-ui-label font-normal text-foreground/50'}>
|
||||
{option.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-settings-item="git.gitmoji"
|
||||
className="group flex cursor-pointer items-center gap-2 py-1.5"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={settingsGitmojiEnabled}
|
||||
onClick={() => {
|
||||
void handleGitmojiChange(!settingsGitmojiEnabled);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
void handleGitmojiChange(!settingsGitmojiEnabled);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={settingsGitmojiEnabled}
|
||||
onChange={(checked) => {
|
||||
void handleGitmojiChange(checked);
|
||||
}}
|
||||
ariaLabel={t('settings.openchamber.git.enableGitmojiAria')}
|
||||
/>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.git.enableGitmoji')}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-settings-item="git.gitignored-files"
|
||||
className="group flex cursor-pointer items-center gap-2 py-1.5"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={showGitignored}
|
||||
onClick={() => setFilesViewShowGitignored(!showGitignored)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
setFilesViewShowGitignored(!showGitignored);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={showGitignored}
|
||||
onChange={setFilesViewShowGitignored}
|
||||
ariaLabel={t('settings.openchamber.git.showGitignoredAria')}
|
||||
/>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.git.showGitignored')}</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsFieldRow,
|
||||
} from '@/components/sections/shared/SettingsSection';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
@@ -157,41 +159,34 @@ export const KeyboardShortcutsSettings: React.FC = () => {
|
||||
}, [clearShortcutOverride, persistShortcutOverrides, shortcutOverrides]);
|
||||
|
||||
return (
|
||||
<div data-settings-item="shortcuts.keyboard-shortcuts" className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.keyboardShortcuts.title')}</h3>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => {
|
||||
resetAllShortcutOverrides();
|
||||
persistShortcutOverrides({});
|
||||
setDraftByAction({});
|
||||
setPendingOverwrite(null);
|
||||
setErrorText('');
|
||||
setWarningText('');
|
||||
}}
|
||||
>
|
||||
{t('settings.openchamber.keyboardShortcuts.actions.resetAll')}
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
{t('settings.openchamber.keyboardShortcuts.tooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingsSection
|
||||
settingsItem="shortcuts.keyboard-shortcuts"
|
||||
title={t('settings.openchamber.keyboardShortcuts.title')}
|
||||
divider={false}
|
||||
info={t('settings.openchamber.keyboardShortcuts.tooltip')}
|
||||
headerAction={(
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => {
|
||||
resetAllShortcutOverrides();
|
||||
persistShortcutOverrides({});
|
||||
setDraftByAction({});
|
||||
setPendingOverwrite(null);
|
||||
setErrorText('');
|
||||
setWarningText('');
|
||||
}}
|
||||
>
|
||||
{t('settings.openchamber.keyboardShortcuts.actions.resetAll')}
|
||||
</Button>
|
||||
)}
|
||||
>
|
||||
{(errorText || warningText || pendingOverwrite) && (
|
||||
<div className="mb-2 space-y-2 px-1">
|
||||
<div className="mb-2 space-y-2">
|
||||
{pendingOverwrite && (
|
||||
<div className="rounded-lg border border-[var(--status-warning-border)] bg-[var(--status-warning-background)] p-3 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div className="rounded-lg border border-[var(--status-warning-border)] bg-[var(--status-warning-background)] p-3 flex flex-col @xl:flex-row @xl:items-center justify-between gap-3">
|
||||
<span className="typography-meta text-foreground">
|
||||
{t('settings.openchamber.keyboardShortcuts.overwritePrompt')}
|
||||
</span>
|
||||
@@ -214,7 +209,7 @@ export const KeyboardShortcutsSettings: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="px-2 pb-2 pt-0 space-y-0.5">
|
||||
<div>
|
||||
{actions.map((action, index) => {
|
||||
const effective = getEffectiveShortcutCombo(action.id, shortcutOverrides);
|
||||
const draft = draftByAction[action.id];
|
||||
@@ -222,11 +217,11 @@ export const KeyboardShortcutsSettings: React.FC = () => {
|
||||
const hasDraft = typeof draft === 'string' && normalizeCombo(draft) !== normalizeCombo(effective);
|
||||
|
||||
return (
|
||||
<div key={action.id} className={cn("flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8", index > 0 && "border-t border-[var(--surface-subtle)]")}>
|
||||
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
|
||||
<span className="typography-ui-label text-foreground">{actionLabel(action.id, action.label)}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial">
|
||||
<div key={action.id} className={cn("py-1.5", index > 0 && "border-t border-border/40")}>
|
||||
<SettingsFieldRow
|
||||
label={actionLabel(action.id, action.label)}
|
||||
alignEnd={false}
|
||||
>
|
||||
<Input
|
||||
readOnly
|
||||
value={capturingActionId === action.id ? t('settings.openchamber.keyboardShortcuts.field.pressKeys') : formatShortcutForDisplay(displayCombo)}
|
||||
@@ -283,11 +278,11 @@ export const KeyboardShortcutsSettings: React.FC = () => {
|
||||
<Button type="button" size="xs" className="!font-normal" variant="ghost" onClick={() => resetOne(action.id)}>
|
||||
{t('settings.common.actions.reset')}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsFieldRow>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import React from 'react';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { isDesktopShell, isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { toast } from '@/components/ui';
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getClientPlatform } from '@/lib/platform';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsTwoColumn,
|
||||
SettingsCheckboxRow,
|
||||
SettingsGroupTitle,
|
||||
SETTINGS_OPTION_STACK_CLASS,
|
||||
} from '@/components/sections/shared/SettingsSection';
|
||||
|
||||
const DEFAULT_NOTIFICATION_TEMPLATES = {
|
||||
completion: {
|
||||
@@ -448,67 +453,40 @@ export const NotificationSettings: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
|
||||
{/* --- Global Delivery Settings --- */}
|
||||
<div data-settings-item="notifications.delivery" className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
{t('settings.notifications.page.delivery.title')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<section className="px-2 pb-2 pt-0 space-y-0.5">
|
||||
<div
|
||||
className="group flex cursor-pointer items-center gap-2 py-1.5"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={nativeNotificationsEnabled && canShowNotifications}
|
||||
onClick={() => {
|
||||
void handleToggleChange(!(nativeNotificationsEnabled && canShowNotifications));
|
||||
<>
|
||||
<SettingsSection
|
||||
settingsItem="notifications.delivery"
|
||||
title={t('settings.notifications.page.delivery.title')}
|
||||
divider={false}
|
||||
>
|
||||
<div className={SETTINGS_OPTION_STACK_CLASS}>
|
||||
<SettingsCheckboxRow
|
||||
checked={nativeNotificationsEnabled && canShowNotifications}
|
||||
onChange={(checked) => {
|
||||
void handleToggleChange(checked);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
void handleToggleChange(!(nativeNotificationsEnabled && canShowNotifications));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={nativeNotificationsEnabled && canShowNotifications}
|
||||
onChange={(checked) => {
|
||||
void handleToggleChange(checked);
|
||||
}}
|
||||
ariaLabel={t('settings.notifications.page.delivery.enableAria')}
|
||||
/>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.notifications.page.delivery.enableLabel')}</span>
|
||||
</div>
|
||||
label={t('settings.notifications.page.delivery.enableLabel')}
|
||||
info={
|
||||
isBrowser
|
||||
? t('settings.notifications.page.delivery.browserPermissionHint')
|
||||
: isVSCode
|
||||
? t('settings.notifications.page.delivery.vscodeHint')
|
||||
: undefined
|
||||
}
|
||||
ariaLabel={t('settings.notifications.page.delivery.enableAria')}
|
||||
/>
|
||||
|
||||
{/* The native Capacitor app never notifies while focused (hard rule) and uses
|
||||
generic, non-customizable text, so the "notify while focused" toggle and the
|
||||
test button are hidden there. */}
|
||||
{nativeNotificationsEnabled && canShowNotifications && !isNativeApp && (
|
||||
<>
|
||||
<div
|
||||
className="group flex cursor-pointer items-center gap-2 py-1.5"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={notificationMode === 'always'}
|
||||
onClick={() => setNotificationMode(notificationMode === 'always' ? 'hidden-only' : 'always')}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
setNotificationMode(notificationMode === 'always' ? 'hidden-only' : 'always');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={notificationMode === 'always'}
|
||||
onChange={(checked) => setNotificationMode(checked ? 'always' : 'hidden-only')}
|
||||
ariaLabel={t('settings.notifications.page.delivery.focusedAria')}
|
||||
/>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.notifications.page.delivery.focusedLabel')}</span>
|
||||
</div>
|
||||
<SettingsCheckboxRow
|
||||
checked={notificationMode === 'always'}
|
||||
onChange={(checked) => setNotificationMode(checked ? 'always' : 'hidden-only')}
|
||||
label={t('settings.notifications.page.delivery.focusedLabel')}
|
||||
ariaLabel={t('settings.notifications.page.delivery.focusedAria')}
|
||||
/>
|
||||
|
||||
<div className="py-2">
|
||||
<Button
|
||||
@@ -522,13 +500,10 @@ export const NotificationSettings: React.FC = () => {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{isBrowser && (
|
||||
<div className="mt-1 px-2">
|
||||
<p className="typography-meta text-muted-foreground/70">
|
||||
{t('settings.notifications.page.delivery.browserPermissionHint')}
|
||||
</p>
|
||||
<div className="mt-1">
|
||||
{notificationPermission === 'denied' && (
|
||||
<p className="typography-meta text-[var(--status-error)] mt-1">
|
||||
{t('settings.notifications.page.delivery.permissionDenied')}
|
||||
@@ -541,104 +516,50 @@ export const NotificationSettings: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isVSCode && (
|
||||
<div className="mt-1 px-2">
|
||||
<p className="typography-meta text-muted-foreground/70">
|
||||
{t('settings.notifications.page.delivery.vscodeHint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{nativeNotificationsEnabled && canShowNotifications && (
|
||||
<>
|
||||
{/* --- Events --- */}
|
||||
<div data-settings-item="notifications.events" className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
{t('settings.notifications.page.events.title')}
|
||||
</h3>
|
||||
<SettingsSection
|
||||
settingsItem="notifications.events"
|
||||
title={t('settings.notifications.page.events.title')}
|
||||
>
|
||||
<div className={SETTINGS_OPTION_STACK_CLASS}>
|
||||
<SettingsCheckboxRow
|
||||
checked={notifyOnCompletion}
|
||||
onChange={setNotifyOnCompletion}
|
||||
label={t('settings.notifications.page.events.completionLabel')}
|
||||
ariaLabel={t('settings.notifications.page.events.completionAria')}
|
||||
/>
|
||||
|
||||
<SettingsCheckboxRow
|
||||
checked={notifyOnSubtasks}
|
||||
onChange={setNotifyOnSubtasks}
|
||||
label={t('settings.notifications.page.events.subtaskLabel')}
|
||||
ariaLabel={t('settings.notifications.page.events.subtaskAria')}
|
||||
/>
|
||||
|
||||
<SettingsCheckboxRow
|
||||
checked={notifyOnError}
|
||||
onChange={setNotifyOnError}
|
||||
label={t('settings.notifications.page.events.errorLabel')}
|
||||
ariaLabel={t('settings.notifications.page.events.errorAria')}
|
||||
/>
|
||||
|
||||
<SettingsCheckboxRow
|
||||
checked={notifyOnQuestion}
|
||||
onChange={setNotifyOnQuestion}
|
||||
label={t('settings.notifications.page.events.questionLabel')}
|
||||
ariaLabel={t('settings.notifications.page.events.questionAria')}
|
||||
/>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<section className="px-2 pb-2 pt-0 space-y-0.5">
|
||||
<div
|
||||
className="group flex cursor-pointer items-center gap-2 py-1.5"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={notifyOnCompletion}
|
||||
onClick={() => setNotifyOnCompletion(!notifyOnCompletion)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
setNotifyOnCompletion(!notifyOnCompletion);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={notifyOnCompletion} onChange={setNotifyOnCompletion} ariaLabel={t('settings.notifications.page.events.completionAria')} />
|
||||
<span className="typography-ui-label text-foreground">{t('settings.notifications.page.events.completionLabel')}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="group flex cursor-pointer items-center gap-2 py-1.5"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={notifyOnSubtasks}
|
||||
onClick={() => setNotifyOnSubtasks(!notifyOnSubtasks)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
setNotifyOnSubtasks(!notifyOnSubtasks);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={notifyOnSubtasks} onChange={setNotifyOnSubtasks} ariaLabel={t('settings.notifications.page.events.subtaskAria')} />
|
||||
<span className="typography-ui-label text-foreground">{t('settings.notifications.page.events.subtaskLabel')}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="group flex cursor-pointer items-center gap-2 py-1.5"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={notifyOnError}
|
||||
onClick={() => setNotifyOnError(!notifyOnError)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
setNotifyOnError(!notifyOnError);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={notifyOnError} onChange={setNotifyOnError} ariaLabel={t('settings.notifications.page.events.errorAria')} />
|
||||
<span className="typography-ui-label text-foreground">{t('settings.notifications.page.events.errorLabel')}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="group flex cursor-pointer items-center gap-2 py-1.5"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={notifyOnQuestion}
|
||||
onClick={() => setNotifyOnQuestion(!notifyOnQuestion)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
setNotifyOnQuestion(!notifyOnQuestion);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={notifyOnQuestion} onChange={setNotifyOnQuestion} ariaLabel={t('settings.notifications.page.events.questionAria')} />
|
||||
<span className="typography-ui-label text-foreground">{t('settings.notifications.page.events.questionLabel')}</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* --- Template Customization (not on the native app — it uses generic text) --- */}
|
||||
{!isNativeApp && (
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
{t('settings.notifications.page.template.title')}
|
||||
</h3>
|
||||
<p className="typography-meta text-muted-foreground mt-0.5">
|
||||
<SettingsSection
|
||||
title={t('settings.notifications.page.template.title')}
|
||||
description={(
|
||||
<>
|
||||
{t('settings.notifications.page.template.variablesLabel')}{' '}
|
||||
<code className="text-[var(--primary-base)]">{'{project_name}'}</code>{' '}
|
||||
<code className="text-[var(--primary-base)]">{'{worktree}'}</code>{' '}
|
||||
@@ -647,15 +568,15 @@ export const NotificationSettings: React.FC = () => {
|
||||
<code className="text-[var(--primary-base)]">{'{agent_name}'}</code>{' '}
|
||||
<code className="text-[var(--primary-base)]">{'{model_name}'}</code>{' '}
|
||||
<code className="text-[var(--primary-base)]">{'{last_message}'}</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 md:grid-cols-2 md:gap-3">
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<SettingsTwoColumn className="gap-2 md:grid-cols-2 md:gap-3 lg:gap-3">
|
||||
{(['completion', 'subtask', 'error', 'question'] as const).map((event: NotificationTemplateEvent) => (
|
||||
<section key={event} className="p-2">
|
||||
<span className="typography-ui-label text-foreground font-normal capitalize block">
|
||||
<SettingsGroupTitle className="capitalize">
|
||||
{t(TEMPLATE_EVENT_LABEL_KEYS[event])}
|
||||
</span>
|
||||
</SettingsGroupTitle>
|
||||
<div className="mt-1.5 space-y-2">
|
||||
<div>
|
||||
<label className="typography-micro text-muted-foreground block mb-1">{t('settings.notifications.page.template.field.title')}</label>
|
||||
@@ -678,56 +599,40 @@ export const NotificationSettings: React.FC = () => {
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SettingsTwoColumn>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* --- Background Push Notifications --- */}
|
||||
{isBrowser && (
|
||||
<div data-settings-item="notifications.push" className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
{t('settings.notifications.page.push.title')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<section className="px-2 pb-2 pt-0">
|
||||
<div className="flex items-start gap-2 py-1.5">
|
||||
<Checkbox
|
||||
checked={pushSupported ? pushSubscribed : false}
|
||||
disabled={!pushSupported || pushBusy}
|
||||
onChange={(checked: boolean) => {
|
||||
if (checked) {
|
||||
void handleEnableBackgroundNotifications();
|
||||
} else {
|
||||
void handleDisableBackgroundNotifications();
|
||||
}
|
||||
}}
|
||||
ariaLabel={t('settings.notifications.page.push.enableAria')}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className={cn("typography-ui-label", !pushSupported ? "text-muted-foreground" : "text-foreground")}>
|
||||
{t('settings.notifications.page.push.enableLabel')}
|
||||
</span>
|
||||
<span className="typography-meta text-muted-foreground">
|
||||
{!pushSupported
|
||||
? t('settings.notifications.page.push.unsupportedHint')
|
||||
: t('settings.notifications.page.push.supportedHint')}
|
||||
</span>
|
||||
</div>
|
||||
{pushBusy && (
|
||||
<div className="pt-0.5 text-muted-foreground">
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-current animate-busy-pulse" aria-label={t('settings.notifications.page.push.loadingAria')} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<SettingsSection
|
||||
settingsItem="notifications.push"
|
||||
title={t('settings.notifications.page.push.title')}
|
||||
>
|
||||
<SettingsCheckboxRow
|
||||
checked={pushSupported ? pushSubscribed : false}
|
||||
disabled={!pushSupported || pushBusy}
|
||||
onChange={(checked) => {
|
||||
if (checked) {
|
||||
void handleEnableBackgroundNotifications();
|
||||
} else {
|
||||
void handleDisableBackgroundNotifications();
|
||||
}
|
||||
}}
|
||||
label={t('settings.notifications.page.push.enableLabel')}
|
||||
description={!pushSupported ? t('settings.notifications.page.push.unsupportedHint') : undefined}
|
||||
info={pushSupported ? t('settings.notifications.page.push.supportedHint') : undefined}
|
||||
ariaLabel={t('settings.notifications.page.push.enableAria')}
|
||||
labelAccessory={
|
||||
pushBusy ? (
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-current text-muted-foreground animate-busy-pulse" aria-label={t('settings.notifications.page.push.loadingAria')} />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,9 +12,11 @@ import { TunnelSettings } from './TunnelSettings';
|
||||
import { OpenCodeCliSettings } from './OpenCodeCliSettings';
|
||||
import { DesktopNetworkSettings } from './DesktopNetworkSettings';
|
||||
import { KeyboardShortcutsSettings } from './KeyboardShortcutsSettings';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime, isWebRuntime, usesFramelessElectronChrome } from '@/lib/desktop';
|
||||
import { isCapacitorApp } from '@/lib/platform';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
|
||||
import type { OpenChamberSection } from './types';
|
||||
|
||||
@@ -34,6 +36,7 @@ interface OpenChamberPageProps {
|
||||
}
|
||||
|
||||
export const OpenChamberPage: React.FC<OpenChamberPageProps> = ({ section }) => {
|
||||
const { t } = useI18n();
|
||||
const { isMobile } = useDeviceInfo();
|
||||
const runtimeEndpointEpoch = useRuntimeEndpointEpoch();
|
||||
const showAbout = isMobile && isWebRuntime();
|
||||
@@ -44,44 +47,23 @@ export const OpenChamberPage: React.FC<OpenChamberPageProps> = ({ section }) =>
|
||||
// If no section specified, show all (mobile/legacy behavior)
|
||||
if (!section) {
|
||||
return (
|
||||
<ScrollableOverlay
|
||||
outerClassName="h-full"
|
||||
className="w-full"
|
||||
>
|
||||
<div className="openchamber-page-body mx-auto max-w-3xl space-y-3 p-3 sm:space-y-6 sm:p-6 sm:pt-8">
|
||||
<OpenChamberVisualSettings />
|
||||
<div className="border-t border-border/40 pt-6">
|
||||
<DefaultsSettings />
|
||||
</div>
|
||||
{showDesktopNetworkSettings && (
|
||||
<div className="border-t border-border/40 pt-6">
|
||||
<DesktopNetworkSettings />
|
||||
</div>
|
||||
)}
|
||||
{!isVSCode && (
|
||||
<div className="border-t border-border/40 pt-6">
|
||||
<OpenCodeCliSettings />
|
||||
</div>
|
||||
)}
|
||||
<div className="border-t border-border/40 pt-6">
|
||||
<SessionRetentionSettings />
|
||||
</div>
|
||||
<div className="border-t border-border/40 pt-6">
|
||||
<PasskeySettings />
|
||||
</div>
|
||||
{showAbout && (
|
||||
<div className="border-t border-border/40 pt-6">
|
||||
<AboutSettings />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollableOverlay>
|
||||
<SettingsPageLayout showSaveStatus className="openchamber-page-body space-y-3 sm:space-y-6">
|
||||
<OpenChamberVisualSettings />
|
||||
<DefaultsSettings />
|
||||
{showDesktopNetworkSettings && <DesktopNetworkSettings />}
|
||||
{!isVSCode && <OpenCodeCliSettings />}
|
||||
<SessionRetentionSettings />
|
||||
{isWebRuntime() && !isDesktopShell() && !isVSCode && !isCapacitorApp() && <PasskeySettings />}
|
||||
{showAbout && <AboutSettings />}
|
||||
</SettingsPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// Show specific section content
|
||||
const renderSectionContent = () => {
|
||||
switch (section) {
|
||||
case 'general':
|
||||
return <GeneralSectionContent />;
|
||||
case 'visual':
|
||||
return <VisualSectionContent />;
|
||||
case 'chat':
|
||||
@@ -105,15 +87,41 @@ export const OpenChamberPage: React.FC<OpenChamberPageProps> = ({ section }) =>
|
||||
}
|
||||
};
|
||||
|
||||
const pageTitle = {
|
||||
general: t('settings.page.general.title'),
|
||||
visual: t('settings.page.appearance.title'),
|
||||
chat: t('settings.page.chat.title'),
|
||||
sessions: t('settings.page.sessions.title'),
|
||||
shortcuts: t('settings.page.shortcuts.title'),
|
||||
git: t('settings.page.git.title'),
|
||||
github: t('settings.page.git.title'),
|
||||
notifications: t('settings.page.notifications.title'),
|
||||
voice: t('settings.page.voice.title'),
|
||||
tunnel: t('settings.page.tunnel.title'),
|
||||
}[section];
|
||||
|
||||
const pageDescription = {
|
||||
general: t('settings.page.general.description'),
|
||||
visual: t('settings.page.appearance.description'),
|
||||
chat: t('settings.page.chat.description'),
|
||||
sessions: t('settings.page.sessions.description'),
|
||||
shortcuts: t('settings.page.shortcuts.description'),
|
||||
git: undefined,
|
||||
github: undefined,
|
||||
notifications: t('settings.page.notifications.description'),
|
||||
voice: t('settings.page.voice.description'),
|
||||
tunnel: t('settings.page.tunnel.description'),
|
||||
}[section];
|
||||
|
||||
return (
|
||||
<ScrollableOverlay
|
||||
outerClassName="h-full"
|
||||
className="w-full"
|
||||
<SettingsPageLayout
|
||||
title={pageTitle}
|
||||
description={pageDescription}
|
||||
showSaveStatus
|
||||
className="openchamber-page-body"
|
||||
>
|
||||
<div className="openchamber-page-body mx-auto max-w-3xl space-y-6 p-3 sm:p-6 sm:pt-8">
|
||||
{renderSectionContent()}
|
||||
</div>
|
||||
</ScrollableOverlay>
|
||||
{renderSectionContent()}
|
||||
</SettingsPageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -121,6 +129,34 @@ const ShortcutsSectionContent: React.FC = () => {
|
||||
return <KeyboardShortcutsSettings />;
|
||||
};
|
||||
|
||||
// General section: app-level settings — startup/tray/network, access password,
|
||||
// passkeys, OpenCode CLI binary, message stream transport, privacy.
|
||||
const GeneralSectionContent: React.FC = () => {
|
||||
const isVSCode = isVSCodeRuntime();
|
||||
const runtimeEndpointEpoch = useRuntimeEndpointEpoch();
|
||||
void runtimeEndpointEpoch;
|
||||
const showDesktopNetworkSettings = isDesktopShell() && (isDesktopLocalOriginActive() || usesFramelessElectronChrome());
|
||||
// Passkeys only work against the browser's WebAuthn UI on the web surface —
|
||||
// desktop shell, VS Code, and the Capacitor app never show the login screen.
|
||||
const showPasskeySettings = isWebRuntime() && !isDesktopShell() && !isVSCode && !isCapacitorApp();
|
||||
return (
|
||||
<>
|
||||
{showDesktopNetworkSettings && <DesktopNetworkSettings />}
|
||||
{showPasskeySettings && <PasskeySettings />}
|
||||
{!isVSCode && <OpenCodeCliSettings />}
|
||||
<OpenChamberVisualSettings visibleSettings={[
|
||||
'fileEditorKeymap',
|
||||
'expandedEditorToolbar',
|
||||
...(!isVSCode ? ['terminalQuickKeys' as const] : []),
|
||||
...(!isVSCode ? ['terminalShell' as const] : []),
|
||||
...(!isVSCode ? ['terminalLoginShell' as const] : []),
|
||||
'messageTransport',
|
||||
'reportUsage',
|
||||
]} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// Visual section: Theme Mode, Font Size, Spacing, Input Bar Offset (mobile), Nav Rail
|
||||
const VisualSectionContent: React.FC = () => {
|
||||
const isVSCode = isVSCodeRuntime();
|
||||
@@ -134,14 +170,8 @@ const VisualSectionContent: React.FC = () => {
|
||||
'fontSize',
|
||||
'terminalFontSize',
|
||||
'editorFontSize',
|
||||
'fileEditorKeymap',
|
||||
'spacing',
|
||||
'inputBarOffset',
|
||||
'expandedEditorToolbar',
|
||||
...(!isVSCode ? ['terminalQuickKeys' as const] : []),
|
||||
...(!isVSCode ? ['terminalShell' as const] : []),
|
||||
...(!isVSCode ? ['terminalLoginShell' as const] : []),
|
||||
'reportUsage',
|
||||
]} />;
|
||||
};
|
||||
|
||||
@@ -154,7 +184,6 @@ const ChatSectionContent: React.FC = () => {
|
||||
'sessionGoal',
|
||||
'sessionAssist',
|
||||
'chatRenderMode',
|
||||
'messageTransport',
|
||||
'activityRenderMode',
|
||||
'userMessageRendering',
|
||||
'mermaidRendering',
|
||||
@@ -182,40 +211,17 @@ const ChatSectionContent: React.FC = () => {
|
||||
|
||||
// Sessions section: Default model & agent, Session retention
|
||||
const SessionsSectionContent: React.FC = () => {
|
||||
const isVSCode = isVSCodeRuntime();
|
||||
const runtimeEndpointEpoch = useRuntimeEndpointEpoch();
|
||||
void runtimeEndpointEpoch;
|
||||
const showDesktopNetworkSettings = isDesktopShell() && (isDesktopLocalOriginActive() || usesFramelessElectronChrome());
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<>
|
||||
<DefaultsSettings />
|
||||
{showDesktopNetworkSettings && (
|
||||
<div className="border-t border-border/40 pt-6">
|
||||
<DesktopNetworkSettings />
|
||||
</div>
|
||||
)}
|
||||
{!isVSCode && (
|
||||
<div className="border-t border-border/40 pt-6">
|
||||
<OpenCodeCliSettings />
|
||||
</div>
|
||||
)}
|
||||
<div className="border-t border-border/40 pt-6">
|
||||
<SessionRetentionSettings />
|
||||
</div>
|
||||
<div className="border-t border-border/40 pt-6">
|
||||
<PasskeySettings />
|
||||
</div>
|
||||
</div>
|
||||
<SessionRetentionSettings />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// Git section: Commit message model, Worktree settings
|
||||
const GitSectionContent: React.FC = () => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<GitSettings />
|
||||
</div>
|
||||
);
|
||||
return <GitSettings />;
|
||||
};
|
||||
|
||||
// GitHub section: Connect account for PR/issue workflows
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,15 @@
|
||||
import * as React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsFieldRow,
|
||||
SettingsCheckboxRow,
|
||||
SettingsInset,
|
||||
SETTINGS_ICON_BUTTON_CLASS,
|
||||
SETTINGS_OPTION_STACK_CLASS,
|
||||
} from '@/components/sections/shared/SettingsSection';
|
||||
import { isDesktopShell, requestFileAccess } from '@/lib/desktop';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
|
||||
@@ -96,90 +102,69 @@ export const OpenCodeCliSettings: React.FC = () => {
|
||||
}, [setShowOpenCodeUpdateNotifications]);
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
{t('settings.openchamber.opencodeCli.title')}
|
||||
</h3>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
{t('settings.openchamber.opencodeCli.tooltipPrefix')}
|
||||
<SettingsSection title={t('settings.openchamber.opencodeCli.title')}>
|
||||
<div className="space-y-0.5">
|
||||
<SettingsFieldRow
|
||||
settingsItem="sessions.opencode-binary"
|
||||
label={t('settings.openchamber.opencodeCli.field.binaryPath')}
|
||||
info={(
|
||||
<>
|
||||
{t('settings.openchamber.opencodeCli.tipPrefix')}
|
||||
{' '}
|
||||
<code className="font-mono text-xs">opencode</code>
|
||||
{t('settings.openchamber.opencodeCli.tooltipSuffix')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="px-2 pb-2 pt-0 space-y-0.5">
|
||||
<div data-settings-item="sessions.opencode-binary" className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-3">
|
||||
<div className="flex min-w-0 flex-col shrink-0">
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.opencodeCli.field.binaryPath')}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2 sm:w-[20rem]">
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder={t('settings.openchamber.opencodeCli.field.binaryPathPlaceholder')}
|
||||
disabled={isLoading || isSaving}
|
||||
className="h-7 min-w-0 flex-1 font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={handleBrowse}
|
||||
disabled={isLoading || isSaving || !isDesktopShell()}
|
||||
className="h-7 w-7 p-0"
|
||||
aria-label={t('settings.openchamber.opencodeCli.actions.browseAria')}
|
||||
title={t('settings.openchamber.opencodeCli.actions.browse')}
|
||||
>
|
||||
<Icon name="folder" className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="py-1.5">
|
||||
<div className="typography-micro text-muted-foreground/70">
|
||||
{t('settings.openchamber.opencodeCli.tipPrefix')}
|
||||
{' '}
|
||||
<span className="font-mono">OPENCODE_BINARY</span>
|
||||
{' '}
|
||||
{t('settings.openchamber.opencodeCli.tipMiddle')}
|
||||
{' '}
|
||||
<span className="font-mono">~/.config/openchamber/settings.json</span>
|
||||
{'.'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label data-settings-item="sessions.opencode-update-notifications" className="flex cursor-pointer items-center gap-2 py-1.5">
|
||||
<Checkbox
|
||||
checked={showOpenCodeUpdateNotifications}
|
||||
onChange={handleShowUpdateNotificationsChange}
|
||||
ariaLabel={t('settings.openchamber.opencodeCli.field.showUpdateNotificationsAria')}
|
||||
<span className="font-mono">OPENCODE_BINARY</span>
|
||||
{' '}
|
||||
{t('settings.openchamber.opencodeCli.tipMiddle')}
|
||||
{' '}
|
||||
<span className="font-mono">~/.config/openchamber/settings.json</span>
|
||||
{'.'}
|
||||
</>
|
||||
)}
|
||||
alignEnd={false}
|
||||
controlClassName="@xl:w-[20rem]"
|
||||
>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder={t('settings.openchamber.opencodeCli.field.binaryPathPlaceholder')}
|
||||
disabled={isLoading || isSaving}
|
||||
className="h-8 min-w-0 flex-1 font-mono text-xs"
|
||||
/>
|
||||
<span className="typography-ui-label text-foreground">
|
||||
{t('settings.openchamber.opencodeCli.field.showUpdateNotifications')}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="flex justify-start py-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={handleSaveAndReload}
|
||||
disabled={isLoading || isSaving}
|
||||
className="shrink-0 !font-normal"
|
||||
onClick={handleBrowse}
|
||||
disabled={isLoading || isSaving || !isDesktopShell()}
|
||||
className={SETTINGS_ICON_BUTTON_CLASS}
|
||||
aria-label={t('settings.openchamber.opencodeCli.actions.browseAria')}
|
||||
title={t('settings.openchamber.opencodeCli.actions.browse')}
|
||||
>
|
||||
{isSaving ? t('settings.common.actions.saving') : t('settings.openchamber.opencodeCli.actions.saveAndReload')}
|
||||
<Icon name="folder" className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</SettingsFieldRow>
|
||||
|
||||
<SettingsInset className={SETTINGS_OPTION_STACK_CLASS}>
|
||||
<SettingsCheckboxRow
|
||||
settingsItem="sessions.opencode-update-notifications"
|
||||
checked={showOpenCodeUpdateNotifications}
|
||||
onChange={handleShowUpdateNotificationsChange}
|
||||
label={t('settings.openchamber.opencodeCli.field.showUpdateNotifications')}
|
||||
ariaLabel={t('settings.openchamber.opencodeCli.field.showUpdateNotificationsAria')}
|
||||
/>
|
||||
|
||||
<div className="flex justify-start py-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
onClick={handleSaveAndReload}
|
||||
disabled={isLoading || isSaving}
|
||||
className="shrink-0 !font-normal"
|
||||
>
|
||||
{isSaving ? t('settings.common.actions.saving') : t('settings.openchamber.opencodeCli.actions.saveAndReload')}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsInset>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type PasskeyStatus,
|
||||
type StoredPasskey,
|
||||
} from '@/lib/passkeys';
|
||||
import { SettingsSection, SettingsFieldRow } from '@/components/sections/shared/SettingsSection';
|
||||
import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
|
||||
import { useUIStore, type TimeFormatPreference } from '@/stores/useUIStore';
|
||||
|
||||
@@ -172,39 +173,30 @@ export const PasskeySettings: React.FC = () => {
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.passkeys.title')}</h3>
|
||||
</div>
|
||||
|
||||
<section className="px-2 pb-2 pt-0 space-y-2">
|
||||
<div className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
|
||||
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.passkeys.field.currentDevice')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 sm:w-fit">
|
||||
<Button
|
||||
type="button"
|
||||
variant={isRegistering ? 'secondary' : 'outline'}
|
||||
size="xs"
|
||||
onClick={() => void handleRegisterPasskey()}
|
||||
disabled={isLoading || isResetting}
|
||||
className="!font-normal"
|
||||
>
|
||||
{isRegistering ? t('settings.openchamber.passkeys.actions.cancelSetup') : t('settings.openchamber.passkeys.actions.add')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => void handleResetAllAuth()}
|
||||
disabled={isLoading || isRegistering || isResetting}
|
||||
className="!font-normal text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{isResetting ? t('settings.openchamber.passkeys.actions.signingOut') : t('settings.openchamber.passkeys.actions.signOutEverywhere')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsSection title={t('settings.openchamber.passkeys.title')}>
|
||||
<div className="space-y-2">
|
||||
<SettingsFieldRow label={t('settings.openchamber.passkeys.field.currentDevice')}>
|
||||
<Button
|
||||
type="button"
|
||||
variant={isRegistering ? 'secondary' : 'outline'}
|
||||
size="xs"
|
||||
onClick={() => void handleRegisterPasskey()}
|
||||
disabled={isLoading || isResetting}
|
||||
className="!font-normal"
|
||||
>
|
||||
{isRegistering ? t('settings.openchamber.passkeys.actions.cancelSetup') : t('settings.openchamber.passkeys.actions.add')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => void handleResetAllAuth()}
|
||||
disabled={isLoading || isRegistering || isResetting}
|
||||
className="!font-normal text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{isResetting ? t('settings.openchamber.passkeys.actions.signingOut') : t('settings.openchamber.passkeys.actions.signOutEverywhere')}
|
||||
</Button>
|
||||
</SettingsFieldRow>
|
||||
|
||||
{!status.enabled && (
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
@@ -225,42 +217,42 @@ export const PasskeySettings: React.FC = () => {
|
||||
) : (
|
||||
<div className="space-y-1 pt-1">
|
||||
{passkeys.map((passkey) => (
|
||||
<div key={passkey.id} className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
|
||||
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
|
||||
<span className="typography-ui-label text-foreground truncate">{passkey.label}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 items-center justify-between gap-3">
|
||||
<span className="typography-meta text-muted-foreground truncate">
|
||||
{passkey.lastUsedAt
|
||||
? t('settings.openchamber.passkeys.item.lastUsed', {
|
||||
time: formatTimestamp(passkey.lastUsedAt, t('settings.openchamber.passkeys.time.neverUsed'), timeFormatPreference),
|
||||
})
|
||||
: t('settings.openchamber.passkeys.item.added', {
|
||||
time: formatTimestamp(passkey.createdAt, t('settings.openchamber.passkeys.time.neverUsed'), timeFormatPreference),
|
||||
})}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => void handleRevokePasskey(passkey.id)}
|
||||
disabled={revokingId === passkey.id}
|
||||
className="!font-normal text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{revokingId === passkey.id ? t('settings.openchamber.passkeys.actions.removing') : t('settings.common.actions.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsFieldRow
|
||||
key={passkey.id}
|
||||
label={<span className="truncate">{passkey.label}</span>}
|
||||
alignEnd={false}
|
||||
controlClassName="justify-between sm:flex-1"
|
||||
>
|
||||
<span className="typography-meta text-muted-foreground truncate">
|
||||
{passkey.lastUsedAt
|
||||
? t('settings.openchamber.passkeys.item.lastUsed', {
|
||||
time: formatTimestamp(passkey.lastUsedAt, t('settings.openchamber.passkeys.time.neverUsed'), timeFormatPreference),
|
||||
})
|
||||
: t('settings.openchamber.passkeys.item.added', {
|
||||
time: formatTimestamp(passkey.createdAt, t('settings.openchamber.passkeys.time.neverUsed'), timeFormatPreference),
|
||||
})}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => void handleRevokePasskey(passkey.id)}
|
||||
disabled={revokingId === passkey.id}
|
||||
className="!font-normal text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{revokingId === passkey.id ? t('settings.openchamber.passkeys.actions.removing') : t('settings.common.actions.delete')}
|
||||
</Button>
|
||||
</SettingsFieldRow>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{errorMessage && (
|
||||
<div className="mt-1 px-2 py-1.5">
|
||||
<div className="mt-1 py-1.5">
|
||||
<p className="typography-meta text-[var(--status-error)]">{errorMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,21 +1,27 @@
|
||||
import React from 'react';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { toast } from '@/components/ui';
|
||||
import { NumberInput } from '@/components/ui/number-input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsFieldRow,
|
||||
SettingsCheckboxRow,
|
||||
SettingsChipGroup,
|
||||
SettingsInset,
|
||||
SETTINGS_ICON_BUTTON_CLASS,
|
||||
} from '@/components/sections/shared/SettingsSection';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useSessionAutoCleanup } from '@/hooks/useSessionAutoCleanup';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useI18n, type I18nKey } from '@/lib/i18n';
|
||||
|
||||
const MIN_DAYS = 1;
|
||||
const MAX_DAYS = 365;
|
||||
const DEFAULT_RETENTION_DAYS = 30;
|
||||
const RETENTION_ACTION_OPTIONS = [
|
||||
const RETENTION_ACTION_OPTIONS: Array<{ value: 'archive' | 'delete'; labelKey: I18nKey }> = [
|
||||
{ value: 'archive', labelKey: 'settings.openchamber.sessionRetention.action.archive' },
|
||||
{ value: 'delete', labelKey: 'settings.openchamber.sessionRetention.action.delete' },
|
||||
] as const;
|
||||
];
|
||||
|
||||
export const SessionRetentionSettings: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
@@ -57,121 +63,83 @@ export const SessionRetentionSettings: React.FC = () => {
|
||||
}, [runCleanup, t]);
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
{t('settings.openchamber.sessionRetention.title')}
|
||||
</h3>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
{t('settings.openchamber.sessionRetention.tooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsSection
|
||||
title={t('settings.openchamber.sessionRetention.title')}
|
||||
info={t('settings.openchamber.sessionRetention.tooltip')}
|
||||
>
|
||||
<SettingsCheckboxRow
|
||||
settingsItem="sessions.auto-cleanup"
|
||||
checked={autoDeleteEnabled}
|
||||
onChange={setAutoDeleteEnabled}
|
||||
label={t('settings.openchamber.sessionRetention.field.enableAutoCleanup')}
|
||||
ariaLabel={t('settings.openchamber.sessionRetention.field.enableAutoCleanupAria')}
|
||||
/>
|
||||
|
||||
<section className="px-2 pb-2 pt-0 space-y-0.5">
|
||||
<div
|
||||
data-settings-item="sessions.auto-cleanup"
|
||||
className="group flex cursor-pointer items-center gap-2 py-1.5"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={autoDeleteEnabled}
|
||||
onClick={() => setAutoDeleteEnabled(!autoDeleteEnabled)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
setAutoDeleteEnabled(!autoDeleteEnabled);
|
||||
}
|
||||
}}
|
||||
<SettingsInset className="space-y-0">
|
||||
<SettingsFieldRow
|
||||
settingsItem="sessions.retention-period"
|
||||
label={t('settings.openchamber.sessionRetention.field.retentionPeriod')}
|
||||
>
|
||||
<Checkbox
|
||||
checked={autoDeleteEnabled}
|
||||
onChange={setAutoDeleteEnabled}
|
||||
ariaLabel={t('settings.openchamber.sessionRetention.field.enableAutoCleanupAria')}
|
||||
<NumberInput
|
||||
value={autoDeleteAfterDays}
|
||||
onValueChange={setAutoDeleteAfterDays}
|
||||
min={MIN_DAYS}
|
||||
max={MAX_DAYS}
|
||||
step={1}
|
||||
aria-label={t('settings.openchamber.sessionRetention.field.retentionPeriodAria')}
|
||||
className="w-20 tabular-nums"
|
||||
/>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.sessionRetention.field.enableAutoCleanup')}</span>
|
||||
</div>
|
||||
<span className="typography-ui-label text-muted-foreground">{t('settings.openchamber.sessionRetention.field.days')}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setAutoDeleteAfterDays(DEFAULT_RETENTION_DAYS)}
|
||||
disabled={autoDeleteAfterDays === DEFAULT_RETENTION_DAYS}
|
||||
className={SETTINGS_ICON_BUTTON_CLASS}
|
||||
aria-label={t('settings.openchamber.sessionRetention.actions.resetRetentionAria')}
|
||||
title={t('settings.common.actions.reset')}
|
||||
>
|
||||
<Icon name="restart" className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</SettingsFieldRow>
|
||||
|
||||
<div data-settings-item="sessions.retention-period" className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
|
||||
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.sessionRetention.field.retentionPeriod')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 sm:w-fit">
|
||||
<NumberInput
|
||||
value={autoDeleteAfterDays}
|
||||
onValueChange={setAutoDeleteAfterDays}
|
||||
min={MIN_DAYS}
|
||||
max={MAX_DAYS}
|
||||
step={1}
|
||||
aria-label={t('settings.openchamber.sessionRetention.field.retentionPeriodAria')}
|
||||
className="w-20 tabular-nums"
|
||||
/>
|
||||
<span className="typography-ui-label text-muted-foreground">{t('settings.openchamber.sessionRetention.field.days')}</span>
|
||||
<Button size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setAutoDeleteAfterDays(DEFAULT_RETENTION_DAYS)}
|
||||
disabled={autoDeleteAfterDays === DEFAULT_RETENTION_DAYS}
|
||||
className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label={t('settings.openchamber.sessionRetention.actions.resetRetentionAria')}
|
||||
title={t('settings.common.actions.reset')}
|
||||
>
|
||||
<Icon name="restart" className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsFieldRow
|
||||
settingsItem="sessions.retention-action"
|
||||
label={t('settings.openchamber.sessionRetention.field.whenSessionsExpire')}
|
||||
>
|
||||
<SettingsChipGroup
|
||||
value={sessionRetentionAction}
|
||||
onChange={setSessionRetentionAction}
|
||||
options={RETENTION_ACTION_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: t(option.labelKey),
|
||||
}))}
|
||||
/>
|
||||
</SettingsFieldRow>
|
||||
</SettingsInset>
|
||||
|
||||
<div data-settings-item="sessions.retention-action" className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
|
||||
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.sessionRetention.field.whenSessionsExpire')}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1 sm:w-fit">
|
||||
{RETENTION_ACTION_OPTIONS.map((option) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
type="button"
|
||||
variant="chip"
|
||||
size="xs"
|
||||
aria-pressed={sessionRetentionAction === option.value}
|
||||
className="!font-normal"
|
||||
onClick={() => setSessionRetentionAction(option.value)}
|
||||
>
|
||||
{t(option.labelKey)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mt-1 px-2 py-1.5 space-y-1">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-8">
|
||||
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
|
||||
<p className="typography-meta text-foreground font-medium">{t('settings.openchamber.sessionRetention.manualCleanup.title')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 sm:w-fit">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={handleRunCleanup}
|
||||
disabled={isRunning}
|
||||
className="!font-normal"
|
||||
>
|
||||
{isRunning ? t('settings.openchamber.sessionRetention.actions.cleaningUp') : t('settings.openchamber.sessionRetention.actions.runCleanupNow')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 py-1.5 space-y-1">
|
||||
<SettingsFieldRow
|
||||
label={t('settings.openchamber.sessionRetention.manualCleanup.title')}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={handleRunCleanup}
|
||||
disabled={isRunning}
|
||||
className="!font-normal"
|
||||
>
|
||||
{isRunning ? t('settings.openchamber.sessionRetention.actions.cleaningUp') : t('settings.openchamber.sessionRetention.actions.runCleanupNow')}
|
||||
</Button>
|
||||
</SettingsFieldRow>
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
{action === 'archive'
|
||||
? t('settings.openchamber.sessionRetention.manualCleanup.eligibleArchiveNow', { count: pendingCount })
|
||||
: t('settings.openchamber.sessionRetention.manualCleanup.eligibleDeleteNow', { count: pendingCount })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,6 +16,8 @@ import { openExternalUrl } from '@/lib/url';
|
||||
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
|
||||
import { formatTimeForPreference } from '@/lib/timeFormat';
|
||||
import { useUIStore, type TimeFormatPreference } from '@/stores/useUIStore';
|
||||
import { SettingsSection, SettingsGroupTitle, SETTINGS_SELECT_SIZE, SETTINGS_FIELD_LABEL_CLASS, SETTINGS_CALLOUT_TITLE_CLASS } from '@/components/sections/shared/SettingsSection';
|
||||
import { SettingsInfoHint } from '@/components/sections/shared/SettingsInfoHint';
|
||||
|
||||
type TunnelState =
|
||||
| 'checking'
|
||||
@@ -1236,26 +1238,24 @@ export const TunnelSettings: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="typography-ui-header font-semibold text-foreground">{t('settings.openchamber.tunnel.title')}</h3>
|
||||
<p className="typography-meta mt-0 text-muted-foreground/70">
|
||||
{t('settings.openchamber.tunnel.description')}
|
||||
</p>
|
||||
<p className="typography-meta mt-0 text-muted-foreground/60">
|
||||
{t('settings.openchamber.tunnel.note.serverSideEnforced')}
|
||||
</p>
|
||||
<p className="typography-meta mt-0 text-muted-foreground/60">
|
||||
{t('settings.openchamber.tunnel.note.connectLinksOneTime')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SettingsSection
|
||||
title={t('settings.openchamber.tunnel.title')}
|
||||
info={(
|
||||
<div className="space-y-1">
|
||||
<p>{t('settings.openchamber.tunnel.description')}</p>
|
||||
<p>{t('settings.openchamber.tunnel.note.serverSideEnforced')}</p>
|
||||
<p>{t('settings.openchamber.tunnel.note.connectLinksOneTime')}</p>
|
||||
</div>
|
||||
)}
|
||||
divider={false}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{renderedSessionRecords.length > 0 && (
|
||||
<section className="space-y-2 px-2 pb-2 pt-0">
|
||||
<div className="rounded-lg border border-[var(--status-info-border)] bg-[var(--status-info-background)]/30 p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Icon name="information" className="size-4 text-[var(--status-info)]" />
|
||||
<p className="typography-ui-label text-foreground">{t('settings.openchamber.tunnel.section.redeemedAccessLinks')}</p>
|
||||
<p className={SETTINGS_CALLOUT_TITLE_CLASS}>{t('settings.openchamber.tunnel.section.redeemedAccessLinks')}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{renderedSessionRecords.map((record) => {
|
||||
@@ -1307,7 +1307,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
<div className="flex items-start gap-2 rounded-lg border border-[var(--status-warning)]/30 bg-[var(--status-warning)]/5 p-3">
|
||||
<Icon name="error-warning" className="mt-0.5 size-4 shrink-0 text-[var(--status-warning)]" />
|
||||
<div className="space-y-1">
|
||||
<p className="typography-meta font-medium text-foreground">
|
||||
<p className={SETTINGS_CALLOUT_TITLE_CLASS}>
|
||||
{t('settings.openchamber.tunnel.notAvailable.dependencyNotFound', { dependency: displayedDependencyInstallInfo.dependency })}
|
||||
</p>
|
||||
<p className="typography-meta text-muted-foreground/70">{t('settings.openchamber.tunnel.notAvailable.installHint')}</p>
|
||||
@@ -1323,7 +1323,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
<section className="space-y-4 px-2 pb-2 pt-0">
|
||||
<div className="space-y-3">
|
||||
<div data-settings-item="tunnel.provider" className="space-y-1.5">
|
||||
<p className="typography-ui-label text-foreground">{t('settings.openchamber.tunnel.field.provider')}</p>
|
||||
<p className={SETTINGS_FIELD_LABEL_CLASS}>{t('settings.openchamber.tunnel.field.provider')}</p>
|
||||
<Select
|
||||
value={tunnelProvider}
|
||||
onValueChange={(value) => {
|
||||
@@ -1331,7 +1331,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
}}
|
||||
disabled={isSavingMode || state === 'starting' || state === 'stopping'}
|
||||
>
|
||||
<SelectTrigger className="max-w-[16rem]">
|
||||
<SelectTrigger size={SETTINGS_SELECT_SIZE} className="max-w-[16rem]">
|
||||
<SelectValue placeholder={t('settings.openchamber.tunnel.field.providerPlaceholder')}>
|
||||
{getProviderLabel(tunnelProvider)}
|
||||
</SelectValue>
|
||||
@@ -1354,7 +1354,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div data-settings-item="tunnel.type" className="space-y-1.5">
|
||||
<p className="typography-ui-label text-foreground">{t('settings.openchamber.tunnel.field.tunnelType')}</p>
|
||||
<p className={SETTINGS_FIELD_LABEL_CLASS}>{t('settings.openchamber.tunnel.field.tunnelType')}</p>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{tunnelModeOptions.map((option) => (
|
||||
<Tooltip key={option.value}>
|
||||
@@ -1383,7 +1383,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
|
||||
<div data-settings-item="tunnel.ttl" className="mt-2 grid grid-cols-1 gap-2 py-1.5 md:grid-cols-[14rem_auto] md:gap-x-8 md:gap-y-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="typography-ui-label shrink-0 text-foreground">{t('settings.openchamber.tunnel.field.connectLinkTtl')}</span>
|
||||
<span className={cn(SETTINGS_FIELD_LABEL_CLASS, 'shrink-0')}>{t('settings.openchamber.tunnel.field.connectLinkTtl')}</span>
|
||||
<Select
|
||||
value={ttlOptionValue(BOOTSTRAP_TTL_OPTIONS, bootstrapTtlMs, '1800000')}
|
||||
onValueChange={(value) => {
|
||||
@@ -1391,7 +1391,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
}}
|
||||
disabled={isSavingTtl || isSavingMode || state === 'starting' || state === 'stopping'}
|
||||
>
|
||||
<SelectTrigger className="max-w-[11rem] min-w-0">
|
||||
<SelectTrigger size={SETTINGS_SELECT_SIZE} className="max-w-[11rem] min-w-0">
|
||||
<SelectValue className="truncate">
|
||||
{ttlOptionLabel(BOOTSTRAP_TTL_OPTIONS, bootstrapTtlMs, '1800000')}
|
||||
</SelectValue>
|
||||
@@ -1405,7 +1405,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="typography-ui-label shrink-0 text-foreground">{t('settings.openchamber.tunnel.field.tunnelSessionTtl')}</span>
|
||||
<span className={cn(SETTINGS_FIELD_LABEL_CLASS, 'shrink-0')}>{t('settings.openchamber.tunnel.field.tunnelSessionTtl')}</span>
|
||||
<Select
|
||||
value={ttlOptionValue(SESSION_TTL_OPTIONS, sessionTtlMs, '28800000')}
|
||||
onValueChange={(value) => {
|
||||
@@ -1413,7 +1413,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
}}
|
||||
disabled={isSavingTtl || isSavingMode || state === 'starting' || state === 'stopping'}
|
||||
>
|
||||
<SelectTrigger className="max-w-[11rem] min-w-0">
|
||||
<SelectTrigger size={SETTINGS_SELECT_SIZE} className="max-w-[11rem] min-w-0">
|
||||
<SelectValue className="truncate">
|
||||
{ttlOptionLabel(SESSION_TTL_OPTIONS, sessionTtlMs, '28800000')}
|
||||
</SelectValue>
|
||||
@@ -1456,7 +1456,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
)}
|
||||
|
||||
<div className="mb-1 flex items-center justify-between gap-3">
|
||||
<p className="typography-ui-label text-foreground">{t('settings.openchamber.tunnel.section.savedManagedRemoteTunnels')}</p>
|
||||
<SettingsGroupTitle>{t('settings.openchamber.tunnel.section.savedManagedRemoteTunnels')}</SettingsGroupTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
@@ -1634,20 +1634,9 @@ export const TunnelSettings: React.FC = () => {
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="typography-meta text-muted-foreground/80">{t('settings.openchamber.tunnel.note.tokensSavedPerTunnel')}</p>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-0.5 text-muted-foreground/70 hover:text-foreground"
|
||||
aria-label={t('settings.openchamber.tunnel.field.managedRemoteTokenInfoAria')}
|
||||
>
|
||||
<Icon name="information" className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
{t('settings.openchamber.tunnel.tooltip.tokensSavedPath')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<SettingsInfoHint>
|
||||
{t('settings.openchamber.tunnel.tooltip.tokensSavedPath')}
|
||||
</SettingsInfoHint>
|
||||
</div>
|
||||
|
||||
{!selectedPreset && managedRemoteValidationError && (
|
||||
@@ -1659,7 +1648,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
{tunnelMode === 'managed-local' && (
|
||||
<div data-settings-item="tunnel.managed-local-config" className="space-y-2 rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)] p-3">
|
||||
<div className="space-y-1.5">
|
||||
<p className="typography-ui-label text-foreground">{t('settings.openchamber.tunnel.field.configurationFile')}</p>
|
||||
<p className={SETTINGS_FIELD_LABEL_CLASS}>{t('settings.openchamber.tunnel.field.configurationFile')}</p>
|
||||
<input
|
||||
ref={managedLocalConfigFileInputRef}
|
||||
type="file"
|
||||
@@ -1772,7 +1761,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
|
||||
{tunnelMode === 'managed-remote' && (
|
||||
<div className="space-y-1.5">
|
||||
<p className="typography-ui-label text-foreground">{t('settings.openchamber.tunnel.field.managedRemoteTunnelToConnect')}</p>
|
||||
<p className={SETTINGS_FIELD_LABEL_CLASS}>{t('settings.openchamber.tunnel.field.managedRemoteTunnelToConnect')}</p>
|
||||
<Select
|
||||
value={selectedPresetId || (managedRemoteTunnelPresets[0]?.id ?? '')}
|
||||
onValueChange={(presetId) => {
|
||||
@@ -1785,7 +1774,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
|| managedRemoteTunnelPresets.length <= 1
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger size={SETTINGS_SELECT_SIZE}>
|
||||
<SelectValue placeholder={t('settings.openchamber.tunnel.field.selectSavedTunnelPlaceholder')}>
|
||||
{selectedPreset?.name}
|
||||
</SelectValue>
|
||||
@@ -1909,6 +1898,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
<Button size="sm" variant="ghost" onClick={handleStart}>{t('settings.openchamber.tunnel.actions.retry')}</Button>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,12 +9,23 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Radio } from '@/components/ui/radio';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { NumberInput } from '@/components/ui/number-input';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsCheckboxRow,
|
||||
SettingsFieldRow,
|
||||
SettingsControlGroup,
|
||||
SettingsChipGroup,
|
||||
SETTINGS_SELECT_SIZE,
|
||||
SETTINGS_SELECT_ROW_TRIGGER_CLASS,
|
||||
SETTINGS_CONTROL_CLUSTER_CLASS,
|
||||
SETTINGS_FIELD_LABEL_CLASS,
|
||||
SETTINGS_HELPER_CLASS,
|
||||
} from '@/components/sections/shared/SettingsSection';
|
||||
import { SettingsInfoHint } from '@/components/sections/shared/SettingsInfoHint';
|
||||
import { browserVoiceService } from '@/lib/voice/browserVoiceService';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
@@ -153,92 +164,104 @@ const LocalModelPicker = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div role="radiogroup" aria-label={t('settings.voice.page.field.model')} className="mt-1 space-y-0">
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={t('settings.voice.page.field.model')}
|
||||
className="mt-1.5 grid w-full grid-cols-1 gap-3 @xl:grid-cols-2"
|
||||
>
|
||||
{LOCAL_STT_MODELS.map((entry) => {
|
||||
const selected = selectedModelId === entry.id;
|
||||
const state = models.get(entry.id) ?? null;
|
||||
return (
|
||||
<div key={entry.id} className="flex w-full items-start gap-2 py-1.5">
|
||||
<div className="pt-0.5">
|
||||
<Radio
|
||||
checked={selected}
|
||||
onChange={() => onSelect(entry.id)}
|
||||
ariaLabel={tUnsafe(entry.labelKey)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="min-w-0 flex-1 cursor-pointer"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onSelect(entry.id)}
|
||||
onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); onSelect(entry.id); } }}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
|
||||
<span className={cn('typography-ui-label font-normal', selected ? 'text-foreground' : 'text-foreground/50')}>
|
||||
{tUnsafe(entry.labelKey)}
|
||||
</span>
|
||||
{entry.badgeKey ? (
|
||||
<span
|
||||
className="rounded px-1 text-[9px] font-medium uppercase leading-[14px] tracking-wide"
|
||||
style={{
|
||||
backgroundColor: 'var(--status-success-background)',
|
||||
color: 'var(--status-success)',
|
||||
border: '1px solid var(--status-success-border)',
|
||||
}}
|
||||
>
|
||||
{tUnsafe(entry.badgeKey)}
|
||||
<div
|
||||
key={entry.id}
|
||||
className={cn(
|
||||
'rounded-lg border border-[var(--interactive-border)] p-3',
|
||||
selected && 'border-[var(--primary-base)] bg-[var(--primary-base)]/5',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div
|
||||
className="flex min-w-0 flex-1 cursor-pointer items-start gap-2"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onSelect(entry.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === ' ' || e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
onSelect(entry.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="pt-0.5">
|
||||
<Radio
|
||||
checked={selected}
|
||||
onChange={() => onSelect(entry.id)}
|
||||
ariaLabel={tUnsafe(entry.labelKey)}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={cn('typography-ui-label font-normal', selected ? 'text-foreground' : 'text-foreground/50')}>
|
||||
{tUnsafe(entry.labelKey)}
|
||||
</span>
|
||||
{entry.badgeKey ? (
|
||||
<span className="rounded border border-[var(--status-success-border)] bg-[var(--status-success-background)] px-1 text-[9px] font-medium uppercase leading-[14px] tracking-wide text-[var(--status-success)]">
|
||||
{tUnsafe(entry.badgeKey)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<RatingBar value={entry.accuracy} label={t('settings.voice.page.stt.meta.accuracy')} />
|
||||
<RatingBar value={entry.speed} label={t('settings.voice.page.stt.meta.speed')} />
|
||||
<span className="typography-ui-compact tabular-nums text-muted-foreground">{entry.size}</span>
|
||||
</div>
|
||||
{state?.downloadError ? (
|
||||
<p className="typography-meta text-[var(--status-error)]">{state.downloadError}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{state?.installed ? (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-6 w-6 p-0 text-muted-foreground hover:text-[var(--status-error)]"
|
||||
disabled={requestingId === entry.id}
|
||||
onClick={() => { void handleDelete(entry.id); }}
|
||||
title={t('settings.voice.page.stt.modelDelete')}
|
||||
aria-label={t('settings.voice.page.stt.modelDelete')}
|
||||
>
|
||||
<Icon name="delete-bin" className="h-4 w-4" />
|
||||
</Button>
|
||||
<Icon
|
||||
name="checkbox-circle"
|
||||
className="h-4 w-4 flex-shrink-0 text-[var(--status-success)]"
|
||||
aria-label={t('settings.voice.page.stt.modelInstalled')}
|
||||
/>
|
||||
</>
|
||||
) : state?.downloading ? (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Icon name="loader-4" className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
||||
<span className="typography-ui-compact tabular-nums text-muted-foreground">
|
||||
{typeof state.downloadProgress === 'number' ? `${state.downloadProgress}%` : ''}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-x-4 gap-y-0.5">
|
||||
<RatingBar value={entry.accuracy} label={t('settings.voice.page.stt.meta.accuracy')} />
|
||||
<RatingBar value={entry.speed} label={t('settings.voice.page.stt.meta.speed')} />
|
||||
<span className="typography-ui-compact tabular-nums text-muted-foreground">{entry.size}</span>
|
||||
</div>
|
||||
{state?.downloadError ? (
|
||||
<p className="mt-0.5 typography-meta text-[var(--status-error)]">{state.downloadError}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex w-14 flex-shrink-0 items-center justify-end gap-1 pt-0.5">
|
||||
{state?.installed ? (
|
||||
<>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-6 w-6 p-0 text-muted-foreground hover:text-[var(--status-error)]"
|
||||
className="h-6 w-6 p-0"
|
||||
disabled={requestingId === entry.id}
|
||||
onClick={() => { void handleDelete(entry.id); }}
|
||||
title={t('settings.voice.page.stt.modelDelete')}
|
||||
aria-label={t('settings.voice.page.stt.modelDelete')}
|
||||
onClick={() => { void handleDownload(entry.id); }}
|
||||
title={t('settings.voice.page.stt.modelDownload')}
|
||||
aria-label={t('settings.voice.page.stt.modelDownload')}
|
||||
>
|
||||
<Icon name="delete-bin" className="h-4 w-4" />
|
||||
<Icon name="download" className="h-4 w-4" />
|
||||
</Button>
|
||||
<Icon
|
||||
name="checkbox-circle"
|
||||
className="h-4 w-4 flex-shrink-0 text-[var(--status-success)]"
|
||||
aria-label={t('settings.voice.page.stt.modelInstalled')}
|
||||
/>
|
||||
</>
|
||||
) : state?.downloading ? (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Icon name="loader-4" className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
||||
<span className="typography-ui-compact tabular-nums text-muted-foreground">
|
||||
{typeof state.downloadProgress === 'number' ? `${state.downloadProgress}%` : ''}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-6 w-6 p-0"
|
||||
disabled={requestingId === entry.id}
|
||||
onClick={() => { void handleDownload(entry.id); }}
|
||||
title={t('settings.voice.page.stt.modelDownload')}
|
||||
aria-label={t('settings.voice.page.stt.modelDownload')}
|
||||
>
|
||||
<Icon name="download" className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -759,118 +782,73 @@ export const VoiceSettings: React.FC = () => {
|
||||
const sliderClass = "flex-1 min-w-0 h-1.5 bg-[var(--interactive-border)] rounded-full appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-[var(--primary-base)] [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-[var(--primary-base)] [&::-moz-range-thumb]:border-0 disabled:opacity-50";
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<>
|
||||
<SettingsSection
|
||||
settingsItem="voice.playback"
|
||||
title={t('settings.voice.page.section.playbackAndSummary')}
|
||||
divider={false}
|
||||
contentClassName="space-y-4"
|
||||
>
|
||||
<SettingsCheckboxRow
|
||||
checked={showMessageTTSButtons}
|
||||
onChange={setShowMessageTTSButtons}
|
||||
label={t('settings.voice.page.field.messageReadAloudButton')}
|
||||
ariaLabel={t('settings.voice.page.field.messageReadAloudButtonAria')}
|
||||
/>
|
||||
|
||||
{/* Playback (read messages aloud) */}
|
||||
<div data-settings-item="voice.playback" className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
{t('settings.voice.page.section.playbackAndSummary')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<section className="px-2 pb-2 pt-0 space-y-0">
|
||||
|
||||
<div
|
||||
className="group flex cursor-pointer items-center gap-2 py-1.5"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={showMessageTTSButtons}
|
||||
onClick={() => setShowMessageTTSButtons(!showMessageTTSButtons)}
|
||||
onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); setShowMessageTTSButtons(!showMessageTTSButtons); } }}
|
||||
>
|
||||
<Checkbox checked={showMessageTTSButtons} onChange={setShowMessageTTSButtons} ariaLabel={t('settings.voice.page.field.messageReadAloudButtonAria')} />
|
||||
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.messageReadAloudButton')}</span>
|
||||
</div>
|
||||
|
||||
{showMessageTTSButtons && (
|
||||
<>
|
||||
<div className="pb-1.5 pt-0.5">
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.provider')}</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
<ul className="space-y-1">
|
||||
<li><strong>{t('settings.voice.page.provider.browser')}</strong> {t('settings.voice.page.tooltip.browser')}</li>
|
||||
<li><strong>{t('settings.voice.page.provider.local')}</strong> {t('settings.voice.page.tooltip.localTts')}</li>
|
||||
<li><strong>OpenAI:</strong> {t('settings.voice.page.tooltip.openai')}</li>
|
||||
<li><strong>{t('settings.voice.page.provider.custom')}</strong> {t('settings.voice.page.tooltip.custom')}</li>
|
||||
<li><strong>{t('settings.voice.page.provider.say')}</strong> {t('settings.voice.page.tooltip.say')}</li>
|
||||
</ul>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<Button
|
||||
variant="chip"
|
||||
size="xs"
|
||||
aria-pressed={voiceProvider === 'browser'}
|
||||
onClick={() => setVoiceProvider('browser')}
|
||||
className="!font-normal"
|
||||
>
|
||||
{t('settings.voice.page.provider.browser')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="chip"
|
||||
size="xs"
|
||||
aria-pressed={voiceProvider === 'local'}
|
||||
onClick={() => setVoiceProvider('local')}
|
||||
className="!font-normal"
|
||||
>
|
||||
{t('settings.voice.page.provider.local')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="chip"
|
||||
size="xs"
|
||||
aria-pressed={voiceProvider === 'openai'}
|
||||
onClick={() => setVoiceProvider('openai')}
|
||||
className="!font-normal"
|
||||
>
|
||||
OpenAI
|
||||
</Button>
|
||||
<Button
|
||||
variant="chip"
|
||||
size="xs"
|
||||
aria-pressed={voiceProvider === 'openai-compatible'}
|
||||
onClick={() => setVoiceProvider('openai-compatible')}
|
||||
className="!font-normal"
|
||||
>
|
||||
{t('settings.voice.page.provider.custom')}
|
||||
</Button>
|
||||
{isSayAvailable && (
|
||||
<Button
|
||||
variant="chip"
|
||||
size="xs"
|
||||
aria-pressed={voiceProvider === 'say'}
|
||||
onClick={() => setVoiceProvider('say')}
|
||||
className="!font-normal"
|
||||
>
|
||||
<Icon name="apple" className="w-3.5 h-3.5 mr-0.5" />
|
||||
{t('settings.voice.page.provider.say')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{showMessageTTSButtons && (
|
||||
<>
|
||||
<SettingsControlGroup
|
||||
title={t('settings.voice.page.field.provider')}
|
||||
info={(
|
||||
<ul className="space-y-1">
|
||||
<li><strong>{t('settings.voice.page.provider.browser')}</strong> {t('settings.voice.page.tooltip.browser')}</li>
|
||||
<li><strong>{t('settings.voice.page.provider.local')}</strong> {t('settings.voice.page.tooltip.localTts')}</li>
|
||||
<li><strong>{t('settings.voice.page.provider.openai')}</strong> {t('settings.voice.page.tooltip.openai')}</li>
|
||||
<li><strong>{t('settings.voice.page.provider.custom')}</strong> {t('settings.voice.page.tooltip.custom')}</li>
|
||||
<li><strong>{t('settings.voice.page.provider.say')}</strong> {t('settings.voice.page.tooltip.say')}</li>
|
||||
</ul>
|
||||
)}
|
||||
>
|
||||
<SettingsChipGroup
|
||||
value={voiceProvider}
|
||||
onChange={setVoiceProvider}
|
||||
aria-label={t('settings.voice.page.field.provider')}
|
||||
className="w-full gap-1.5 sm:gap-2"
|
||||
options={[
|
||||
{ value: 'browser', label: t('settings.voice.page.provider.browser') },
|
||||
{ value: 'local', label: t('settings.voice.page.provider.local') },
|
||||
{ value: 'openai', label: t('settings.voice.page.provider.openai') },
|
||||
{ value: 'openai-compatible', label: t('settings.voice.page.provider.custom') },
|
||||
...(isSayAvailable
|
||||
? [{
|
||||
value: 'say' as const,
|
||||
label: (
|
||||
<>
|
||||
<Icon name="apple" className="mr-0.5 h-3.5 w-3.5" />
|
||||
{t('settings.voice.page.provider.say')}
|
||||
</>
|
||||
),
|
||||
}]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
</SettingsControlGroup>
|
||||
|
||||
{/* OpenAI API Key */}
|
||||
{voiceProvider === 'openai' && (
|
||||
<div className="py-1.5">
|
||||
<span className={cn("typography-ui-label text-foreground", !isOpenAIAvailable && "text-[var(--status-error)]")}>
|
||||
<div className="space-y-1.5">
|
||||
<span className={cn(SETTINGS_FIELD_LABEL_CLASS, !isOpenAIAvailable && "text-[var(--status-error)]")}>
|
||||
{t('settings.voice.page.field.apiKey')}
|
||||
</span>
|
||||
<span className={cn("typography-meta ml-2", !isOpenAIAvailable ? "text-[var(--status-error)]/80" : "text-muted-foreground")}>
|
||||
<span className={cn(SETTINGS_HELPER_CLASS, !isOpenAIAvailable && "text-[var(--status-error)]/80")}>
|
||||
{isOpenAIAvailable && !openaiApiKey
|
||||
? t('settings.voice.page.field.apiKeyHintUsingConfig')
|
||||
: !isOpenAIAvailable
|
||||
? t('settings.voice.page.field.apiKeyHintRequired')
|
||||
: t('settings.voice.page.field.apiKeyHintProvide')}
|
||||
</span>
|
||||
<div className="relative mt-1.5 max-w-xs">
|
||||
<div className={cn('relative', SETTINGS_CONTROL_CLUSTER_CLASS)}>
|
||||
<input
|
||||
type="password"
|
||||
value={openaiApiKey}
|
||||
@@ -893,15 +871,15 @@ export const VoiceSettings: React.FC = () => {
|
||||
|
||||
{/* OpenAI-compatible custom server */}
|
||||
{voiceProvider === 'openai-compatible' && (
|
||||
<div className="py-1.5 space-y-2">
|
||||
<div>
|
||||
<span className={cn("typography-ui-label text-foreground", !openaiCompatibleUrl.trim() && "text-[var(--status-error)]")}>
|
||||
{t('settings.voice.page.field.serverUrl')}
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className={cn(SETTINGS_FIELD_LABEL_CLASS, !openaiCompatibleUrl.trim() && "text-[var(--status-error)]")}>
|
||||
{t('settings.voice.page.field.serverUrl')}
|
||||
</span>
|
||||
<SettingsInfoHint>{t('settings.voice.page.field.serverUrlHint')}</SettingsInfoHint>
|
||||
</span>
|
||||
<span className="typography-meta ml-2 text-muted-foreground">
|
||||
{t('settings.voice.page.field.serverUrlHint')}
|
||||
</span>
|
||||
<div className="relative mt-1.5 max-w-xs">
|
||||
<div className={cn('relative', SETTINGS_CONTROL_CLUSTER_CLASS)}>
|
||||
<input
|
||||
type="text"
|
||||
value={openaiCompatibleUrl}
|
||||
@@ -920,12 +898,12 @@ export const VoiceSettings: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="typography-ui-label text-foreground">API Key</span>
|
||||
<span className="typography-meta ml-2 text-muted-foreground">
|
||||
<div className="space-y-1.5">
|
||||
<span className={SETTINGS_FIELD_LABEL_CLASS}>API Key</span>
|
||||
<span className={SETTINGS_HELPER_CLASS}>
|
||||
Optional
|
||||
</span>
|
||||
<div className="relative mt-1.5 max-w-xs">
|
||||
<div className={cn('relative', SETTINGS_CONTROL_CLUSTER_CLASS)}>
|
||||
<input
|
||||
type="password"
|
||||
value={openaiCompatibleApiKey}
|
||||
@@ -944,9 +922,9 @@ export const VoiceSettings: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.model')}</span>
|
||||
<div className="relative mt-1.5 max-w-xs">
|
||||
<div className="space-y-1.5">
|
||||
<span className={SETTINGS_FIELD_LABEL_CLASS}>{t('settings.voice.page.field.model')}</span>
|
||||
<div className={cn('relative', SETTINGS_CONTROL_CLUSTER_CLASS)}>
|
||||
<input
|
||||
type="text"
|
||||
value={openaiCompatibleTtsModel}
|
||||
@@ -956,13 +934,13 @@ export const VoiceSettings: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.voice')}</span>
|
||||
<span className="typography-meta ml-2 text-muted-foreground">
|
||||
{t('settings.voice.page.field.voiceIdentifierHint')}
|
||||
<div className="space-y-1.5">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className={SETTINGS_FIELD_LABEL_CLASS}>{t('settings.voice.page.field.voice')}</span>
|
||||
<SettingsInfoHint>{t('settings.voice.page.field.voiceIdentifierHint')}</SettingsInfoHint>
|
||||
</span>
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
<div className="relative max-w-xs flex-1">
|
||||
<div className={cn('flex items-center gap-2', SETTINGS_CONTROL_CLUSTER_CLASS)}>
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={openaiCompatibleVoice}
|
||||
@@ -983,16 +961,14 @@ export const VoiceSettings: React.FC = () => {
|
||||
{voiceProvider === 'local' && <LocalTtsModelStatus />}
|
||||
|
||||
{/* Voice Selection */}
|
||||
<div className="flex items-center gap-8 py-1.5">
|
||||
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">{t('settings.voice.page.field.voice')}</span>
|
||||
<div className="flex items-center gap-2 w-fit">
|
||||
<SettingsFieldRow label={t('settings.voice.page.field.voice')}>
|
||||
{voiceProvider === 'local' && (
|
||||
<>
|
||||
<Select
|
||||
value={String(localTtsVoiceId)}
|
||||
onValueChange={(value) => setLocalTtsVoiceId(Number.parseInt(value, 10) || 0)}
|
||||
>
|
||||
<SelectTrigger className="w-fit">
|
||||
<SelectTrigger size={SETTINGS_SELECT_SIZE} className={SETTINGS_SELECT_ROW_TRIGGER_CLASS}>
|
||||
<SelectValue placeholder={t('settings.voice.page.field.selectVoicePlaceholder')}>
|
||||
{(value) => KOKORO_VOICE_OPTIONS.find((v) => String(v.id) === value)?.label ?? value}
|
||||
</SelectValue>
|
||||
@@ -1015,7 +991,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
{voiceProvider === 'openai' && isOpenAIAvailable && (
|
||||
<>
|
||||
<Select value={openaiVoice} onValueChange={setOpenaiVoice}>
|
||||
<SelectTrigger className="w-fit">
|
||||
<SelectTrigger size={SETTINGS_SELECT_SIZE} className={SETTINGS_SELECT_ROW_TRIGGER_CLASS}>
|
||||
<SelectValue placeholder={t('settings.voice.page.field.selectVoicePlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -1031,13 +1007,13 @@ export const VoiceSettings: React.FC = () => {
|
||||
)}
|
||||
|
||||
{voiceProvider === 'openai-compatible' && (
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.voice.page.field.configuredAbove')}</span>
|
||||
<span className={SETTINGS_HELPER_CLASS}>{t('settings.voice.page.field.configuredAbove')}</span>
|
||||
)}
|
||||
|
||||
{voiceProvider === 'say' && isSayAvailable && sayVoices.length > 0 && (
|
||||
<>
|
||||
<Select value={sayVoice} onValueChange={setSayVoice}>
|
||||
<SelectTrigger className="w-fit">
|
||||
<SelectTrigger size={SETTINGS_SELECT_SIZE} className={SETTINGS_SELECT_ROW_TRIGGER_CLASS}>
|
||||
<SelectValue placeholder={t('settings.voice.page.field.selectVoicePlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -1055,7 +1031,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
{voiceProvider === 'browser' && filteredBrowserVoices.length > 0 && (
|
||||
<>
|
||||
<Select value={browserVoice || '$auto'} onValueChange={(value) => setBrowserVoice(value === '$auto' ? '' : value)}>
|
||||
<SelectTrigger className="w-fit max-w-[200px]">
|
||||
<SelectTrigger size={SETTINGS_SELECT_SIZE} className={SETTINGS_SELECT_ROW_TRIGGER_CLASS}>
|
||||
<SelectValue placeholder={t('settings.voice.page.field.auto')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -1070,31 +1046,22 @@ export const VoiceSettings: React.FC = () => {
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SettingsFieldRow>
|
||||
|
||||
{/* Speech Rate */}
|
||||
<div className="flex items-center gap-8 py-1.5">
|
||||
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">{t('settings.voice.page.field.speechRate')}</span>
|
||||
<div className="flex items-center gap-2 w-fit">
|
||||
<SettingsFieldRow label={t('settings.voice.page.field.speechRate')}>
|
||||
{!isMobile && <input type="range" min={0.5} max={2} step={0.1} value={speechRate} onChange={(e) => setSpeechRate(Number(e.target.value))} className={sliderClass} />}
|
||||
<NumberInput value={speechRate} onValueChange={setSpeechRate} min={0.5} max={2} step={0.1} className="w-16 tabular-nums" />
|
||||
</div>
|
||||
</div>
|
||||
</SettingsFieldRow>
|
||||
|
||||
{/* Speech Pitch */}
|
||||
<div className="flex items-center gap-8 py-1.5">
|
||||
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">{t('settings.voice.page.field.speechPitch')}</span>
|
||||
<div className="flex items-center gap-2 w-fit">
|
||||
<SettingsFieldRow label={t('settings.voice.page.field.speechPitch')}>
|
||||
{!isMobile && <input type="range" min={0.5} max={2} step={0.1} value={speechPitch} onChange={(e) => setSpeechPitch(Number(e.target.value))} className={sliderClass} />}
|
||||
<NumberInput value={speechPitch} onValueChange={setSpeechPitch} min={0.5} max={2} step={0.1} className="w-16 tabular-nums" />
|
||||
</div>
|
||||
</div>
|
||||
</SettingsFieldRow>
|
||||
|
||||
{/* Speech Volume */}
|
||||
<div className="flex items-center gap-8 py-1.5">
|
||||
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">{t('settings.voice.page.field.speechVolume')}</span>
|
||||
<div className="flex items-center gap-2 w-fit">
|
||||
<SettingsFieldRow label={t('settings.voice.page.field.speechVolume')}>
|
||||
{!isMobile && <input type="range" min={0} max={1} step={0.1} value={speechVolume} onChange={(e) => setSpeechVolume(Number(e.target.value))} className={sliderClass} />}
|
||||
{isMobile ? (
|
||||
<NumberInput value={Math.round(speechVolume * 100)} onValueChange={(v) => setSpeechVolume(v / 100)} min={0} max={100} step={10} className="w-16 tabular-nums" />
|
||||
@@ -1103,131 +1070,77 @@ export const VoiceSettings: React.FC = () => {
|
||||
{Math.round(speechVolume * 100)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SettingsFieldRow>
|
||||
|
||||
{/* TTS input mode */}
|
||||
<div className="pb-1.5 pt-0.5">
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="typography-ui-label text-foreground">
|
||||
{t('settings.voice.page.field.ttsInputMode')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<Button
|
||||
variant="chip"
|
||||
size="xs"
|
||||
aria-pressed={ttsInputMode === 'sanitized'}
|
||||
onClick={() => setTtsInputMode('sanitized')}
|
||||
className="!font-normal"
|
||||
>
|
||||
{t('settings.voice.page.field.ttsInputModeSanitized')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="chip"
|
||||
size="xs"
|
||||
aria-pressed={ttsInputMode === 'raw'}
|
||||
onClick={() => setTtsInputMode('raw')}
|
||||
className="!font-normal"
|
||||
>
|
||||
{t('settings.voice.page.field.ttsInputModeRaw')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="chip"
|
||||
size="xs"
|
||||
aria-pressed={ttsInputMode === 'summarized'}
|
||||
onClick={() => setTtsInputMode('summarized')}
|
||||
className="!font-normal"
|
||||
>
|
||||
{t('settings.voice.page.field.ttsInputModeSummarized')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
<SettingsControlGroup title={t('settings.voice.page.field.ttsInputMode')}>
|
||||
<SettingsChipGroup
|
||||
value={ttsInputMode}
|
||||
onChange={setTtsInputMode}
|
||||
aria-label={t('settings.voice.page.field.ttsInputMode')}
|
||||
className="w-full gap-1.5 sm:gap-2"
|
||||
options={[
|
||||
{ value: 'sanitized', label: t('settings.voice.page.field.ttsInputModeSanitized') },
|
||||
{ value: 'raw', label: t('settings.voice.page.field.ttsInputModeRaw') },
|
||||
{ value: 'summarized', label: t('settings.voice.page.field.ttsInputModeSummarized') },
|
||||
]}
|
||||
/>
|
||||
</SettingsControlGroup>
|
||||
</>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
{/* Speech Recognition */}
|
||||
<div data-settings-item="voice.speech-recognition" className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
{t('settings.voice.page.section.speechRecognition')}
|
||||
</h3>
|
||||
</div>
|
||||
<SettingsSection
|
||||
settingsItem="voice.speech-recognition"
|
||||
title={t('settings.voice.page.section.speechRecognition')}
|
||||
contentClassName="space-y-4"
|
||||
>
|
||||
<SettingsCheckboxRow
|
||||
checked={dictationEnabled}
|
||||
onChange={setDictationEnabled}
|
||||
label={t('settings.voice.page.field.enableVoiceInput')}
|
||||
ariaLabel={t('settings.voice.page.field.enableVoiceInputAria')}
|
||||
/>
|
||||
|
||||
<section className="px-2 pb-2 pt-0 space-y-0">
|
||||
<div
|
||||
className="group flex cursor-pointer items-center gap-2 py-1.5"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={dictationEnabled}
|
||||
onClick={() => setDictationEnabled(!dictationEnabled)}
|
||||
onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); setDictationEnabled(!dictationEnabled); } }}
|
||||
{dictationEnabled && (
|
||||
<>
|
||||
<SettingsControlGroup
|
||||
title={t('settings.voice.page.field.provider')}
|
||||
info={(
|
||||
<ul className="space-y-1">
|
||||
<li><strong>{t('settings.voice.page.provider.local')}</strong> {t('settings.voice.page.tooltip.sttLocal')}</li>
|
||||
<li><strong>{t('settings.voice.page.provider.server')}</strong> {t('settings.voice.page.tooltip.sttServer')}</li>
|
||||
</ul>
|
||||
)}
|
||||
>
|
||||
<Checkbox checked={dictationEnabled} onChange={setDictationEnabled} ariaLabel={t('settings.voice.page.field.enableVoiceInputAria')} />
|
||||
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.enableVoiceInput')}</span>
|
||||
</div>
|
||||
|
||||
{dictationEnabled && (<>
|
||||
<div className="pb-1.5 pt-0.5">
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.provider')}</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
<ul className="space-y-1">
|
||||
<li><strong>{t('settings.voice.page.provider.local')}</strong> {t('settings.voice.page.tooltip.sttLocal')}</li>
|
||||
<li><strong>{t('settings.voice.page.provider.server')}</strong> {t('settings.voice.page.tooltip.sttServer')}</li>
|
||||
</ul>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<Button
|
||||
variant="chip"
|
||||
size="xs"
|
||||
aria-pressed={sttProvider === 'local'}
|
||||
onClick={() => setSttProvider('local')}
|
||||
className="!font-normal"
|
||||
>
|
||||
{t('settings.voice.page.provider.local')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="chip"
|
||||
size="xs"
|
||||
aria-pressed={sttProvider === 'openai-compatible'}
|
||||
onClick={() => setSttProvider('openai-compatible')}
|
||||
className="!font-normal"
|
||||
>
|
||||
{t('settings.voice.page.provider.server')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsChipGroup
|
||||
value={sttProvider}
|
||||
onChange={setSttProvider}
|
||||
aria-label={t('settings.voice.page.field.provider')}
|
||||
className="w-full gap-1.5 sm:gap-2"
|
||||
options={[
|
||||
{ value: 'local', label: t('settings.voice.page.provider.local') },
|
||||
{ value: 'openai-compatible', label: t('settings.voice.page.provider.server') },
|
||||
]}
|
||||
/>
|
||||
</SettingsControlGroup>
|
||||
|
||||
{sttProvider === 'local' && (
|
||||
<div className="py-1.5">
|
||||
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.model')}</span>
|
||||
<div className="space-y-1.5">
|
||||
<span className={SETTINGS_FIELD_LABEL_CLASS}>{t('settings.voice.page.field.model')}</span>
|
||||
<LocalModelPicker selectedModelId={sttLocalModel} onSelect={setSttLocalModel} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sttProvider === 'openai-compatible' && (
|
||||
<div className="py-1.5 space-y-2">
|
||||
<div>
|
||||
<span className={cn("typography-ui-label text-foreground", !sttServerUrl.trim() && "text-[var(--status-error)]")}>
|
||||
{t('settings.voice.page.field.serverUrl')}
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className={cn(SETTINGS_FIELD_LABEL_CLASS, !sttServerUrl.trim() && "text-[var(--status-error)]")}>
|
||||
{t('settings.voice.page.field.serverUrl')}
|
||||
</span>
|
||||
<SettingsInfoHint>{t('settings.voice.page.field.sttServerUrlHint')}</SettingsInfoHint>
|
||||
</span>
|
||||
<span className="typography-meta ml-2 text-muted-foreground">
|
||||
{t('settings.voice.page.field.sttServerUrlHint')}
|
||||
</span>
|
||||
<div className="relative mt-1.5 max-w-xs">
|
||||
<div className={cn('relative', SETTINGS_CONTROL_CLUSTER_CLASS)}>
|
||||
<input
|
||||
type="text"
|
||||
value={sttServerUrl}
|
||||
@@ -1246,12 +1159,12 @@ export const VoiceSettings: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="typography-ui-label text-foreground">API Key</span>
|
||||
<span className="typography-meta ml-2 text-muted-foreground">
|
||||
<div className="space-y-1.5">
|
||||
<span className={SETTINGS_FIELD_LABEL_CLASS}>API Key</span>
|
||||
<span className={SETTINGS_HELPER_CLASS}>
|
||||
Optional
|
||||
</span>
|
||||
<div className="relative mt-1.5 max-w-xs">
|
||||
<div className={cn('relative', SETTINGS_CONTROL_CLUSTER_CLASS)}>
|
||||
<input
|
||||
type="password"
|
||||
value={sttApiKey}
|
||||
@@ -1270,9 +1183,9 @@ export const VoiceSettings: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.model')}</span>
|
||||
<div className="relative mt-1.5 max-w-xs">
|
||||
<div className="space-y-1.5">
|
||||
<span className={SETTINGS_FIELD_LABEL_CLASS}>{t('settings.voice.page.field.model')}</span>
|
||||
<div className={cn('relative', SETTINGS_CONTROL_CLUSTER_CLASS)}>
|
||||
<input
|
||||
type="text"
|
||||
value={sttModel}
|
||||
@@ -1282,12 +1195,12 @@ export const VoiceSettings: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.language')}</span>
|
||||
<span className="typography-meta ml-2 text-muted-foreground">
|
||||
{t('settings.voice.page.field.sttLanguageHint')}
|
||||
<div className="space-y-1.5">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className={SETTINGS_FIELD_LABEL_CLASS}>{t('settings.voice.page.field.language')}</span>
|
||||
<SettingsInfoHint>{t('settings.voice.page.field.sttLanguageHint')}</SettingsInfoHint>
|
||||
</span>
|
||||
<div className="relative mt-1.5 max-w-[8rem]">
|
||||
<div className="relative max-w-[8rem]">
|
||||
<input
|
||||
type="text"
|
||||
value={sttLanguage}
|
||||
@@ -1299,12 +1212,9 @@ export const VoiceSettings: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</>)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</SettingsSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,8 +2,8 @@ import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { toast } from '@/components/ui';
|
||||
import { SettingsInfoHint } from '@/components/sections/shared/SettingsInfoHint';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
@@ -326,29 +326,19 @@ export const WorktreeSectionContent: React.FC<WorktreeSectionContentProps> = ({
|
||||
}, [sessionsKey, isGitRepoLocal, projectPath, refreshWorktrees]);
|
||||
|
||||
const setupTooltip = (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
{t('settings.openchamber.worktrees.setup.tooltipPrefix')}
|
||||
{' '}
|
||||
<code className="font-mono text-xs bg-sidebar-accent/50 px-1 rounded">$ROOT_PROJECT_PATH</code>
|
||||
{' '}
|
||||
{t('settings.openchamber.worktrees.setup.tooltipSuffix')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<SettingsInfoHint>
|
||||
{t('settings.openchamber.worktrees.setup.tooltipPrefix')}
|
||||
{' '}
|
||||
<code className="font-mono text-xs bg-sidebar-accent/50 px-1 rounded">$ROOT_PROJECT_PATH</code>
|
||||
{' '}
|
||||
{t('settings.openchamber.worktrees.setup.tooltipSuffix')}
|
||||
</SettingsInfoHint>
|
||||
);
|
||||
|
||||
const listTooltip = (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
{t('settings.openchamber.worktrees.list.tooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<SettingsInfoHint>
|
||||
{t('settings.openchamber.worktrees.list.tooltip')}
|
||||
</SettingsInfoHint>
|
||||
);
|
||||
|
||||
if (!projectPath) {
|
||||
@@ -397,14 +387,16 @@ export const WorktreeSectionContent: React.FC<WorktreeSectionContentProps> = ({
|
||||
placeholder={t('settings.openchamber.worktrees.setup.commandPlaceholder')}
|
||||
className="h-7 min-w-0 flex-1 font-mono text-xs"
|
||||
/>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveCommand(index)}
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-destructive/10 hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
className="h-7 w-7 shrink-0 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||
aria-label={t('settings.openchamber.worktrees.setup.removeCommandAria')}
|
||||
>
|
||||
<Icon name="close" className="h-4 w-4" />
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export type OpenChamberSection =
|
||||
| 'general'
|
||||
| 'visual'
|
||||
| 'chat'
|
||||
| 'shortcuts'
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Icon } from '@/components/icon/Icon';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
|
||||
import { SettingsSection } from '@/components/sections/shared/SettingsSection';
|
||||
import { RegistryBanner } from './RegistryBanner';
|
||||
import {
|
||||
usePluginsStore,
|
||||
@@ -194,29 +195,28 @@ export const PluginsPage: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsPageLayout>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<h2 className="typography-ui-header font-semibold text-foreground truncate">
|
||||
{t('settings.plugins.page.header.entry')}
|
||||
</h2>
|
||||
<ScopeBadge
|
||||
scope={selectedEntry.scope}
|
||||
label={
|
||||
selectedEntry.scope === 'project'
|
||||
? t('settings.plugins.sidebar.group.projectEntries')
|
||||
: t('settings.plugins.sidebar.group.userEntries')
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsPageLayout
|
||||
title={t('settings.plugins.page.header.entry')}
|
||||
titleAccessory={(
|
||||
<ScopeBadge
|
||||
scope={selectedEntry.scope}
|
||||
label={
|
||||
selectedEntry.scope === 'project'
|
||||
? t('settings.plugins.sidebar.group.projectEntries')
|
||||
: t('settings.plugins.sidebar.group.userEntries')
|
||||
}
|
||||
/>
|
||||
)}
|
||||
showSaveStatus={false}
|
||||
>
|
||||
<SettingsSection divider={false}>
|
||||
<RegistryBanner entryId={selectedEntry.id} spec={selectedEntry.spec} />
|
||||
</SettingsSection>
|
||||
|
||||
<RegistryBanner entryId={selectedEntry.id} spec={selectedEntry.spec} />
|
||||
|
||||
<div data-settings-item="plugins.spec" className="space-y-1.5">
|
||||
<label className="typography-meta text-muted-foreground">
|
||||
{t('settings.plugins.page.field.spec')}
|
||||
</label>
|
||||
<SettingsSection
|
||||
title={t('settings.plugins.page.field.spec')}
|
||||
settingsItem="plugins.spec"
|
||||
>
|
||||
<Input
|
||||
value={draft.spec}
|
||||
onChange={(e) =>
|
||||
@@ -226,12 +226,12 @@ export const PluginsPage: React.FC = () => {
|
||||
className="font-mono typography-meta"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<div data-settings-item="plugins.options" className="space-y-1.5">
|
||||
<label className="typography-meta text-muted-foreground">
|
||||
{t('settings.plugins.page.field.options')}
|
||||
</label>
|
||||
<SettingsSection
|
||||
title={t('settings.plugins.page.field.options')}
|
||||
settingsItem="plugins.options"
|
||||
>
|
||||
<Textarea
|
||||
value={draft.optionsJson}
|
||||
onChange={(e) =>
|
||||
@@ -250,26 +250,25 @@ export const PluginsPage: React.FC = () => {
|
||||
{t('settings.plugins.page.field.options.invalidJson')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => void handleEntrySave()}
|
||||
disabled={!isDirty || !optionsValid || isSaving}
|
||||
>
|
||||
{t('settings.plugins.page.action.save')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleEntryDiscard}
|
||||
disabled={!isDirty || isSaving}
|
||||
>
|
||||
{t('settings.plugins.page.action.discard')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pt-3">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => void handleEntrySave()}
|
||||
disabled={!isDirty || !optionsValid || isSaving}
|
||||
>
|
||||
{t('settings.plugins.page.action.save')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleEntryDiscard}
|
||||
disabled={!isDirty || isSaving}
|
||||
>
|
||||
{t('settings.plugins.page.action.discard')}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</SettingsPageLayout>
|
||||
);
|
||||
}
|
||||
@@ -312,12 +311,10 @@ export const PluginsPage: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsPageLayout>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 min-w-0 flex-wrap">
|
||||
<h2 className="typography-ui-header font-semibold text-foreground truncate">
|
||||
{t('settings.plugins.page.header.file')}
|
||||
</h2>
|
||||
<SettingsPageLayout
|
||||
title={t('settings.plugins.page.header.file')}
|
||||
titleAccessory={(
|
||||
<>
|
||||
<ScopeBadge
|
||||
scope={selectedFile.scope}
|
||||
label={
|
||||
@@ -335,13 +332,15 @@ export const PluginsPage: React.FC = () => {
|
||||
>
|
||||
{selectedFile.fileName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-settings-item="plugins.content" className="space-y-1.5">
|
||||
<label className="typography-meta text-muted-foreground">
|
||||
{t('settings.plugins.page.field.content')}
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
showSaveStatus={false}
|
||||
>
|
||||
<SettingsSection
|
||||
title={t('settings.plugins.page.field.content')}
|
||||
divider={false}
|
||||
settingsItem="plugins.content"
|
||||
>
|
||||
<Textarea
|
||||
value={draft.content}
|
||||
onChange={(e) =>
|
||||
@@ -352,26 +351,25 @@ export const PluginsPage: React.FC = () => {
|
||||
spellCheck={false}
|
||||
disabled={isLoadingFile}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => void handleFileSave()}
|
||||
disabled={!isDirty || isSaving || isLoadingFile}
|
||||
>
|
||||
{t('settings.plugins.page.action.save')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleFileDiscard}
|
||||
disabled={isSaving || isLoadingFile}
|
||||
>
|
||||
{t('settings.plugins.page.action.discard')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pt-3">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => void handleFileSave()}
|
||||
disabled={!isDirty || isSaving || isLoadingFile}
|
||||
>
|
||||
{t('settings.plugins.page.action.save')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleFileDiscard}
|
||||
disabled={isSaving || isLoadingFile}
|
||||
>
|
||||
{t('settings.plugins.page.action.discard')}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</SettingsPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type PluginEntry,
|
||||
type PluginFile,
|
||||
} from '@/stores/usePluginsStore';
|
||||
import { SETTINGS_PANEL_TITLE_CLASS } from '@/components/sections/shared/SettingsSection';
|
||||
|
||||
interface PluginsSidebarProps {
|
||||
onItemSelect?: () => void;
|
||||
@@ -277,7 +278,7 @@ export const PluginsSidebar: React.FC<PluginsSidebarProps> = ({
|
||||
header={
|
||||
<div className="border-b px-3 pt-4 pb-3">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<h2 className="text-base font-semibold text-foreground">
|
||||
<h2 className={SETTINGS_PANEL_TITLE_CLASS}>
|
||||
{t('settings.plugins.sidebar.title')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { toast } from '@/components/ui';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { useDesktopSshStore } from '@/stores/useDesktopSshStore';
|
||||
@@ -41,6 +40,8 @@ import {
|
||||
PROJECT_SETTINGS_CONTROL_WIDTH,
|
||||
ProjectSettingsSubsection,
|
||||
} from '@/components/sections/projects/ProjectSettingsSubsection';
|
||||
import { SETTINGS_SELECT_SIZE } from '@/components/sections/shared/SettingsSection';
|
||||
import { SettingsInfoHint } from '@/components/sections/shared/SettingsInfoHint';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -228,7 +229,7 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
|
||||
return (
|
||||
<ProjectSettingsSubsection
|
||||
title={t('settings.projects.actions.title')}
|
||||
description={t('settings.projects.actions.description')}
|
||||
info={t('settings.projects.actions.description')}
|
||||
settingsItem="projects.actions"
|
||||
headerAction={(
|
||||
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={handleAddAction}>
|
||||
@@ -387,14 +388,9 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
|
||||
placeholder={t('settings.projects.actions.field.overrideUrlPlaceholder')}
|
||||
className="h-7 w-full max-w-[24rem]"
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Icon name="information" className="h-3.5 w-3.5 shrink-0 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
{t('settings.projects.actions.field.overrideUrlTooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<SettingsInfoHint contentClassName="max-w-xs">
|
||||
{t('settings.projects.actions.field.overrideUrlTooltip')}
|
||||
</SettingsInfoHint>
|
||||
</div>
|
||||
|
||||
{isDesktopShellApp ? (
|
||||
@@ -414,7 +410,7 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-full">
|
||||
<SelectTrigger size={SETTINGS_SELECT_SIZE} className="w-full">
|
||||
<SelectValue placeholder={t('settings.projects.actions.field.useOutputManualUrl')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
@@ -64,25 +64,26 @@ export const ProjectIdentityFields: React.FC<ProjectIdentityFieldsProps> = ({ fo
|
||||
<ProjectSettingsSubsection
|
||||
title={t('settings.projects.page.field.projectName')}
|
||||
settingsItem="projects.name"
|
||||
divider={false}
|
||||
>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder={t('settings.projects.page.field.projectNamePlaceholder')}
|
||||
className={cn('h-7', PROJECT_SETTINGS_CONTROL_WIDTH)}
|
||||
className={cn('h-8 rounded-md px-3', PROJECT_SETTINGS_CONTROL_WIDTH)}
|
||||
/>
|
||||
</ProjectSettingsSubsection>
|
||||
|
||||
<ProjectSettingsSubsection
|
||||
title={t('settings.projects.page.field.defaultModel')}
|
||||
description={t('settings.projects.page.field.defaultModelDescription')}
|
||||
info={t('settings.projects.page.field.defaultModelDescription')}
|
||||
settingsItem="projects.default-model"
|
||||
>
|
||||
<ModelSelector
|
||||
providerId={parsedDefaultModel.providerId}
|
||||
modelId={parsedDefaultModel.modelId}
|
||||
onChange={handleDefaultModelChange}
|
||||
className={PROJECT_SETTINGS_CONTROL_WIDTH}
|
||||
className={cn('h-8 min-h-8 rounded-md px-3 max-w-48', PROJECT_SETTINGS_CONTROL_WIDTH)}
|
||||
/>
|
||||
</ProjectSettingsSubsection>
|
||||
|
||||
@@ -125,6 +126,7 @@ export const ProjectIdentityFields: React.FC<ProjectIdentityFieldsProps> = ({ fo
|
||||
<ProjectSettingsSubsection
|
||||
title={t('settings.projects.page.field.projectIcon')}
|
||||
settingsItem="projects.icon"
|
||||
contentClassName="space-y-3"
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
@@ -176,7 +178,7 @@ export const ProjectIdentityFields: React.FC<ProjectIdentityFieldsProps> = ({ fo
|
||||
})}
|
||||
</div>
|
||||
{effectiveHasImageIcon && showImagePreview && (
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.projects.page.field.preview')}</span>
|
||||
<span className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-border/60 bg-[var(--surface-elevated)] p-1">
|
||||
<span
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
} from '@/components/sections/projects/useProjectIdentityForm';
|
||||
import { useProjectIdentityAutoSave } from '@/components/sections/projects/useProjectIdentityAutoSave';
|
||||
import type { ProjectEntry } from '@/lib/api/types';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
type ProjectSettingsPanelProps = {
|
||||
project: ProjectEntry | null;
|
||||
@@ -19,7 +18,6 @@ export const ProjectSettingsPanel: React.FC<ProjectSettingsPanelProps> = ({
|
||||
project,
|
||||
onIdentitySave,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const form = useProjectIdentityForm(project);
|
||||
|
||||
const projectRef = React.useMemo(() => {
|
||||
@@ -39,19 +37,8 @@ export const ProjectSettingsPanel: React.FC<ProjectSettingsPanelProps> = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const headerLabel = project.label ?? t('settings.projects.page.title.default');
|
||||
|
||||
return (
|
||||
<div className="space-y-0">
|
||||
<div className="mb-5 px-1">
|
||||
<h2 className="typography-ui-header font-semibold text-foreground truncate">
|
||||
{headerLabel}
|
||||
</h2>
|
||||
<p className="typography-meta text-muted-foreground truncate" title={project.path}>
|
||||
{project.path}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ProjectIdentityFields form={form} />
|
||||
<ProjectActionsSection projectRef={projectRef} />
|
||||
<WorktreeSectionContent projectRef={projectRef} />
|
||||
|
||||
@@ -1,47 +1,48 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SettingsSection } from '@/components/sections/shared/SettingsSection';
|
||||
|
||||
export const PROJECT_SETTINGS_CONTROL_WIDTH = 'w-full max-w-[30rem]';
|
||||
export { PROJECT_SETTINGS_CONTROL_WIDTH } from './projectSettingsConstants';
|
||||
|
||||
type ProjectSettingsSubsectionProps = {
|
||||
title: string;
|
||||
description?: string;
|
||||
/** Helper text hidden behind an info icon next to the title. */
|
||||
info?: React.ReactNode;
|
||||
settingsItem?: string;
|
||||
titleAccessory?: React.ReactNode;
|
||||
headerAction?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
/** Use false for the first subsection under a page header. @default true */
|
||||
divider?: boolean;
|
||||
};
|
||||
|
||||
export const ProjectSettingsSubsection: React.FC<ProjectSettingsSubsectionProps> = ({
|
||||
title,
|
||||
description,
|
||||
info,
|
||||
settingsItem,
|
||||
titleAccessory,
|
||||
headerAction,
|
||||
children,
|
||||
className,
|
||||
contentClassName,
|
||||
divider = true,
|
||||
}) => {
|
||||
return (
|
||||
<section
|
||||
data-settings-item={settingsItem}
|
||||
className={cn('border-b border-border/50 py-5 first:pt-0 last:border-b-0', className)}
|
||||
<SettingsSection
|
||||
title={title}
|
||||
description={description}
|
||||
info={info}
|
||||
settingsItem={settingsItem}
|
||||
titleAccessory={titleAccessory}
|
||||
headerAction={headerAction}
|
||||
divider={divider}
|
||||
className={className}
|
||||
contentClassName={contentClassName}
|
||||
>
|
||||
<div className="mb-3 flex items-start justify-between gap-3 px-1">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{title}</h3>
|
||||
{titleAccessory}
|
||||
</div>
|
||||
{description ? (
|
||||
<p className="mt-0.5 typography-meta text-muted-foreground">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{headerAction ? <div className="shrink-0">{headerAction}</div> : null}
|
||||
</div>
|
||||
<div className={cn('space-y-2 px-2', contentClassName)}>{children}</div>
|
||||
</section>
|
||||
{children}
|
||||
</SettingsSection>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { ProjectSettingsPanel } from '@/components/sections/projects/ProjectSettingsPanel';
|
||||
@@ -42,19 +42,22 @@ export const ProjectsPage: React.FC = () => {
|
||||
|
||||
if (!selectedProject) {
|
||||
return (
|
||||
<ScrollableOverlay outerClassName="h-full" className="w-full">
|
||||
<div className="mx-auto w-full max-w-4xl p-3 sm:p-6 sm:pt-8">
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.projects.page.empty.noProjects')}</p>
|
||||
</div>
|
||||
</ScrollableOverlay>
|
||||
<SettingsPageLayout title={t('settings.page.projects.title')} showSaveStatus>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.projects.page.empty.noProjects')}</p>
|
||||
</SettingsPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const headerLabel = selectedProject.label ?? t('settings.projects.page.title.default');
|
||||
|
||||
return (
|
||||
<ScrollableOverlay outerClassName="h-full" className="w-full bg-background">
|
||||
<div className="mx-auto w-full max-w-4xl p-3 sm:p-6 sm:pt-8">
|
||||
<ProjectSettingsPanel project={selectedProject} onIdentitySave={handleIdentitySave} />
|
||||
</div>
|
||||
</ScrollableOverlay>
|
||||
<SettingsPageLayout
|
||||
title={headerLabel}
|
||||
description={selectedProject.path}
|
||||
showSaveStatus
|
||||
outerClassName="bg-background"
|
||||
>
|
||||
<ProjectSettingsPanel project={selectedProject} onIdentitySave={handleIdentitySave} />
|
||||
</SettingsPageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { sessionEvents } from '@/lib/sessionEvents';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { SETTINGS_PANEL_TITLE_CLASS } from '@/components/sections/shared/SettingsSection';
|
||||
|
||||
export const ProjectsSidebar: React.FC<{ onItemSelect?: () => void }> = ({ onItemSelect }) => {
|
||||
const { t } = useI18n();
|
||||
@@ -43,7 +44,7 @@ export const ProjectsSidebar: React.FC<{ onItemSelect?: () => void }> = ({ onIte
|
||||
variant="background"
|
||||
header={
|
||||
<div className={cn('border-b px-3', 'pt-4 pb-3')}>
|
||||
<h2 className="text-base font-semibold text-foreground mb-3">{t('settings.page.projects.title')}</h2>
|
||||
<h2 className={`${SETTINGS_PANEL_TITLE_CLASS} mb-3`}>{t('settings.page.projects.title')}</h2>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.projects.sidebar.total', { count: projects.length })}</span>
|
||||
{!isVSCode && (
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { SETTINGS_CONTROL_CLUSTER_CLASS } from '@/components/sections/shared/SettingsSection';
|
||||
|
||||
// Keep project controls on the shared settings control width.
|
||||
export const PROJECT_SETTINGS_CONTROL_WIDTH = SETTINGS_CONTROL_CLUSTER_CLASS;
|
||||
@@ -1,5 +1,8 @@
|
||||
import React from 'react';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
|
||||
import { SettingsSection, SETTINGS_CUSTOM_TRIGGER_CLASS } from '@/components/sections/shared/SettingsSection';
|
||||
import { SettingsInfoHint } from '@/components/sections/shared/SettingsInfoHint';
|
||||
import { ProviderLogo } from '@/components/ui/ProviderLogo';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
@@ -12,7 +15,6 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { toast } from '@/components/ui';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import type { IconName } from "@/components/icon/icons";
|
||||
import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
|
||||
@@ -24,7 +26,6 @@ import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { shouldLoadAvailableProviders } from './providerAvailability';
|
||||
import { QuotaCredentials } from './QuotaCredentials';
|
||||
|
||||
const formatCompactNumber = (value: number) => new Intl.NumberFormat(getCurrentIntlLocale(), {
|
||||
notation: 'compact',
|
||||
@@ -512,18 +513,15 @@ export const ProvidersPage: React.FC = () => {
|
||||
|
||||
if (isAddMode) {
|
||||
return (
|
||||
<ScrollableOverlay outerClassName="h-full" className="w-full">
|
||||
<div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8">
|
||||
<div data-settings-item="providers.connect" className="mb-4">
|
||||
<h1 className="typography-ui-header font-semibold text-foreground">{t('settings.providers.page.connect.title')}</h1>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h2 className="typography-ui-header font-medium text-foreground">{t('settings.providers.page.connect.selectProviderTitle')}</h2>
|
||||
</div>
|
||||
|
||||
<section className="px-2 pb-2 pt-0">
|
||||
<SettingsPageLayout
|
||||
title={t('settings.providers.page.connect.title')}
|
||||
showSaveStatus={false}
|
||||
>
|
||||
<SettingsSection
|
||||
title={t('settings.providers.page.connect.selectProviderTitle')}
|
||||
divider={false}
|
||||
settingsItem="providers.connect"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2 py-1.5">
|
||||
<span className="typography-ui-label text-foreground">{t('settings.providers.page.connect.providerField')}</span>
|
||||
{availableLoading ? (
|
||||
@@ -540,9 +538,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-2 rounded-lg border border-input bg-transparent px-2 py-2 typography-ui-label whitespace-nowrap shadow-none outline-none hover:bg-interactive-hover h-6 w-fit",
|
||||
)}
|
||||
className={SETTINGS_CUSTOM_TRIGGER_CLASS}
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
{candidateProviderId ? <ProviderLogo providerId={candidateProviderId} className="h-3.5 w-3.5 flex-shrink-0" /> : null}
|
||||
@@ -609,32 +605,24 @@ export const ProvidersPage: React.FC = () => {
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{candidateProviderId && (
|
||||
<div data-settings-item="providers.auth" className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h2 className="typography-ui-header font-medium text-foreground">{t('settings.providers.page.auth.title')}</h2>
|
||||
</div>
|
||||
|
||||
<SettingsSection
|
||||
title={t('settings.providers.page.auth.title')}
|
||||
settingsItem="providers.auth"
|
||||
contentClassName="space-y-4"
|
||||
>
|
||||
{authLoading ? (
|
||||
<p className="typography-meta text-muted-foreground px-2">{t('settings.providers.page.auth.loadingMethods')}</p>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.providers.page.auth.loadingMethods')}</p>
|
||||
) : (
|
||||
<section className="px-2 pb-2 pt-0 space-y-4">
|
||||
<>
|
||||
<div className="py-1.5">
|
||||
<label className="typography-ui-label text-foreground flex items-center gap-1.5">
|
||||
{t('settings.providers.page.auth.apiKeyLabel')}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
{t('settings.providers.page.auth.apiKeyTooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<SettingsInfoHint>{t('settings.providers.page.auth.apiKeyTooltip')}</SettingsInfoHint>
|
||||
</label>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 mt-1.5">
|
||||
<div className="flex flex-col @xl:flex-row @xl:items-center gap-2 mt-1.5">
|
||||
<Input
|
||||
type="password"
|
||||
value={apiKeyInputs[candidateProviderId] ?? ''}
|
||||
@@ -750,12 +738,11 @@ export const ProvidersPage: React.FC = () => {
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
)}
|
||||
</div>
|
||||
</ScrollableOverlay>
|
||||
</SettingsPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -784,42 +771,32 @@ export const ProvidersPage: React.FC = () => {
|
||||
});
|
||||
|
||||
return (
|
||||
<ScrollableOverlay outerClassName="h-full" className="w-full">
|
||||
<div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8">
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<ProviderLogo providerId={selectedProvider.id} className="h-5 w-5 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<h2 className="typography-ui-header font-semibold text-foreground truncate">
|
||||
{selectedProvider.name || selectedProvider.id}
|
||||
</h2>
|
||||
<p className="typography-meta text-muted-foreground truncate">
|
||||
<span className="font-mono">{selectedProvider.id}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Authentication */}
|
||||
<div data-settings-item="providers.auth" className="mb-8">
|
||||
<div className="mb-1 px-1 flex items-center justify-between gap-2">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.providers.page.auth.title')}</h3>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => setShowAuthPanel((prev) => !prev)}
|
||||
>
|
||||
{showAuthPanel ? t('settings.providers.page.actions.hide') : t('settings.providers.page.actions.reconnect')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<section className="px-2 pb-2 pt-0">
|
||||
<SettingsPageLayout
|
||||
title={selectedProvider.name || selectedProvider.id}
|
||||
titleLeading={<ProviderLogo providerId={selectedProvider.id} className="h-5 w-5 shrink-0" />}
|
||||
description={<span className="font-mono typography-settings-description text-muted-foreground">{selectedProvider.id}</span>}
|
||||
showSaveStatus={false}
|
||||
>
|
||||
<SettingsSection
|
||||
title={t('settings.providers.page.auth.title')}
|
||||
divider={false}
|
||||
headerAction={(
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => setShowAuthPanel((prev) => !prev)}
|
||||
>
|
||||
{showAuthPanel ? t('settings.providers.page.actions.hide') : t('settings.providers.page.actions.reconnect')}
|
||||
</Button>
|
||||
)}
|
||||
settingsItem="providers.auth"
|
||||
>
|
||||
{!showAuthPanel ? (
|
||||
<div className="flex items-center gap-1.5 py-1.5">
|
||||
<Icon name="check" className="w-4 h-4 text-[var(--status-success)] shrink-0" />
|
||||
<span className="typography-ui-label text-foreground">{t('settings.providers.page.auth.connected')}</span>
|
||||
<span className="typography-meta text-muted-foreground ml-1">{t('settings.providers.page.auth.useReconnectHint')}</span>
|
||||
<SettingsInfoHint>{t('settings.providers.page.auth.useReconnectHint')}</SettingsInfoHint>
|
||||
</div>
|
||||
) : authLoading ? (
|
||||
<div className="py-1.5 typography-meta text-muted-foreground">{t('settings.providers.page.auth.loadingMethods')}</div>
|
||||
@@ -828,16 +805,9 @@ export const ProvidersPage: React.FC = () => {
|
||||
<div className="py-1.5">
|
||||
<label className="typography-ui-label text-foreground flex items-center gap-1.5">
|
||||
{t('settings.providers.page.auth.apiKeyLabel')}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
{t('settings.providers.page.auth.apiKeyTooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<SettingsInfoHint>{t('settings.providers.page.auth.apiKeyTooltip')}</SettingsInfoHint>
|
||||
</label>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 mt-1.5">
|
||||
<div className="flex flex-col @xl:flex-row @xl:items-center gap-2 mt-1.5">
|
||||
<Input
|
||||
type="password"
|
||||
value={apiKeyInputs[selectedProvider.id] ?? ''}
|
||||
@@ -944,21 +914,14 @@ export const ProvidersPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{(selectedProvider.id === 'opencode' || selectedProvider.id === 'opencode-go') && <QuotaCredentials providerId="opencode-go" providerName="OpenCode Go" />}
|
||||
{(selectedProvider.id === 'ollama' || selectedProvider.id === 'ollama-cloud') && <QuotaCredentials providerId="ollama-cloud" providerName="Ollama Cloud" />}
|
||||
{selectedProvider.id === 'cursor' && <QuotaCredentials providerId="cursor" providerName="Cursor" />}
|
||||
|
||||
{/* Connection Details */}
|
||||
<div data-settings-item="providers.connection-details" className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.providers.page.connectionDetails.title')}</h3>
|
||||
</div>
|
||||
|
||||
<section className="px-2 pb-2 pt-0">
|
||||
<div className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:justify-between sm:gap-8">
|
||||
<SettingsSection
|
||||
title={t('settings.providers.page.connectionDetails.title')}
|
||||
settingsItem="providers.connection-details"
|
||||
>
|
||||
<div className="flex flex-col gap-2 py-1.5 @xl:flex-row @xl:items-center @xl:justify-between @xl:gap-8">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
{selectedSources && (selectedSources.auth.exists || selectedSources.user.exists || selectedSources.project.exists || selectedSources.custom?.exists) ? (
|
||||
<span className="typography-meta text-muted-foreground">
|
||||
@@ -985,46 +948,44 @@ export const ProvidersPage: React.FC = () => {
|
||||
{authBusyKey === `disconnect:${selectedProvider.id}` ? t('settings.providers.page.actions.disconnecting') : t('settings.providers.page.actions.disconnect')}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Models */}
|
||||
<div data-settings-item="providers.models" className="mb-8">
|
||||
<div className="mb-1 px-1 flex items-center justify-between gap-2">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
{t('settings.providers.page.models.title')}
|
||||
{providerModels.length > 0 && (
|
||||
<span className="ml-1.5 typography-micro text-muted-foreground font-normal">
|
||||
({providerModels.length})
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => {
|
||||
const allIds = providerModels
|
||||
.map((model) => (typeof model?.id === 'string' ? model.id : ''))
|
||||
.filter((id) => id.length > 0);
|
||||
hideAllModels(selectedProvider.id, allIds);
|
||||
}}
|
||||
>
|
||||
{t('settings.providers.page.actions.hideAll')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => showAllModels(selectedProvider.id)}
|
||||
>
|
||||
{t('settings.providers.page.actions.showAll')}
|
||||
</Button>
|
||||
</div>
|
||||
<SettingsSection
|
||||
title={t('settings.providers.page.models.title')}
|
||||
titleAccessory={
|
||||
providerModels.length > 0 ? (
|
||||
<span className="typography-micro text-muted-foreground font-normal">
|
||||
({providerModels.length})
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
headerAction={(
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => {
|
||||
const allIds = providerModels
|
||||
.map((model) => (typeof model?.id === 'string' ? model.id : ''))
|
||||
.filter((id) => id.length > 0);
|
||||
hideAllModels(selectedProvider.id, allIds);
|
||||
}}
|
||||
>
|
||||
{t('settings.providers.page.actions.hideAll')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => showAllModels(selectedProvider.id)}
|
||||
>
|
||||
{t('settings.providers.page.actions.showAll')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<section className="px-2 pb-2 pt-0">
|
||||
)}
|
||||
settingsItem="providers.models"
|
||||
>
|
||||
<div className="relative mb-2">
|
||||
<Icon name="search" className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
@@ -1104,9 +1065,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollableOverlay>
|
||||
</SettingsSection>
|
||||
</SettingsPageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Icon } from "@/components/icon/Icon";
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { SETTINGS_PANEL_TITLE_CLASS } from '@/components/sections/shared/SettingsSection';
|
||||
|
||||
const ADD_PROVIDER_ID = '__add_provider__';
|
||||
|
||||
@@ -111,7 +112,7 @@ export const ProvidersSidebar: React.FC<ProvidersSidebarProps> = ({ onItemSelect
|
||||
return (
|
||||
<div className={cn('flex h-full flex-col', bgClass)}>
|
||||
<div className="border-b px-3 pt-4 pb-3">
|
||||
<h2 className="text-base font-semibold text-foreground mb-3">{t('settings.providers.sidebar.title')}</h2>
|
||||
<h2 className={`${SETTINGS_PANEL_TITLE_CLASS} mb-3`}>{t('settings.providers.sidebar.title')}</h2>
|
||||
<SettingsProjectSelector className="mb-3" />
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.providers.sidebar.total', { count: providers.length })}</span>
|
||||
|
||||
@@ -18,9 +18,16 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsGroupTitle,
|
||||
SETTINGS_PAGE_TITLE_CLASS,
|
||||
SETTINGS_FIELD_LABEL_CLASS,
|
||||
SETTINGS_SELECT_SIZE,
|
||||
} from '@/components/sections/shared/SettingsSection';
|
||||
import { SettingsInfoHint } from '@/components/sections/shared/SettingsInfoHint';
|
||||
import { useDesktopSshStore } from '@/stores/useDesktopSshStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { toast } from '@/components/ui';
|
||||
@@ -28,6 +35,7 @@ import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Radio } from '@/components/ui/radio';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatDateTimeForPreference } from '@/lib/timeFormat';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { openExternalUrl } from '@/lib/url';
|
||||
import { useI18n, type I18nKey } from '@/lib/i18n';
|
||||
@@ -184,16 +192,9 @@ const suggestConcreteHost = (pattern: string): string => {
|
||||
|
||||
const HintLabel: React.FC<{ label: string; hint: React.ReactNode }> = ({ label, hint }) => {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 typography-meta text-muted-foreground">
|
||||
<span className={`inline-flex items-center gap-1 ${SETTINGS_FIELD_LABEL_CLASS}`}>
|
||||
<span>{label}</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
<div className="typography-meta text-foreground">{hint}</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<SettingsInfoHint contentClassName="max-w-xs">{hint}</SettingsInfoHint>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -380,6 +381,7 @@ const normalizeForSave = (instance: DesktopSshInstance): DesktopSshInstance => {
|
||||
|
||||
export const RemoteInstancesPage: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const timeFormatPreference = useUIStore((state) => state.timeFormatPreference);
|
||||
const { clientAuth } = useRuntimeAPIs();
|
||||
const showInstanceManagement = isDesktopShell();
|
||||
const instances = useDesktopSshStore((state) => state.instances);
|
||||
@@ -1384,21 +1386,22 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
|
||||
if (!draft) {
|
||||
return (
|
||||
<SettingsPageLayout>
|
||||
<SettingsPageLayout title={t('settings.page.remoteInstances.title')}>
|
||||
{clientAuth ? (
|
||||
<div data-settings-item="remote-instances.client-auth" className="mb-8">
|
||||
<div className="mb-1 px-1 space-y-0.5">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.clientAuth.title')}</h3>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.clientAuth.description')}</p>
|
||||
</div>
|
||||
<section className="px-2 pb-2 pt-0 space-y-3">
|
||||
<SettingsSection
|
||||
title={t('settings.remoteInstances.clientAuth.title')}
|
||||
info={t('settings.remoteInstances.clientAuth.description')}
|
||||
divider={false}
|
||||
settingsItem="remote-instances.client-auth"
|
||||
contentClassName="space-y-3"
|
||||
>
|
||||
<div>
|
||||
<Button type="button" size="xs" className="!font-normal" onClick={() => void openAddDevice()}>
|
||||
<Icon name="add" className="h-3.5 w-3.5" />
|
||||
{t('settings.remoteInstances.clientAuth.actions.addDevice')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-2.5">
|
||||
{revokedClientCount > 0 ? (
|
||||
<div className="flex justify-end">
|
||||
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => void purgeRevokedRemoteClients()}>
|
||||
@@ -1414,7 +1417,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
<>
|
||||
{pendingPairings.map((pending) => (
|
||||
<div key={`pending-${pending.id}`} className="flex items-center justify-between gap-3 py-1.5">
|
||||
<div className="min-w-0">
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-[var(--status-warning)] animate-pulse" />
|
||||
<p className="typography-ui-label text-foreground truncate">{pending.label || t('settings.remoteInstances.clientAuth.field.labelPlaceholder')}</p>
|
||||
@@ -1444,13 +1447,20 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
? (client.lastTransport === 'relay' && !isLocalDesktopClient
|
||||
? t('settings.remoteInstances.clientAuth.state.connectedRelay')
|
||||
: t('settings.remoteInstances.clientAuth.state.connectedDirect'))
|
||||
: client.lastUsedAt
|
||||
? t('settings.remoteInstances.clientAuth.lastUsed', { date: client.lastUsedAt })
|
||||
: Number.isFinite(lastUsedMs)
|
||||
? t('settings.remoteInstances.clientAuth.lastUsed', {
|
||||
date: formatDateTimeForPreference(lastUsedMs, timeFormatPreference, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
}),
|
||||
})
|
||||
: t('settings.remoteInstances.clientAuth.neverUsed');
|
||||
return (
|
||||
<div key={client.id} className="flex items-center justify-between gap-3 py-1.5">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0.5">
|
||||
<span className={cn(
|
||||
'h-2 w-2 shrink-0 rounded-full',
|
||||
client.revokedAt ? 'bg-muted-foreground/20' : isOnline ? 'bg-[var(--status-success)]' : 'bg-muted-foreground/30',
|
||||
@@ -1466,8 +1476,8 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
{t('settings.remoteInstances.clientAuth.state.thisDevice')}
|
||||
</span>
|
||||
) : null}
|
||||
<span className={cn('typography-micro truncate', isOnline && !client.revokedAt ? 'text-[var(--status-success)]' : 'text-muted-foreground')}>{statusText}</span>
|
||||
</div>
|
||||
<p className={cn('typography-micro truncate', isOnline && !client.revokedAt ? 'text-[var(--status-success)]' : 'text-muted-foreground')}>{statusText}</p>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => void revokeRemoteClient(client)} disabled={Boolean(client.revokedAt)}>
|
||||
{t('settings.remoteInstances.clientAuth.actions.revoke')}
|
||||
@@ -1479,20 +1489,19 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
{remoteClientError ? <p className="typography-meta text-[var(--status-error)]">{remoteClientError}</p> : null}
|
||||
</section>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
) : null}
|
||||
|
||||
{showInstanceManagement ? <div data-settings-item="remote-instances.direct-hosts" className="mb-8 border-t border-[var(--surface-subtle)] pt-8">
|
||||
<div className="mb-1 flex items-start justify-between gap-3 px-1">
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.direct.title')}</h3>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.direct.description')}</p>
|
||||
</div>
|
||||
{/* Importing a pairing link is the flagship path; add-by-address is
|
||||
the manual fallback. The token-storage note lives in the add
|
||||
dialog next to the token field it describes. */}
|
||||
<div className="flex shrink-0 items-center gap-2 pt-0.5">
|
||||
{showInstanceManagement ? <SettingsSection
|
||||
title={t('settings.remoteInstances.direct.title')}
|
||||
info={t('settings.remoteInstances.direct.description')}
|
||||
settingsItem="remote-instances.direct-hosts"
|
||||
contentClassName="space-y-4"
|
||||
headerAction={(
|
||||
/* Importing a pairing link is the flagship path; add-by-address is
|
||||
the manual fallback. The token-storage note lives in the add
|
||||
dialog next to the token field it describes. */
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button type="button" size="xs" className="!font-normal" onClick={() => setDirectImportDialogOpen(true)} disabled={directSaving}>
|
||||
{t('settings.remoteInstances.direct.import.action')}
|
||||
</Button>
|
||||
@@ -1501,9 +1510,9 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
{t('settings.remoteInstances.direct.actions.add')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<section className="px-2 pb-2 pt-0 space-y-4">
|
||||
<div className="space-y-1">
|
||||
)}
|
||||
>
|
||||
<div className="space-y-2.5">
|
||||
{directLoading ? (
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.direct.state.loading')}</p>
|
||||
) : directHosts.length === 0 ? (
|
||||
@@ -1527,7 +1536,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
return (
|
||||
<div key={host.id} className="py-1.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className={cn(
|
||||
'h-2 w-2 shrink-0 rounded-full',
|
||||
@@ -1572,8 +1581,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{directError ? <p className="typography-meta text-[var(--status-error)]">{directError}</p> : null}
|
||||
</section>
|
||||
</div> : null}
|
||||
</SettingsSection> : null}
|
||||
|
||||
{showInstanceManagement ? <Dialog open={directAddDialogOpen} onOpenChange={setDirectAddDialogOpen}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
@@ -1589,9 +1597,9 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
<p className="px-1 typography-micro text-muted-foreground">{t('settings.remoteInstances.direct.note')}</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<p className="typography-ui-label text-foreground">{t('settings.remoteInstances.direct.headers.title')}</p>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.direct.headers.description')}</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<SettingsGroupTitle>{t('settings.remoteInstances.direct.headers.title')}</SettingsGroupTitle>
|
||||
<SettingsInfoHint>{t('settings.remoteInstances.direct.headers.description')}</SettingsInfoHint>
|
||||
</div>
|
||||
{directHeaders.map((header) => (
|
||||
<div key={header.id} className="flex w-full gap-2">
|
||||
@@ -1626,9 +1634,9 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
<Input className="h-8" value={directEditUrl} onChange={(event) => setDirectEditUrl(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.urlPlaceholder')} disabled={directSaving} autoFocus />
|
||||
<Input className="h-8" value={directEditToken} onChange={(event) => setDirectEditToken(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.tokenPlaceholder')} type="password" disabled={directSaving} />
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<p className="typography-ui-label text-foreground">{t('settings.remoteInstances.direct.headers.title')}</p>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.direct.headers.description')}</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<SettingsGroupTitle>{t('settings.remoteInstances.direct.headers.title')}</SettingsGroupTitle>
|
||||
<SettingsInfoHint>{t('settings.remoteInstances.direct.headers.description')}</SettingsInfoHint>
|
||||
</div>
|
||||
{directEditHeaders.map((header) => (
|
||||
<div key={header.id} className="flex w-full gap-2">
|
||||
@@ -1762,20 +1770,17 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{showInstanceManagement ? <div className="mb-8 border-t border-[var(--surface-subtle)] pt-8">
|
||||
<div className="mb-1 px-1 space-y-0.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.sidebar.title')}</h3>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.sidebar.total', { count: instances.length })}</p>
|
||||
</div>
|
||||
<Button type="button" size="xs" className="!font-normal" onClick={() => setSshAddDialogOpen(true)}>
|
||||
<Icon name="add" className="h-3.5 w-3.5" />
|
||||
{t('settings.remoteInstances.sidebar.actions.addSshInstance')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<section className="px-2 pb-2 pt-0 space-y-1">
|
||||
{showInstanceManagement ? <SettingsSection
|
||||
title={t('settings.remoteInstances.sidebar.title')}
|
||||
description={t('settings.remoteInstances.sidebar.total', { count: instances.length })}
|
||||
headerAction={(
|
||||
<Button type="button" size="xs" className="!font-normal" onClick={() => setSshAddDialogOpen(true)}>
|
||||
<Icon name="add" className="h-3.5 w-3.5" />
|
||||
{t('settings.remoteInstances.sidebar.actions.addSshInstance')}
|
||||
</Button>
|
||||
)}
|
||||
contentClassName="space-y-2.5"
|
||||
>
|
||||
{isLoading ? (
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.import.loading')}</p>
|
||||
) : instances.length === 0 ? (
|
||||
@@ -1787,7 +1792,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
const ready = phase === 'ready';
|
||||
return (
|
||||
<div key={instance.id} className="flex items-center justify-between gap-3 py-1.5">
|
||||
<div className="min-w-0">
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className={`h-2 w-2 rounded-full ${phaseDotClass(phase)}`} />
|
||||
<p className="typography-ui-label text-foreground truncate">{title}</p>
|
||||
@@ -1824,8 +1829,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
</div> : null}
|
||||
</SettingsSection> : null}
|
||||
|
||||
{showInstanceManagement ? <Dialog open={sshAddDialogOpen} onOpenChange={setSshAddDialogOpen}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
@@ -1844,11 +1848,9 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
</DialogContent>
|
||||
</Dialog> : null}
|
||||
|
||||
{showInstanceManagement ? <div className="mb-8 border-t border-[var(--surface-subtle)] pt-8">
|
||||
<div className="mb-1 px-1 space-y-0.5">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.import.sectionTitle')}</h3>
|
||||
</div>
|
||||
<section className="px-2 pb-2 pt-0">
|
||||
{showInstanceManagement ? <SettingsSection
|
||||
title={t('settings.remoteInstances.page.import.sectionTitle')}
|
||||
>
|
||||
{isImportsLoading ? (
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.import.loading')}</p>
|
||||
) : importCandidates.length === 0 ? (
|
||||
@@ -1877,8 +1879,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div> : null}
|
||||
</SettingsSection> : null}
|
||||
|
||||
<Dialog
|
||||
open={Boolean(patternHost)}
|
||||
@@ -1930,7 +1931,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
<Dialog open={Boolean(draft)} onOpenChange={(open) => { if (!open) setSelectedId(null); }}>
|
||||
<DialogContent className="sm:max-w-4xl max-h-[90vh] overflow-auto">
|
||||
<div className="mb-6 px-1">
|
||||
<h2 className="typography-ui-header font-semibold text-foreground truncate">{instanceTitle}</h2>
|
||||
<h1 className={`${SETTINGS_PAGE_TITLE_CLASS} truncate`}>{instanceTitle}</h1>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 typography-meta text-muted-foreground">
|
||||
<span className={`h-2.5 w-2.5 rounded-full ${phaseDotClass(statusPhase)}`} />
|
||||
<span>{t(phaseLabelKey(statusPhase))}</span>
|
||||
@@ -1939,12 +1940,12 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1 space-y-0.5">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.section.actions')}</h3>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.section.actionsDescription')}</p>
|
||||
</div>
|
||||
<section className="px-2 pb-2 pt-0 space-y-3">
|
||||
<SettingsSection
|
||||
title={t('settings.remoteInstances.page.section.actions')}
|
||||
info={t('settings.remoteInstances.page.section.actionsDescription')}
|
||||
divider={false}
|
||||
contentClassName="space-y-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
@@ -2010,15 +2011,13 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
<span className="font-mono text-foreground/90">{status.localUrl}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1 space-y-0.5">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.section.instance')}</h3>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.section.instanceDescription')}</p>
|
||||
</div>
|
||||
<section className="px-2 pb-2 pt-0 space-y-3">
|
||||
<SettingsSection
|
||||
title={t('settings.remoteInstances.page.section.instance')}
|
||||
info={t('settings.remoteInstances.page.section.instanceDescription')}
|
||||
contentClassName="space-y-3"
|
||||
>
|
||||
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
|
||||
<span className="typography-ui-label text-foreground w-56 shrink-0">{t('settings.remoteInstances.page.field.sshCommand')}</span>
|
||||
<Input
|
||||
@@ -2064,15 +2063,13 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<div className="mb-8 border-t border-[var(--surface-subtle)] pt-8">
|
||||
<div className="mb-1 px-1 space-y-0.5">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.section.remoteServer')}</h3>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.section.remoteServerDescription')}</p>
|
||||
</div>
|
||||
<section className="px-2 pb-2 pt-0 space-y-3">
|
||||
<SettingsSection
|
||||
title={t('settings.remoteInstances.page.section.remoteServer')}
|
||||
info={t('settings.remoteInstances.page.section.remoteServerDescription')}
|
||||
contentClassName="space-y-3"
|
||||
>
|
||||
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
|
||||
<div className="w-56 shrink-0">
|
||||
<HintLabel
|
||||
@@ -2092,7 +2089,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-fit min-w-[140px]">
|
||||
<SelectTrigger size={SETTINGS_SELECT_SIZE} className="w-fit min-w-[140px]">
|
||||
<SelectValue placeholder={t('settings.remoteInstances.page.field.modePlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -2161,7 +2158,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-fit min-w-[140px]">
|
||||
<SelectTrigger size={SETTINGS_SELECT_SIZE} className="w-fit min-w-[140px]">
|
||||
<SelectValue placeholder={t('settings.remoteInstances.page.field.selectInstallMethodPlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -2198,15 +2195,13 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<div className="mb-8 border-t border-[var(--surface-subtle)] pt-8">
|
||||
<div className="mb-1 px-1 space-y-0.5">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.section.mainTunnel')}</h3>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.section.mainTunnelDescription')}</p>
|
||||
</div>
|
||||
<section className="px-2 pb-2 pt-0 space-y-3">
|
||||
<SettingsSection
|
||||
title={t('settings.remoteInstances.page.section.mainTunnel')}
|
||||
info={t('settings.remoteInstances.page.section.mainTunnelDescription')}
|
||||
contentClassName="space-y-3"
|
||||
>
|
||||
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
|
||||
<div className="w-56 shrink-0">
|
||||
<HintLabel
|
||||
@@ -2232,7 +2227,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-fit min-w-[140px]">
|
||||
<SelectTrigger size={SETTINGS_SELECT_SIZE} className="w-fit min-w-[140px]">
|
||||
<SelectValue placeholder={t('settings.remoteInstances.page.field.selectBindHostPlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -2298,15 +2293,13 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<div className="mb-8 border-t border-[var(--surface-subtle)] pt-8">
|
||||
<div className="mb-1 px-1 space-y-0.5">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.section.authentication')}</h3>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.section.authenticationDescription')}</p>
|
||||
</div>
|
||||
<section className="px-2 pb-2 pt-0 space-y-3">
|
||||
<SettingsSection
|
||||
title={t('settings.remoteInstances.page.section.authentication')}
|
||||
info={t('settings.remoteInstances.page.section.authenticationDescription')}
|
||||
contentClassName="space-y-3"
|
||||
>
|
||||
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
|
||||
<span className="typography-ui-label text-foreground w-56 shrink-0">{t('settings.remoteInstances.page.field.sshPasswordOptional')}</span>
|
||||
<Input
|
||||
@@ -2352,15 +2345,13 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
placeholder={t('settings.remoteInstances.page.field.uiPasswordPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<div className="mb-8 border-t border-[var(--surface-subtle)] pt-8">
|
||||
<div className="mb-1 px-1 space-y-0.5">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.section.portForwards')}</h3>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.section.portForwardsDescription')}</p>
|
||||
</div>
|
||||
<section className="px-2 pb-2 pt-0 space-y-2">
|
||||
<SettingsSection
|
||||
title={t('settings.remoteInstances.page.section.portForwards')}
|
||||
info={t('settings.remoteInstances.page.section.portForwardsDescription')}
|
||||
contentClassName="space-y-2"
|
||||
>
|
||||
{draft.portForwards.length === 0 ? (
|
||||
<p className="typography-micro text-muted-foreground/80">{t('settings.remoteInstances.page.empty.noExtraForwards')}</p>
|
||||
) : null}
|
||||
@@ -2436,12 +2427,16 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
</div>
|
||||
<CollapsibleContent className="pt-2">
|
||||
<div className="space-y-0 pb-2">
|
||||
<p className="typography-meta text-muted-foreground mb-3">{t(forwardTypeDescriptionKey(forward.type))}</p>
|
||||
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
|
||||
<div className="w-56 shrink-0">
|
||||
<HintLabel
|
||||
label={t('settings.remoteInstances.page.field.forwardType')}
|
||||
hint={t('settings.remoteInstances.page.field.forwardTypeHint')}
|
||||
hint={(
|
||||
<div className="space-y-1">
|
||||
<p>{t('settings.remoteInstances.page.field.forwardTypeHint')}</p>
|
||||
<p>{t(forwardTypeDescriptionKey(forward.type))}</p>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
@@ -2453,7 +2448,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-fit min-w-[140px]">
|
||||
<SelectTrigger size={SETTINGS_SELECT_SIZE} className="w-fit min-w-[140px]">
|
||||
<SelectValue placeholder={t('settings.remoteInstances.page.field.typePlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -2624,8 +2619,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
<Icon name="add" className="h-3.5 w-3.5" />
|
||||
{t('settings.remoteInstances.page.actions.addForward')}
|
||||
</Button>
|
||||
</section>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<div className="mt-8 border-t border-[var(--interactive-border)] pt-3">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SettingsInfoHintProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
/** Tooltip panel width cap. @default 'max-w-sm' */
|
||||
contentClassName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Info icon revealing helper text on hover or click (click covers touch
|
||||
* devices, where hover tooltips never open). Clicking outside closes it.
|
||||
* Settings pages hide secondary descriptions behind this to keep the
|
||||
* default view quiet.
|
||||
*/
|
||||
export const SettingsInfoHint: React.FC<SettingsInfoHintProps> = ({
|
||||
children,
|
||||
className,
|
||||
contentClassName,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const triggerRef = React.useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
const trigger = triggerRef.current;
|
||||
if (trigger && event.target instanceof Node && trigger.contains(event.target)) {
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
};
|
||||
document.addEventListener('pointerdown', handlePointerDown, true);
|
||||
return () => document.removeEventListener('pointerdown', handlePointerDown, true);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<Tooltip open={open} onOpenChange={setOpen}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
aria-label={t('settings.common.infoAria')}
|
||||
aria-expanded={open}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setOpen((current) => !current);
|
||||
}}
|
||||
className={cn(
|
||||
'inline-flex h-5 w-5 shrink-0 cursor-help items-center justify-center rounded text-muted-foreground/60 hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Icon name="information" className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className={cn('max-w-sm', contentClassName)}>
|
||||
{children}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -1,10 +1,29 @@
|
||||
import React from 'react';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { getSettingsSaveState, subscribeToSettingsSaveState } from '@/lib/persistence';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
SETTINGS_DESCRIPTION_CLASS,
|
||||
SETTINGS_PAGE_TITLE_CLASS,
|
||||
} from '@/components/sections/shared/SettingsSection';
|
||||
|
||||
interface SettingsPageLayoutProps {
|
||||
/** Page content */
|
||||
children: React.ReactNode;
|
||||
/** Optional page title shown above settings content. */
|
||||
title?: React.ReactNode;
|
||||
/** Optional content rendered before a string/number page title. */
|
||||
titleLeading?: React.ReactNode;
|
||||
/** Optional content rendered after a string/number page title. */
|
||||
titleAccessory?: React.ReactNode;
|
||||
/** Optional supporting description under the page title. */
|
||||
description?: React.ReactNode;
|
||||
/** Optional content rendered at the end of the header row (before save status). */
|
||||
headerEnd?: React.ReactNode;
|
||||
/** Show persistence feedback for instant-save settings. */
|
||||
showSaveStatus?: boolean;
|
||||
/** Additional className for the content container */
|
||||
className?: string;
|
||||
/** Additional className for the outer ScrollableOverlay */
|
||||
@@ -13,36 +32,120 @@ interface SettingsPageLayoutProps {
|
||||
|
||||
/**
|
||||
* Standard layout wrapper for settings page content.
|
||||
* Provides scrolling and centered max-width container.
|
||||
*
|
||||
* @example
|
||||
* <SettingsPageLayout>
|
||||
* <SettingsSection title="General">
|
||||
* <SomeSettingsForm />
|
||||
* </SettingsSection>
|
||||
* <SettingsSection title="Advanced" divider>
|
||||
* <OtherSettingsForm />
|
||||
* </SettingsSection>
|
||||
* </SettingsPageLayout>
|
||||
* UI Kit: max-width 840px, padding 32px vertical / 48px horizontal.
|
||||
*/
|
||||
export const SettingsPageLayout: React.FC<SettingsPageLayoutProps> = ({
|
||||
children,
|
||||
className,
|
||||
outerClassName,
|
||||
title,
|
||||
titleLeading,
|
||||
titleAccessory,
|
||||
description,
|
||||
headerEnd,
|
||||
showSaveStatus = false,
|
||||
}) => {
|
||||
const hasHeader = title != null || description != null || headerEnd != null || showSaveStatus;
|
||||
const isPlainTitle = typeof title === 'string' || typeof title === 'number';
|
||||
const hasTitleChrome = titleLeading != null || titleAccessory != null;
|
||||
|
||||
return (
|
||||
<ScrollableOverlay
|
||||
outerClassName={cn('h-full', outerClassName)}
|
||||
className="w-full"
|
||||
className="w-full @container"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'mx-auto max-w-3xl space-y-6 p-3 sm:p-6 sm:pt-8',
|
||||
'mx-auto max-w-[840px] space-y-0 px-6 py-6 @3xl:px-12 @3xl:py-8',
|
||||
// The first visible section never needs the top divider, no matter
|
||||
// which platform-conditional sections above it rendered null.
|
||||
'[&>section:first-of-type]:border-t-0 [&>section:first-of-type]:pt-0',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{hasHeader && (
|
||||
<div className="mb-2 flex items-start justify-between gap-4 pb-6">
|
||||
<div className="min-w-0 space-y-1">
|
||||
{title != null ? (
|
||||
isPlainTitle ? (
|
||||
hasTitleChrome ? (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{titleLeading}
|
||||
<h1 className={cn(SETTINGS_PAGE_TITLE_CLASS, 'min-w-0 truncate')}>{title}</h1>
|
||||
{titleAccessory}
|
||||
</div>
|
||||
) : (
|
||||
<h1 className={SETTINGS_PAGE_TITLE_CLASS}>{title}</h1>
|
||||
)
|
||||
) : (
|
||||
title
|
||||
)
|
||||
) : null}
|
||||
{description != null ? (
|
||||
typeof description === 'string' || typeof description === 'number' ? (
|
||||
<p className={SETTINGS_DESCRIPTION_CLASS}>{description}</p>
|
||||
) : (
|
||||
description
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
{headerEnd}
|
||||
{showSaveStatus && <SettingsSaveStatus />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</ScrollableOverlay>
|
||||
);
|
||||
};
|
||||
|
||||
// Only saves slower than this surface a "Saving…" spinner — local writes
|
||||
// finish instantly and stay silent; remote/mobile connections get feedback.
|
||||
const SAVE_SPINNER_DELAY_MS = 500;
|
||||
|
||||
const SettingsSaveStatus: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const status = React.useSyncExternalStore(
|
||||
subscribeToSettingsSaveState,
|
||||
getSettingsSaveState,
|
||||
getSettingsSaveState,
|
||||
);
|
||||
const [showSaving, setShowSaving] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (status !== 'saving') {
|
||||
setShowSaving(false);
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => setShowSaving(true), SAVE_SPINNER_DELAY_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [status]);
|
||||
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<div
|
||||
aria-live="assertive"
|
||||
className="flex shrink-0 items-center gap-1.5 typography-meta text-[var(--status-error)]"
|
||||
>
|
||||
<Icon name="error-warning" className="size-3.5" />
|
||||
<span>{t('settings.common.status.saveFailed')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status !== 'saving' || !showSaving) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="flex shrink-0 items-center gap-1.5 typography-meta text-muted-foreground"
|
||||
>
|
||||
<Icon name="loader-4" className="size-3.5 animate-spin" />
|
||||
<span>{t('settings.common.actions.saving')}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,558 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Radio } from '@/components/ui/radio';
|
||||
import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SettingsInfoHint } from './SettingsInfoHint';
|
||||
|
||||
/** Settings select trigger: full column width in stacked cells; capped in field rows via parent. */
|
||||
export const SETTINGS_SELECT_TRIGGER_CLASS = 'w-full min-w-40 max-w-48';
|
||||
export const SETTINGS_SELECT_SIZE = 'settings' as const;
|
||||
|
||||
/** Fixed-width select used inside full-width SettingsFieldRow control columns. */
|
||||
export const SETTINGS_SELECT_ROW_TRIGGER_CLASS = 'w-full min-w-40 max-w-48';
|
||||
|
||||
/** Compact reset / icon action next to a settings control (matches h-8 controls). */
|
||||
export const SETTINGS_ICON_BUTTON_CLASS =
|
||||
'h-8 w-8 px-0 text-muted-foreground hover:text-foreground';
|
||||
|
||||
/** Custom dropdown triggers (ModelSelector / AgentSelector) in settings field rows. */
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const SETTINGS_CUSTOM_TRIGGER_CLASS = cn(
|
||||
dropdownTriggerVariants(),
|
||||
'w-full min-w-40 max-w-48',
|
||||
);
|
||||
|
||||
/** Shared width for stacked control clusters (select/input + reset). */
|
||||
export const SETTINGS_CONTROL_CLUSTER_CLASS = 'w-full max-w-[24rem]';
|
||||
|
||||
/** Fill a control cluster the same way as a full-width select. */
|
||||
export const SETTINGS_CLUSTER_CONTROL_CLASS = 'min-w-0 flex-1';
|
||||
|
||||
/**
|
||||
* Row wrapping a NumberInput + unit label + reset.
|
||||
* Keep the stepper intrinsic — never flex-grow it or +/- buttons stretch unevenly.
|
||||
*/
|
||||
export const SETTINGS_NUMBER_STEPPER_ROW_CLASS = 'flex w-full min-w-0 items-center gap-2';
|
||||
|
||||
/** Unit suffix next to a settings number stepper (%, px, …). */
|
||||
export const SETTINGS_NUMBER_UNIT_CLASS =
|
||||
'typography-meta shrink-0 text-muted-foreground tabular-nums';
|
||||
|
||||
/** Vertical stack spacing for fields inside a column. */
|
||||
export const SETTINGS_FIELDS_STACK_CLASS = 'space-y-4';
|
||||
|
||||
/** Compact checkbox / radio list stack. */
|
||||
export const SETTINGS_OPTION_STACK_CLASS = 'space-y-1.5';
|
||||
|
||||
/**
|
||||
* Settings heading classes by context (size + default color).
|
||||
* Prefer these over ad-hoc typography-* + color combinations.
|
||||
*/
|
||||
/** L1 — page / detail-pane title (larger, quieter than section titles). */
|
||||
export const SETTINGS_PAGE_TITLE_CLASS =
|
||||
'typography-settings-page-title text-muted-foreground';
|
||||
/** L2 — section title inside a settings page. */
|
||||
export const SETTINGS_SECTION_TITLE_CLASS =
|
||||
'typography-settings-section-title text-foreground';
|
||||
/** Split-pane sidebar panel title — same level as section titles. */
|
||||
export const SETTINGS_PANEL_TITLE_CLASS = SETTINGS_SECTION_TITLE_CLASS;
|
||||
/** L3 — control-group heading inside a section. */
|
||||
export const SETTINGS_GROUP_TITLE_CLASS =
|
||||
'typography-settings-group-title text-foreground';
|
||||
/** L4 — field / control labels. */
|
||||
export const SETTINGS_FIELD_LABEL_CLASS =
|
||||
'typography-settings-field-label text-foreground';
|
||||
/** Supporting copy under page or section titles. */
|
||||
export const SETTINGS_DESCRIPTION_CLASS =
|
||||
'typography-settings-description text-muted-foreground';
|
||||
/** Supporting copy under group titles and fields. */
|
||||
export const SETTINGS_HELPER_CLASS = 'typography-meta text-muted-foreground';
|
||||
/** Callout / alert headline inside a section (not a control-group title). */
|
||||
export const SETTINGS_CALLOUT_TITLE_CLASS = 'typography-meta font-medium text-foreground';
|
||||
/** Brand / product name under a logo — quieter than L1 page title. */
|
||||
export const SETTINGS_BRAND_TITLE_CLASS =
|
||||
'typography-settings-section-title text-foreground';
|
||||
|
||||
interface SettingsSectionProps {
|
||||
/** Section title. Strings render as the shared h2 style. */
|
||||
title?: React.ReactNode;
|
||||
/** Optional supporting text under the title. */
|
||||
description?: React.ReactNode;
|
||||
/** Optional icon/badge next to the title. */
|
||||
titleAccessory?: React.ReactNode;
|
||||
/** Helper text hidden behind an info icon next to the title. */
|
||||
info?: React.ReactNode;
|
||||
/** Optional action aligned to the right of the header. */
|
||||
headerAction?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* Show a top border divider.
|
||||
* Use `false` for the first section under the page header.
|
||||
* @default true
|
||||
*/
|
||||
divider?: boolean;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
settingsItem?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared settings section chrome: single-style header + optional divider.
|
||||
*/
|
||||
export const SettingsSection: React.FC<SettingsSectionProps> = ({
|
||||
title,
|
||||
description,
|
||||
titleAccessory,
|
||||
info,
|
||||
headerAction,
|
||||
children,
|
||||
divider = true,
|
||||
className,
|
||||
contentClassName,
|
||||
settingsItem,
|
||||
}) => {
|
||||
const hasHeader = title != null || description != null || headerAction != null || info != null;
|
||||
|
||||
return (
|
||||
<section
|
||||
data-settings-item={settingsItem}
|
||||
className={cn(
|
||||
'space-y-5',
|
||||
divider ? 'border-t border-border/60 py-8' : 'pb-8',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{hasHeader ? (
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
{title != null ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{typeof title === 'string' || typeof title === 'number' ? (
|
||||
<h2 className={SETTINGS_SECTION_TITLE_CLASS}>{title}</h2>
|
||||
) : (
|
||||
title
|
||||
)}
|
||||
{titleAccessory}
|
||||
{info != null ? <SettingsInfoHint>{info}</SettingsInfoHint> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{description != null ? (
|
||||
<div className={SETTINGS_DESCRIPTION_CLASS}>{description}</div>
|
||||
) : null}
|
||||
</div>
|
||||
{headerAction ? <div className="shrink-0">{headerAction}</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className={cn(contentClassName)}>{children}</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
interface SettingsTwoColumnProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Responsive two-column settings grid used when space allows. */
|
||||
export const SettingsTwoColumn: React.FC<SettingsTwoColumnProps> = ({
|
||||
children,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<div className={cn('grid grid-cols-1 gap-6 @3xl:grid-cols-2 @3xl:gap-10', className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface SettingsGroupTitleProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
as?: 'h2' | 'h3' | 'div';
|
||||
}
|
||||
|
||||
/** In-section control-group heading (quieter than SettingsSection title). */
|
||||
export const SettingsGroupTitle: React.FC<SettingsGroupTitleProps> = ({
|
||||
children,
|
||||
className,
|
||||
as: Tag = 'h3',
|
||||
}) => {
|
||||
return (
|
||||
<Tag className={cn(SETTINGS_GROUP_TITLE_CLASS, className)}>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
interface SettingsControlGroupProps {
|
||||
title?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
/** Helper text hidden behind an info icon next to the group title. */
|
||||
info?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
settingsItem?: string;
|
||||
}
|
||||
|
||||
/** Labeled control cluster inside a section (radios, chips, stacked fields). */
|
||||
export const SettingsControlGroup: React.FC<SettingsControlGroupProps> = ({
|
||||
title,
|
||||
description,
|
||||
info,
|
||||
children,
|
||||
className,
|
||||
contentClassName,
|
||||
settingsItem,
|
||||
}) => {
|
||||
return (
|
||||
<div data-settings-item={settingsItem} className={cn('space-y-2', className)}>
|
||||
{title != null || description != null || info != null ? (
|
||||
<div className="space-y-0.5">
|
||||
{title != null ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<SettingsGroupTitle>{title}</SettingsGroupTitle>
|
||||
{info != null ? <SettingsInfoHint>{info}</SettingsInfoHint> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{description != null ? (
|
||||
<p className={SETTINGS_HELPER_CLASS}>{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className={cn(contentClassName)}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface SettingsStackedFieldProps {
|
||||
label: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
/** Helper text hidden behind an info icon next to the label. */
|
||||
info?: React.ReactNode;
|
||||
/** Where helper text sits relative to the control. @default 'before' */
|
||||
descriptionPlacement?: 'before' | 'after';
|
||||
children: React.ReactNode;
|
||||
settingsItem?: string;
|
||||
className?: string;
|
||||
controlClassName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Label (+ optional description) above a control — for two-column cells.
|
||||
* Prefer this over SettingsFieldRow inside SettingsTwoColumn (FieldRow overflows half-width columns).
|
||||
*/
|
||||
export const SettingsStackedField: React.FC<SettingsStackedFieldProps> = ({
|
||||
label,
|
||||
description,
|
||||
info,
|
||||
descriptionPlacement = 'before',
|
||||
children,
|
||||
settingsItem,
|
||||
className,
|
||||
controlClassName,
|
||||
}) => {
|
||||
const descriptionNode =
|
||||
description != null ? (
|
||||
<p className={SETTINGS_HELPER_CLASS}>{description}</p>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div data-settings-item={settingsItem} className={cn('space-y-2', className)}>
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className={SETTINGS_FIELD_LABEL_CLASS}>{label}</div>
|
||||
{info != null ? <SettingsInfoHint>{info}</SettingsInfoHint> : null}
|
||||
</div>
|
||||
{descriptionPlacement === 'before' ? descriptionNode : null}
|
||||
</div>
|
||||
<div className={cn('flex min-w-0 max-w-[24rem] items-center gap-2', controlClassName)}>{children}</div>
|
||||
{descriptionPlacement === 'after' ? descriptionNode : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface SettingsFieldRowProps {
|
||||
label: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
/** Helper text hidden behind an info icon next to the label. */
|
||||
info?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
settingsItem?: string;
|
||||
className?: string;
|
||||
controlClassName?: string;
|
||||
/** Align control to the trailing edge on desktop. @default true */
|
||||
alignEnd?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Side-by-side form row: fixed label column + control cluster.
|
||||
* Use inside full-width sections or single columns.
|
||||
*/
|
||||
export const SettingsFieldRow: React.FC<SettingsFieldRowProps> = ({
|
||||
label,
|
||||
description,
|
||||
info,
|
||||
children,
|
||||
settingsItem,
|
||||
className,
|
||||
controlClassName,
|
||||
alignEnd = true,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
data-settings-item={settingsItem}
|
||||
className={cn(
|
||||
'flex flex-col gap-2 py-0.5 @xl:flex-row @xl:items-center @xl:gap-8',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 @xl:w-56 @xl:shrink-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className={SETTINGS_FIELD_LABEL_CLASS}>{label}</div>
|
||||
{info != null ? <SettingsInfoHint>{info}</SettingsInfoHint> : null}
|
||||
</div>
|
||||
{description != null ? (
|
||||
<p className={cn(SETTINGS_HELPER_CLASS, 'mt-0.5')}>{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-w-0 flex-1 items-center gap-2 @xl:w-fit @xl:flex-none',
|
||||
alignEnd && '@xl:justify-end',
|
||||
controlClassName,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface SettingsInsetProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
settingsItem?: string;
|
||||
}
|
||||
|
||||
/** Optional inset block under a section (spacing only; section dividers own borders). */
|
||||
export const SettingsInset: React.FC<SettingsInsetProps> = ({
|
||||
children,
|
||||
className,
|
||||
settingsItem,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
data-settings-item={settingsItem}
|
||||
className={cn('pt-4', className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface SettingsCheckboxRowProps {
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
label: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
settingsItem?: string;
|
||||
className?: string;
|
||||
labelAccessory?: React.ReactNode;
|
||||
/** Helper text hidden behind an info icon next to the label. */
|
||||
info?: React.ReactNode;
|
||||
}
|
||||
|
||||
/** Shared checkbox setting row with keyboard support. */
|
||||
export const SettingsCheckboxRow: React.FC<SettingsCheckboxRowProps> = ({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
description,
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
settingsItem,
|
||||
className,
|
||||
labelAccessory,
|
||||
info,
|
||||
}) => {
|
||||
const toggle = () => {
|
||||
if (!disabled) onChange(!checked);
|
||||
};
|
||||
|
||||
const hasDescription = description != null;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-settings-item={settingsItem}
|
||||
className={cn(
|
||||
'group flex cursor-pointer gap-2 py-0.5',
|
||||
hasDescription ? 'items-start' : 'items-center',
|
||||
disabled && 'cursor-not-allowed opacity-60',
|
||||
className,
|
||||
)}
|
||||
role="button"
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
aria-pressed={checked}
|
||||
aria-disabled={disabled || undefined}
|
||||
onClick={toggle}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
toggle();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
ariaLabel={ariaLabel}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className={SETTINGS_FIELD_LABEL_CLASS}>{label}</span>
|
||||
{labelAccessory}
|
||||
{info != null ? <SettingsInfoHint>{info}</SettingsInfoHint> : null}
|
||||
</div>
|
||||
{hasDescription ? (
|
||||
<span className={SETTINGS_HELPER_CLASS}>{description}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface SettingsRadioOptionProps {
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
label: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
ariaLabel?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Single radio option row used inside SettingsRadioGroup. */
|
||||
export const SettingsRadioOption: React.FC<SettingsRadioOptionProps> = ({
|
||||
selected,
|
||||
onSelect,
|
||||
label,
|
||||
description,
|
||||
ariaLabel,
|
||||
disabled = false,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex cursor-pointer items-start gap-2 py-0.5',
|
||||
disabled && 'cursor-not-allowed opacity-60',
|
||||
className,
|
||||
)}
|
||||
role="button"
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
aria-pressed={selected}
|
||||
aria-disabled={disabled || undefined}
|
||||
onClick={() => {
|
||||
if (!disabled) onSelect();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
if (!disabled) onSelect();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Radio
|
||||
checked={selected}
|
||||
onChange={onSelect}
|
||||
disabled={disabled}
|
||||
ariaLabel={ariaLabel}
|
||||
className={description != null ? 'mt-0.5' : undefined}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span
|
||||
className={cn(
|
||||
'typography-settings-field-label font-normal',
|
||||
selected ? 'text-foreground' : 'text-foreground/50',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{description != null ? (
|
||||
<span className={SETTINGS_HELPER_CLASS}>{description}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface SettingsRadioGroupProps {
|
||||
'aria-label': string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Accessible radio group wrapper with compact vertical spacing. */
|
||||
export const SettingsRadioGroup: React.FC<SettingsRadioGroupProps> = ({
|
||||
'aria-label': ariaLabel,
|
||||
children,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<div role="radiogroup" aria-label={ariaLabel} className={cn(SETTINGS_OPTION_STACK_CLASS, className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface SettingsChipOption<T extends string> {
|
||||
value: T;
|
||||
label: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface SettingsChipGroupProps<T extends string> {
|
||||
value: T;
|
||||
options: Array<SettingsChipOption<T>>;
|
||||
onChange: (value: T) => void;
|
||||
className?: string;
|
||||
'aria-label'?: string;
|
||||
}
|
||||
|
||||
/** Compact chip / segmented enum picker. */
|
||||
export function SettingsChipGroup<T extends string>({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
className,
|
||||
'aria-label': ariaLabel,
|
||||
}: SettingsChipGroupProps<T>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={ariaLabel}
|
||||
className={cn('flex flex-wrap items-center gap-1', className)}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
type="button"
|
||||
variant="chip"
|
||||
size="xs"
|
||||
disabled={option.disabled}
|
||||
aria-pressed={value === option.value}
|
||||
className="!font-normal"
|
||||
onClick={() => onChange(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,15 @@ import { toast } from '@/components/ui';
|
||||
import { useSkillsStore, type SkillConfig, type SkillScope, type SupportingFile, type PendingFile } from '@/stores/useSkillsStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsFieldRow,
|
||||
SettingsStackedField,
|
||||
SETTINGS_FIELD_LABEL_CLASS,
|
||||
SETTINGS_SELECT_SIZE,
|
||||
} from '@/components/sections/shared/SettingsSection';
|
||||
import { SettingsInfoHint } from '@/components/sections/shared/SettingsInfoHint';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -504,113 +513,103 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollableOverlay outerClassName="h-full" className="w-full">
|
||||
<div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8">
|
||||
<>
|
||||
<SettingsPageLayout
|
||||
title={isNewSkill ? t('settings.skills.page.title.newSkill') : selectedSkillName}
|
||||
description={selectedSkill
|
||||
? t('settings.skills.page.subtitle.skillLocation', {
|
||||
location: locationLabelText(locationValueFrom(selectedSkill.scope, selectedSkill.source)),
|
||||
})
|
||||
: t('settings.skills.page.subtitle.newSkill')}
|
||||
showSaveStatus={false}
|
||||
>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-4">
|
||||
<div className="min-w-0">
|
||||
<h2 className="typography-ui-header font-semibold text-foreground truncate flex items-center gap-2">
|
||||
{isNewSkill ? t('settings.skills.page.title.newSkill') : selectedSkillName}
|
||||
</h2>
|
||||
<p className="typography-meta text-muted-foreground truncate">
|
||||
{selectedSkill
|
||||
? t('settings.skills.page.subtitle.skillLocation', {
|
||||
location: locationLabelText(locationValueFrom(selectedSkill.scope, selectedSkill.source)),
|
||||
})
|
||||
: t('settings.skills.page.subtitle.newSkill')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Basic Information */}
|
||||
<div data-settings-item="skills.basic-information" className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
{t('settings.skills.page.section.basicInformation')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<section className="px-2 pb-2 pt-0 space-y-0">
|
||||
<SettingsSection
|
||||
title={t('settings.skills.page.section.basicInformation')}
|
||||
divider={false}
|
||||
settingsItem="skills.basic-information"
|
||||
contentClassName="space-y-0"
|
||||
>
|
||||
|
||||
{isNewSkill && (
|
||||
<div className="py-1.5">
|
||||
<span className="typography-ui-label text-foreground">{t('settings.skills.page.field.skillNameLocation')}</span>
|
||||
<span className="typography-meta text-muted-foreground ml-2">{t('settings.skills.page.field.skillNameHint')}</span>
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
<Input
|
||||
value={draftName}
|
||||
onChange={(e) => setDraftName(e.target.value.toLowerCase().replace(/\s+/g, '-'))}
|
||||
placeholder={t('settings.skills.page.field.skillNamePlaceholder')}
|
||||
className="h-7 w-40 px-2"
|
||||
/>
|
||||
<Select
|
||||
value={locationValueFrom(draftScope, draftSource)}
|
||||
onValueChange={(v) => {
|
||||
const next = locationPartsFrom(v as SkillLocationValue);
|
||||
setDraftScope(next.scope);
|
||||
setDraftSource(next.source === 'agents' ? 'agents' : 'opencode');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-fit gap-1.5">
|
||||
{draftScope === 'user' ? (
|
||||
<Icon name="user-3" className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Icon name="folder" className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{draftSource === 'agents' ? <Icon name="robot-2" className="h-3.5 w-3.5" /> : null}
|
||||
<span>{locationLabelText(locationValueFrom(draftScope, draftSource))}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
{SKILL_LOCATION_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{option.scope === 'user' ? <Icon name="user-3" className="h-3.5 w-3.5" /> : <Icon name="folder" className="h-3.5 w-3.5" />}
|
||||
{option.source === 'agents' ? <Icon name="robot-2" className="h-3.5 w-3.5" /> : null}
|
||||
<span>{locationLabelText(option.value)}</span>
|
||||
</div>
|
||||
<span className="typography-micro text-muted-foreground ml-6">{locationDescriptionText(option.value)}</span>
|
||||
<SettingsFieldRow
|
||||
label={t('settings.skills.page.field.skillNameLocation')}
|
||||
info={t('settings.skills.page.field.skillNameHint')}
|
||||
>
|
||||
<Input
|
||||
value={draftName}
|
||||
onChange={(e) => setDraftName(e.target.value.toLowerCase().replace(/\s+/g, '-'))}
|
||||
placeholder={t('settings.skills.page.field.skillNamePlaceholder')}
|
||||
className="h-7 w-40 px-2"
|
||||
/>
|
||||
<Select
|
||||
value={locationValueFrom(draftScope, draftSource)}
|
||||
onValueChange={(v) => {
|
||||
const next = locationPartsFrom(v as SkillLocationValue);
|
||||
setDraftScope(next.scope);
|
||||
setDraftSource(next.source === 'agents' ? 'agents' : 'opencode');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size={SETTINGS_SELECT_SIZE} className="w-fit gap-1.5">
|
||||
{draftScope === 'user' ? (
|
||||
<Icon name="user-3" className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Icon name="folder" className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{draftSource === 'agents' ? <Icon name="robot-2" className="h-3.5 w-3.5" /> : null}
|
||||
<span>{locationLabelText(locationValueFrom(draftScope, draftSource))}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
{SKILL_LOCATION_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{option.scope === 'user' ? <Icon name="user-3" className="h-3.5 w-3.5" /> : <Icon name="folder" className="h-3.5 w-3.5" />}
|
||||
{option.source === 'agents' ? <Icon name="robot-2" className="h-3.5 w-3.5" /> : null}
|
||||
<span>{locationLabelText(option.value)}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<span className="typography-micro text-muted-foreground ml-6">{locationDescriptionText(option.value)}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingsFieldRow>
|
||||
)}
|
||||
|
||||
<div className="py-1.5">
|
||||
<span className="typography-ui-label text-foreground">{t('settings.common.field.description')} <span className="text-[var(--status-error)]">*</span></span>
|
||||
<span className="typography-meta text-muted-foreground ml-2">{t('settings.skills.page.field.descriptionHint')}</span>
|
||||
<div className="mt-1.5">
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => handleDescriptionChange(e.target.value)}
|
||||
placeholder={t('settings.skills.page.field.descriptionPlaceholder')}
|
||||
rows={2}
|
||||
className="w-full resize-none min-h-[60px] max-h-32 bg-transparent"
|
||||
disabled={isReadOnlySkill}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsStackedField
|
||||
label={(
|
||||
<>
|
||||
{t('settings.common.field.description')} <span className="text-[var(--status-error)]">*</span>
|
||||
</>
|
||||
)}
|
||||
info={t('settings.skills.page.field.descriptionHint')}
|
||||
controlClassName="w-full max-w-none"
|
||||
>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => handleDescriptionChange(e.target.value)}
|
||||
placeholder={t('settings.skills.page.field.descriptionPlaceholder')}
|
||||
rows={2}
|
||||
className="w-full resize-none min-h-[60px] max-h-32 bg-transparent"
|
||||
disabled={isReadOnlySkill}
|
||||
/>
|
||||
</SettingsStackedField>
|
||||
|
||||
</section>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Instructions */}
|
||||
<div data-settings-item="skills.instructions" className="mb-8">
|
||||
<div className="mb-1 px-1 flex items-center justify-between gap-2">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
{t('settings.skills.page.section.instructions')}
|
||||
</h3>
|
||||
<SettingsSection
|
||||
title={t('settings.skills.page.section.instructions')}
|
||||
headerAction={(
|
||||
<PreviewToggleButton
|
||||
currentMode={skillEditorMode === 'preview' ? 'preview' : 'edit'}
|
||||
onToggle={() => setSkillEditorMode((mode) => mode === 'preview' ? 'edit' : 'preview')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="px-2 pb-2 pt-0">
|
||||
)}
|
||||
settingsItem="skills.instructions"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden rounded-md border border-[var(--surface-subtle)] bg-background',
|
||||
@@ -639,21 +638,17 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Supporting Files */}
|
||||
<div data-settings-item="skills.supporting-files" className="mb-2">
|
||||
<div className="mb-1 px-1 flex items-center gap-2">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
{t('settings.skills.page.section.supportingFiles')}
|
||||
</h3>
|
||||
<SettingsSection
|
||||
title={t('settings.skills.page.section.supportingFiles')}
|
||||
headerAction={(
|
||||
<Button variant="outline" size="xs" className="!font-normal gap-1" onClick={handleAddFile} disabled={isReadOnlySkill}>
|
||||
<Icon name="add" className="h-3.5 w-3.5" /> {t('settings.skills.page.actions.addFile')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<section className="px-2 pb-2 pt-0">
|
||||
)}
|
||||
settingsItem="skills.supporting-files"
|
||||
>
|
||||
{(() => {
|
||||
const filesToShow = isNewSkill ? pendingFiles : supportingFiles;
|
||||
|
||||
@@ -697,11 +692,9 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</section>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Save action */}
|
||||
<div className="px-2 py-1">
|
||||
<SettingsSection>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isReadOnlySkill || isSaving || !hasSkillChanges}
|
||||
@@ -710,9 +703,9 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
>
|
||||
{isSaving ? t('settings.common.actions.saving') : isNewSkill ? t('settings.skills.page.actions.createSkill') : t('settings.common.actions.saveChanges')}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</SettingsPageLayout>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Add/Edit File Dialog */}
|
||||
<Dialog
|
||||
@@ -764,9 +757,14 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
) : (
|
||||
<div className="space-y-4 flex-1 min-h-0 flex flex-col pt-2">
|
||||
<div className="space-y-2 flex-shrink-0">
|
||||
<label className="typography-ui-label font-medium text-foreground">
|
||||
{t('settings.skills.page.fileDialog.field.filePath')}
|
||||
</label>
|
||||
<div className="flex items-center gap-1">
|
||||
<label className={SETTINGS_FIELD_LABEL_CLASS}>
|
||||
{t('settings.skills.page.fileDialog.field.filePath')}
|
||||
</label>
|
||||
{!editingFilePath && (
|
||||
<SettingsInfoHint>{t('settings.skills.page.fileDialog.field.filePathHint')}</SettingsInfoHint>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
value={newFileName}
|
||||
onChange={(e) => setNewFileName(e.target.value)}
|
||||
@@ -774,14 +772,9 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
className="text-foreground placeholder:text-muted-foreground focus-visible:ring-[var(--primary-base)]"
|
||||
disabled={editingFilePath !== null}
|
||||
/>
|
||||
{!editingFilePath && (
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
{t('settings.skills.page.fileDialog.field.filePathHint')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2 flex-1 min-h-0 flex flex-col">
|
||||
<label className="typography-ui-label font-medium text-foreground flex-shrink-0">
|
||||
<label className={`${SETTINGS_FIELD_LABEL_CLASS} flex-shrink-0`}>
|
||||
{t('settings.skills.page.fileDialog.field.content')}
|
||||
</label>
|
||||
<div className="h-[45vh] min-h-[250px] max-h-[55vh] overflow-hidden rounded-md border border-[var(--surface-subtle)] bg-background">
|
||||
@@ -813,7 +806,8 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</ScrollableOverlay>
|
||||
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import { SettingsProjectSelector } from '@/components/sections/shared/SettingsPr
|
||||
import { SidebarGroup } from '@/components/sections/shared/SidebarGroup';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { SETTINGS_PANEL_TITLE_CLASS } from '@/components/sections/shared/SettingsSection';
|
||||
|
||||
interface SkillsSidebarProps {
|
||||
onItemSelect?: () => void;
|
||||
@@ -232,7 +233,7 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
return (
|
||||
<div className={cn('flex h-full flex-col', bgClass)}>
|
||||
<div className="border-b px-3 pt-4 pb-3">
|
||||
<h2 className="text-base font-semibold text-foreground mb-3">{t('settings.skills.sidebar.title')}</h2>
|
||||
<h2 className={`${SETTINGS_PANEL_TITLE_CLASS} mb-3`}>{t('settings.skills.sidebar.title')}</h2>
|
||||
<SettingsProjectSelector className="mb-3" />
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.skills.sidebar.total', { count: skills.length })}</span>
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
SelectTrigger,
|
||||
} from '@/components/ui/select';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { SettingsInfoHint } from '@/components/sections/shared/SettingsInfoHint';
|
||||
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
@@ -278,7 +279,10 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label text-foreground">{t('settings.skills.catalog.add.field.repository')}</label>
|
||||
<div className="flex items-center gap-1">
|
||||
<label className="typography-ui-label text-foreground">{t('settings.skills.catalog.add.field.repository')}</label>
|
||||
<SettingsInfoHint>{t('settings.skills.catalog.add.field.repositoryHint')}</SettingsInfoHint>
|
||||
</div>
|
||||
<Input
|
||||
value={source}
|
||||
onChange={(e) => {
|
||||
@@ -287,9 +291,6 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
|
||||
}}
|
||||
placeholder={t('settings.skills.catalog.shared.field.repositoryPlaceholder')}
|
||||
/>
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
{t('settings.skills.catalog.add.field.repositoryHint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -306,9 +307,10 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
|
||||
|
||||
{identityOptions.length > 0 && !isVSCodeRuntime() ? (
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<div className="flex items-center">
|
||||
<span className="typography-ui-label text-[var(--status-warning)]">{t('settings.skills.catalog.shared.auth.title')}</span>
|
||||
<span className="typography-meta text-muted-foreground ml-2">{t('settings.skills.catalog.shared.auth.description')}</span>
|
||||
<SettingsInfoHint className="ml-1">{t('settings.skills.catalog.shared.auth.footerHint')}</SettingsInfoHint>
|
||||
</div>
|
||||
<Select
|
||||
value={gitIdentityId || ''}
|
||||
@@ -328,9 +330,6 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
{t('settings.skills.catalog.shared.auth.footerHint')}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -3,7 +3,12 @@ import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
|
||||
import {
|
||||
SettingsSection,
|
||||
SETTINGS_SELECT_SIZE,
|
||||
SETTINGS_SELECT_TRIGGER_CLASS,
|
||||
} from '@/components/sections/shared/SettingsSection';
|
||||
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -161,12 +166,12 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollableOverlay outerClassName="h-full" className="w-full">
|
||||
<div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8">
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-4">
|
||||
{showModeTabs && (
|
||||
<>
|
||||
<SettingsPageLayout
|
||||
title={t('settings.skills.catalog.page.title')}
|
||||
showSaveStatus={false}
|
||||
>
|
||||
{showModeTabs && (
|
||||
<div className="mb-4">
|
||||
<div className="h-10">
|
||||
<SortableTabsStrip
|
||||
@@ -184,22 +189,22 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<h2 className="typography-ui-header font-semibold text-foreground px-1">{t('settings.skills.catalog.page.title')}</h2>
|
||||
</div>
|
||||
|
||||
{/* Source & Search */}
|
||||
<div data-settings-item="skills.catalog.source" className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.skills.catalog.page.section.sourceRepository')}</h3>
|
||||
</div>
|
||||
|
||||
<section className="px-2 pb-2 pt-0 space-y-0">
|
||||
|
||||
|
||||
<SettingsSection
|
||||
title={t('settings.skills.catalog.page.section.sourceRepository')}
|
||||
divider={false}
|
||||
settingsItem="skills.catalog.source"
|
||||
contentClassName="space-y-0"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2 py-1.5">
|
||||
<Select
|
||||
value={selectedSourceId || ''}
|
||||
onValueChange={(v) => setSelectedSource(v)}
|
||||
>
|
||||
<SelectTrigger className="w-fit">
|
||||
<SelectTrigger size={SETTINGS_SELECT_SIZE} className={cn(SETTINGS_SELECT_TRIGGER_CLASS, 'w-fit')}>
|
||||
<SelectValue placeholder={t('settings.skills.catalog.page.field.selectSourcePlaceholder')}>
|
||||
{selectedSource?.label}
|
||||
</SelectValue>
|
||||
@@ -269,20 +274,18 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
: t('settings.skills.catalog.page.foundCount', { count: filtered.length })}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Error State */}
|
||||
{lastCatalogError && (
|
||||
<div className="mb-8 rounded-lg border border-[var(--status-error-border)] bg-[var(--status-error-background)] px-4 py-3">
|
||||
<div className="typography-ui-label font-medium text-[var(--status-error)]">{t('settings.skills.catalog.page.error.catalogTitle')}</div>
|
||||
<div className="typography-meta text-[var(--status-error)]/80 mt-1">{lastCatalogError.message}</div>
|
||||
</div>
|
||||
<SettingsSection>
|
||||
<div className="rounded-lg border border-[var(--status-error-border)] bg-[var(--status-error-background)] px-4 py-3">
|
||||
<div className="typography-ui-label font-medium text-[var(--status-error)]">{t('settings.skills.catalog.page.error.catalogTitle')}</div>
|
||||
<div className="typography-meta text-[var(--status-error)]/80 mt-1">{lastCatalogError.message}</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{/* Skills List */}
|
||||
<div className="mb-8">
|
||||
<section className="px-2 pb-2 pt-0">
|
||||
<SettingsSection>
|
||||
{filtered.length === 0 && !isLoadingSource ? (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
<p className="typography-body">{t('settings.skills.catalog.page.empty.noSkillsTitle')}</p>
|
||||
@@ -367,10 +370,8 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{isClawdHubSource && hasMoreClawdHub && !isLoadingSource && filtered.length > 0 && (
|
||||
<div className="flex justify-center mt-2 px-2">
|
||||
<div className="flex justify-center mt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
@@ -382,7 +383,8 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</SettingsPageLayout>
|
||||
|
||||
{/* Dialogs */}
|
||||
<AddCatalogDialog open={addCatalogOpen} onOpenChange={setAddCatalogOpen} />
|
||||
@@ -417,7 +419,6 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
</div>
|
||||
</ScrollableOverlay>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,10 +5,17 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import { toast } from '@/components/ui';
|
||||
import { useSnippetsStore, type SnippetScope } from '@/stores/useSnippetsStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsFieldRow,
|
||||
SettingsStackedField,
|
||||
SETTINGS_SELECT_SIZE,
|
||||
} from '@/components/sections/shared/SettingsSection';
|
||||
import { SettingsInfoHint } from '@/components/sections/shared/SettingsInfoHint';
|
||||
|
||||
export const SnippetsPage: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
@@ -117,53 +124,61 @@ export const SnippetsPage: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollableOverlay outerClassName="h-full" className="w-full">
|
||||
<div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8">
|
||||
<div className="mb-4 min-w-0">
|
||||
<h2 className="typography-ui-header font-semibold text-foreground truncate">
|
||||
{isNew ? t('settings.snippets.page.title.new') : `#${selectedSnippetName}`}
|
||||
</h2>
|
||||
{selectedSnippet ? <p className="typography-meta text-muted-foreground truncate">{selectedSnippet.filePath}</p> : null}
|
||||
</div>
|
||||
<SettingsPageLayout
|
||||
title={isNew ? t('settings.snippets.page.title.new') : `#${selectedSnippetName}`}
|
||||
description={selectedSnippet ? selectedSnippet.filePath : undefined}
|
||||
showSaveStatus={false}
|
||||
>
|
||||
<SettingsSection divider={false} contentClassName="space-y-0">
|
||||
{isNew ? (
|
||||
<SettingsFieldRow label="#">
|
||||
<Input
|
||||
value={draftName}
|
||||
onChange={(e) => setDraftName(e.target.value)}
|
||||
placeholder={t('settings.snippets.page.field.namePlaceholder')}
|
||||
className="h-7 w-44 px-2"
|
||||
/>
|
||||
<Select value={draftScope} onValueChange={(value) => setDraftScope(value as SnippetScope)}>
|
||||
<SelectTrigger size={SETTINGS_SELECT_SIZE} className="w-fit min-w-[100px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectItem value="global">{t('settings.common.scope.global')}</SelectItem>
|
||||
<SelectItem value="project">{t('settings.common.scope.project')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingsFieldRow>
|
||||
) : null}
|
||||
<SettingsStackedField label={t('settings.common.field.description')}>
|
||||
<Input
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={t('settings.snippets.page.field.descriptionPlaceholder')}
|
||||
className="h-7 w-full max-w-sm px-2"
|
||||
/>
|
||||
</SettingsStackedField>
|
||||
<SettingsStackedField label={t('settings.snippets.page.field.aliases')}>
|
||||
<Input
|
||||
value={aliases}
|
||||
onChange={(e) => setAliases(e.target.value)}
|
||||
placeholder={t('settings.snippets.page.field.aliasesPlaceholder')}
|
||||
className="h-7 w-full max-w-sm px-2"
|
||||
/>
|
||||
</SettingsStackedField>
|
||||
</SettingsSection>
|
||||
|
||||
<div className="mb-8 space-y-3 px-2">
|
||||
<div>
|
||||
{isNew ? (
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<span className="typography-ui-label text-foreground">#</span>
|
||||
<Input value={draftName} onChange={(e) => setDraftName(e.target.value)} placeholder={t('settings.snippets.page.field.namePlaceholder')} className="h-7 w-44 px-2" />
|
||||
<Select value={draftScope} onValueChange={(value) => setDraftScope(value as SnippetScope)}>
|
||||
<SelectTrigger className="w-fit min-w-[100px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectItem value="global">{t('settings.common.scope.global')}</SelectItem>
|
||||
<SelectItem value="project">{t('settings.common.scope.project')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : null}
|
||||
<span className="typography-ui-label text-foreground">{t('settings.common.field.description')}</span>
|
||||
<Input value={description} onChange={(e) => setDescription(e.target.value)} placeholder={t('settings.snippets.page.field.descriptionPlaceholder')} className="mt-1.5 h-7 w-full max-w-sm px-2" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.snippets.page.field.aliases')}</span>
|
||||
<Input value={aliases} onChange={(e) => setAliases(e.target.value)} placeholder={t('settings.snippets.page.field.aliasesPlaceholder')} className="mt-1.5 h-7 w-full max-w-sm px-2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-settings-item="snippets.content" className="mb-2 px-2">
|
||||
<SettingsSection settingsItem="snippets.content">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="typography-ui-label text-foreground">{t('settings.snippets.page.field.content')}</span>
|
||||
<Textarea value={content} onChange={(e) => setContent(e.target.value)} placeholder={t('settings.snippets.page.field.contentPlaceholder')} rows={12} className="mt-1.5 w-full font-mono typography-meta min-h-[160px] max-h-[60vh] bg-transparent" />
|
||||
<p className="mt-2 typography-meta text-muted-foreground">{t('settings.snippets.page.hint')}</p>
|
||||
<SettingsInfoHint>{t('settings.snippets.page.hint')}</SettingsInfoHint>
|
||||
</div>
|
||||
|
||||
<div className="px-2 py-1">
|
||||
<Textarea value={content} onChange={(e) => setContent(e.target.value)} placeholder={t('settings.snippets.page.field.contentPlaceholder')} rows={12} className="mt-1.5 w-full font-mono typography-meta min-h-[160px] max-h-[60vh] bg-transparent" />
|
||||
<div className="pt-3">
|
||||
<Button onClick={handleSave} disabled={isSaving || !isDirty} size="xs" className="!font-normal">
|
||||
{isSaving ? t('settings.common.actions.saving') : t('settings.common.actions.saveChanges')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollableOverlay>
|
||||
</SettingsSection>
|
||||
</SettingsPageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ import { cn } from '@/lib/utils';
|
||||
import type { Snippet } from '@/types/snippet';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { SETTINGS_PANEL_TITLE_CLASS } from '@/components/sections/shared/SettingsSection';
|
||||
|
||||
interface SnippetsSidebarProps {
|
||||
onItemSelect?: () => void;
|
||||
@@ -62,7 +63,7 @@ export const SnippetsSidebar: React.FC<SnippetsSidebarProps> = ({ onItemSelect }
|
||||
return (
|
||||
<div className={cn('flex h-full flex-col', 'bg-background')}>
|
||||
<div className="border-b px-3 pt-4 pb-3">
|
||||
<h2 className="text-base font-semibold text-foreground mb-3">{t('settings.snippets.sidebar.title')}</h2>
|
||||
<h2 className={`${SETTINGS_PANEL_TITLE_CLASS} mb-3`}>{t('settings.snippets.sidebar.title')}</h2>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.snippets.sidebar.total', { count: snippets.length })}</span>
|
||||
<Button size="sm" data-settings-item="snippets.create" variant="ghost" className="h-7 w-7 px-0 -my-1 text-muted-foreground" onClick={handleCreateNew} aria-label={t('settings.snippets.sidebar.actions.create')}>
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ export const QuotaCredentials: React.FC<{ providerId: ProviderId; providerName:
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
const field = (name: string, label: string, placeholder: string) => <label className="block typography-ui-label text-foreground">{label}<Input className="mt-1 h-7 font-mono text-xs" type={name === 'workspaceId' ? 'text' : 'password'} autoComplete="off" value={values[name] ?? ''} onChange={(event) => setValues((current) => ({ ...current, [name]: event.target.value }))} placeholder={status?.secretMasked ?? placeholder} /></label>;
|
||||
return <div data-settings-item={`providers.${providerId}-credentials`} className="mb-8">
|
||||
return <div data-settings-item={`usage.${providerId}-credentials`} className="mb-8">
|
||||
<div className="mb-1 px-1"><h3 className="typography-ui-header font-medium text-foreground">{providerName}</h3></div>
|
||||
<section className="space-y-3 px-2 pb-2 pt-0">
|
||||
{providerId === 'opencode-go' && field('workspaceId', t('settings.providers.page.openCodeGo.workspaceId'), 'wrk_...')}
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { UsageCard } from './UsageCard';
|
||||
import { QuotaCredentials } from './QuotaCredentials';
|
||||
import { QUOTA_PROVIDERS } from '@/lib/quota';
|
||||
import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
@@ -13,11 +12,15 @@ import {
|
||||
} from '@/components/ui/collapsible';
|
||||
import type { UsageWindows, QuotaProviderId } from '@/types';
|
||||
import { getAllModelFamilies, getDisplayModelName, sortModelFamilies, groupModelsByFamilyWithGetter } from '@/lib/quota/model-families';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { formatTimeForPreference } from '@/lib/timeFormat';
|
||||
import { useUIStore, type TimeFormatPreference } from '@/stores/useUIStore';
|
||||
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsCheckboxRow,
|
||||
} from '@/components/sections/shared/SettingsSection';
|
||||
|
||||
const formatTime = (timestamp: number | null, timeFormatPreference: TimeFormatPreference) => {
|
||||
if (!timestamp) return '-';
|
||||
@@ -74,6 +77,7 @@ export const UsagePage: React.FC = () => {
|
||||
const providerName = providerMeta?.name ?? selectedProviderId ?? t('settings.usage.sidebar.title');
|
||||
const usage = selectedResult?.usage;
|
||||
const showInDropdown = selectedProviderId ? dropdownProviderIds.includes(selectedProviderId) : false;
|
||||
const hasCredentialsForm = selectedProviderId === 'opencode-go' || selectedProviderId === 'ollama-cloud' || selectedProviderId === 'cursor';
|
||||
const handleDropdownToggle = React.useCallback((enabled: boolean) => {
|
||||
if (!selectedProviderId) {
|
||||
return;
|
||||
@@ -149,222 +153,181 @@ export const UsagePage: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollableOverlay outerClassName="h-full" className="w-full">
|
||||
<div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8">
|
||||
<SettingsPageLayout
|
||||
title={t('settings.usage.page.header.providerUsage', { provider: providerName })}
|
||||
titleLeading={<ProviderLogo providerId={selectedProviderId} className="h-5 w-5 shrink-0" />}
|
||||
description={
|
||||
isLoading ? (
|
||||
<span className="animate-pulse typography-settings-description text-muted-foreground">{t('settings.usage.page.header.refreshing')}</span>
|
||||
) : (
|
||||
t('settings.usage.page.header.lastUpdated', { time: formatTime(lastUpdated, timeFormatPreference) })
|
||||
)
|
||||
}
|
||||
showSaveStatus
|
||||
>
|
||||
<SettingsSection divider={false} settingsItem="usage.header-menu">
|
||||
<SettingsCheckboxRow
|
||||
checked={showInDropdown}
|
||||
onChange={handleDropdownToggle}
|
||||
label={t('settings.usage.page.options.showInHeader')}
|
||||
ariaLabel={t('settings.usage.page.options.showInHeaderAria')}
|
||||
info={t('settings.usage.page.options.showInHeaderTooltip')}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<ProviderLogo providerId={selectedProviderId} className="h-5 w-5 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<h2 className="typography-ui-header font-semibold text-foreground truncate">
|
||||
{t('settings.usage.page.header.providerUsage', { provider: providerName })}
|
||||
</h2>
|
||||
<p className="typography-meta text-muted-foreground truncate">
|
||||
{isLoading ? (
|
||||
<span className="animate-pulse">{t('settings.usage.page.header.refreshing')}</span>
|
||||
) : (
|
||||
t('settings.usage.page.header.lastUpdated', { time: formatTime(lastUpdated, timeFormatPreference) })
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{!selectedResult && (
|
||||
<p className="typography-ui-label text-foreground pb-8">{t('settings.usage.page.state.noData')}</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mb-8 rounded-lg border border-[var(--status-error-border)] bg-[var(--status-error-background)] px-4 py-3">
|
||||
<p className="typography-ui-label font-medium text-[var(--status-error)]">{t('settings.usage.page.state.refreshFailedTitle')}</p>
|
||||
<p className="typography-meta text-[var(--status-error)]/80 mt-1">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Options */}
|
||||
<div data-settings-item="usage.header-menu" className="mb-8 px-2">
|
||||
<div
|
||||
className="group flex cursor-pointer items-center gap-2 py-1.5"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={showInDropdown}
|
||||
onClick={() => handleDropdownToggle(!showInDropdown)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
handleDropdownToggle(!showInDropdown);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={showInDropdown}
|
||||
onChange={handleDropdownToggle}
|
||||
ariaLabel={t('settings.usage.page.options.showInHeaderAria')}
|
||||
/>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="typography-ui-label text-foreground">{t('settings.usage.page.options.showInHeader')}</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
{t('settings.usage.page.options.showInHeaderTooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{/* Providers with an inline credentials form don't need the "go to Providers" banner — the form IS the fix. */}
|
||||
{selectedResult && !selectedResult.configured && !hasCredentialsForm && (
|
||||
<div className="mb-8 rounded-lg border border-[var(--status-warning-border)] bg-[var(--status-warning-background)] px-4 py-3">
|
||||
<p className="typography-ui-label font-medium text-[var(--status-warning)]">{t('settings.usage.page.state.providerNotConfiguredTitle')}</p>
|
||||
<p className="typography-meta text-[var(--status-warning)]/80 mt-1">
|
||||
{t('settings.usage.page.state.providerNotConfiguredDescription')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* State Messages */}
|
||||
{!selectedResult && (
|
||||
<div className="mb-8 px-2">
|
||||
<p className="typography-ui-label text-foreground">{t('settings.usage.page.state.noData')}</p>
|
||||
{(selectedProviderId === 'opencode-go' || selectedProviderId === 'ollama-cloud' || selectedProviderId === 'cursor') && (
|
||||
<QuotaCredentials providerId={selectedProviderId} providerName={providerName} />
|
||||
)}
|
||||
|
||||
{usage?.windows && Object.keys(usage.windows).length > 0 && (
|
||||
<SettingsSection settingsItem="usage.model-quotas">
|
||||
<div className="divide-y divide-[var(--surface-subtle)]">
|
||||
{Object.entries(usage.windows).map(([label, window]) => (
|
||||
<UsageCard key={label} title={label} window={window} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mb-8 rounded-lg border border-[var(--status-error-border)] bg-[var(--status-error-background)] px-4 py-3">
|
||||
<p className="typography-ui-label font-medium text-[var(--status-error)]">{t('settings.usage.page.state.refreshFailedTitle')}</p>
|
||||
<p className="typography-meta text-[var(--status-error)]/80 mt-1">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
{providerModels.length > 0 && (
|
||||
<SettingsSection
|
||||
title={t('settings.usage.page.section.modelQuotas')}
|
||||
contentClassName="space-y-3"
|
||||
>
|
||||
{sortedFamilies.map((family) => {
|
||||
const familyModels = modelsByFamily.get(family.id) ?? [];
|
||||
if (familyModels.length === 0) return null;
|
||||
|
||||
{selectedResult && !selectedResult.configured && (
|
||||
<div className="mb-8 rounded-lg border border-[var(--status-warning-border)] bg-[var(--status-warning-background)] px-4 py-3">
|
||||
<p className="typography-ui-label font-medium text-[var(--status-warning)]">{t('settings.usage.page.state.providerNotConfiguredTitle')}</p>
|
||||
<p className="typography-meta text-[var(--status-warning)]/80 mt-1">
|
||||
{t('settings.usage.page.state.providerNotConfiguredDescription')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
const isCollapsed = collapsedFamilies[family.id] ?? false;
|
||||
|
||||
{/* Overall Usage Windows */}
|
||||
{usage?.windows && Object.keys(usage.windows).length > 0 && (
|
||||
<div data-settings-item="usage.model-quotas" className="mb-8">
|
||||
<section className="px-2 pb-2 pt-0">
|
||||
<div className="divide-y divide-[var(--surface-subtle)]">
|
||||
{Object.entries(usage.windows).map(([label, window]) => (
|
||||
<UsageCard key={label} title={label} window={window} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
return (
|
||||
<section key={family.id} className="p-2">
|
||||
<Collapsible
|
||||
open={!isCollapsed}
|
||||
onOpenChange={() => toggleFamilyCollapsed(family.id)}
|
||||
>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between py-0.5 group">
|
||||
<div className="flex items-center gap-1.5 text-left">
|
||||
<span className="typography-ui-label font-normal text-foreground">{family.label}</span>
|
||||
<span className="typography-micro text-muted-foreground">
|
||||
({familyModels.length})
|
||||
</span>
|
||||
</div>
|
||||
{isCollapsed ? (
|
||||
<Icon name="arrow-right-s" className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||
) : (
|
||||
<Icon name="arrow-down-s" className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="divide-y divide-[var(--surface-subtle)] mt-1">
|
||||
{familyModels.map((model) => {
|
||||
const entries = Object.entries(model.windows.windows);
|
||||
if (entries.length === 0) return null;
|
||||
const [label, window] = entries[0];
|
||||
const isSelected = providerSelectedModels.includes(model.name);
|
||||
|
||||
{/* Models Section */}
|
||||
{providerModels.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.usage.page.section.modelQuotas')}</h3>
|
||||
</div>
|
||||
return (
|
||||
<UsageCard
|
||||
key={model.name}
|
||||
title={label}
|
||||
subtitle={getDisplayModelName(model.name)}
|
||||
window={window}
|
||||
showToggle
|
||||
toggleEnabled={isSelected}
|
||||
onToggle={() => handleModelToggle(model.name)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Predefined families */}
|
||||
{sortedFamilies.map((family) => {
|
||||
const familyModels = modelsByFamily.get(family.id) ?? [];
|
||||
if (familyModels.length === 0) return null;
|
||||
{(() => {
|
||||
const otherModels = modelsByFamily.get(null) ?? [];
|
||||
if (otherModels.length === 0) return null;
|
||||
|
||||
const isCollapsed = collapsedFamilies[family.id] ?? false;
|
||||
const isCollapsed = collapsedFamilies['other'] ?? false;
|
||||
|
||||
return (
|
||||
<section key={family.id} className="p-2">
|
||||
<Collapsible
|
||||
open={!isCollapsed}
|
||||
onOpenChange={() => toggleFamilyCollapsed(family.id)}
|
||||
>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between py-0.5 group">
|
||||
<div className="flex items-center gap-1.5 text-left">
|
||||
<span className="typography-ui-label font-normal text-foreground">{family.label}</span>
|
||||
<span className="typography-micro text-muted-foreground">
|
||||
({familyModels.length})
|
||||
</span>
|
||||
</div>
|
||||
{isCollapsed ? (
|
||||
<Icon name="arrow-right-s" className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||
) : (
|
||||
<Icon name="arrow-down-s" className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="divide-y divide-[var(--surface-subtle)] mt-1">
|
||||
{familyModels.map((model) => {
|
||||
const entries = Object.entries(model.windows.windows);
|
||||
if (entries.length === 0) return null;
|
||||
const [label, window] = entries[0];
|
||||
const isSelected = providerSelectedModels.includes(model.name);
|
||||
return (
|
||||
<section className="p-2">
|
||||
<Collapsible
|
||||
open={!isCollapsed}
|
||||
onOpenChange={() => toggleFamilyCollapsed('other')}
|
||||
>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between py-0.5 group">
|
||||
<div className="flex items-center gap-1.5 text-left">
|
||||
<span className="typography-ui-label font-normal text-foreground">{t('settings.usage.page.section.otherModels')}</span>
|
||||
<span className="typography-micro text-muted-foreground">
|
||||
({otherModels.length})
|
||||
</span>
|
||||
</div>
|
||||
{isCollapsed ? (
|
||||
<Icon name="arrow-right-s" className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||
) : (
|
||||
<Icon name="arrow-down-s" className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="divide-y divide-[var(--surface-subtle)] mt-1">
|
||||
{otherModels.map((model) => {
|
||||
const entries = Object.entries(model.windows.windows);
|
||||
if (entries.length === 0) return null;
|
||||
const [label, window] = entries[0];
|
||||
const isSelected = providerSelectedModels.includes(model.name);
|
||||
|
||||
return (
|
||||
<UsageCard
|
||||
key={model.name}
|
||||
title={label}
|
||||
subtitle={getDisplayModelName(model.name)}
|
||||
window={window}
|
||||
showToggle
|
||||
toggleEnabled={isSelected}
|
||||
onToggle={() => handleModelToggle(model.name)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
return (
|
||||
<UsageCard
|
||||
key={model.name}
|
||||
title={label}
|
||||
subtitle={getDisplayModelName(model.name)}
|
||||
window={window}
|
||||
showToggle
|
||||
toggleEnabled={isSelected}
|
||||
onToggle={() => handleModelToggle(model.name)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</section>
|
||||
);
|
||||
})()}
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{/* Other family */}
|
||||
{(() => {
|
||||
const otherModels = modelsByFamily.get(null) ?? [];
|
||||
if (otherModels.length === 0) return null;
|
||||
|
||||
const isCollapsed = collapsedFamilies['other'] ?? false;
|
||||
|
||||
return (
|
||||
<section className="p-2">
|
||||
<Collapsible
|
||||
open={!isCollapsed}
|
||||
onOpenChange={() => toggleFamilyCollapsed('other')}
|
||||
>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between py-0.5 group">
|
||||
<div className="flex items-center gap-1.5 text-left">
|
||||
<span className="typography-ui-label font-normal text-foreground">{t('settings.usage.page.section.otherModels')}</span>
|
||||
<span className="typography-micro text-muted-foreground">
|
||||
({otherModels.length})
|
||||
</span>
|
||||
</div>
|
||||
{isCollapsed ? (
|
||||
<Icon name="arrow-right-s" className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||
) : (
|
||||
<Icon name="arrow-down-s" className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="divide-y divide-[var(--surface-subtle)] mt-1">
|
||||
{otherModels.map((model) => {
|
||||
const entries = Object.entries(model.windows.windows);
|
||||
if (entries.length === 0) return null;
|
||||
const [label, window] = entries[0];
|
||||
const isSelected = providerSelectedModels.includes(model.name);
|
||||
|
||||
return (
|
||||
<UsageCard
|
||||
key={model.name}
|
||||
title={label}
|
||||
subtitle={getDisplayModelName(model.name)}
|
||||
window={window}
|
||||
showToggle
|
||||
toggleEnabled={isSelected}
|
||||
onToggle={() => handleModelToggle(model.name)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</section>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedResult?.configured && usage && Object.keys(usage.windows ?? {}).length === 0 &&
|
||||
providerModels.length === 0 && (
|
||||
<div className="mb-8 px-2">
|
||||
<p className="typography-ui-label text-foreground">{t('settings.usage.page.state.noQuotaWindowsTitle')}</p>
|
||||
<p className="typography-meta text-muted-foreground mt-1">{t('settings.usage.page.state.noQuotaWindowsDescription')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</ScrollableOverlay>
|
||||
{selectedResult?.configured && usage && Object.keys(usage.windows ?? {}).length === 0 &&
|
||||
providerModels.length === 0 && (
|
||||
<div className="pb-8">
|
||||
<p className="typography-ui-label text-foreground">{t('settings.usage.page.state.noQuotaWindowsTitle')}</p>
|
||||
<p className="typography-meta text-muted-foreground mt-1">{t('settings.usage.page.state.noQuotaWindowsDescription')}</p>
|
||||
</div>
|
||||
)}
|
||||
</SettingsPageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ import { QUOTA_PROVIDERS, resolveUsageTone } from '@/lib/quota';
|
||||
import { useQuotaStore } from '@/stores/useQuotaStore';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { SETTINGS_PANEL_TITLE_CLASS } from '@/components/sections/shared/SettingsSection';
|
||||
|
||||
interface UsageSidebarProps {
|
||||
onItemSelect?: () => void;
|
||||
@@ -88,7 +89,7 @@ export const UsageSidebar: React.FC<UsageSidebarProps> = ({ onItemSelect }) => {
|
||||
return (
|
||||
<div className={cn('flex h-full flex-col', bgClass)}>
|
||||
<div className="border-b px-3 pt-4 pb-3">
|
||||
<h2 className="text-base font-semibold text-foreground mb-3">{t('settings.usage.sidebar.title')}</h2>
|
||||
<h2 className={`${SETTINGS_PANEL_TITLE_CLASS} mb-3`}>{t('settings.usage.sidebar.title')}</h2>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.usage.sidebar.total', { count: QUOTA_PROVIDERS.length })}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
CommandSeparator,
|
||||
} from '@/components/ui/command';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
@@ -1584,12 +1585,15 @@ export function NewWorktreeDialog({
|
||||
<div className="flex items-center gap-2">
|
||||
<DropdownMenu open={existingBranchDropdownOpen} onOpenChange={setExistingBranchDropdownOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-9 min-w-[220px] max-w-full justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(dropdownTriggerVariants({ size: 'default' }), 'min-w-[220px] max-w-full')}
|
||||
>
|
||||
<span className={cn('truncate', existingBranchState.selectedBranch ? 'text-foreground' : 'text-muted-foreground')}>
|
||||
{existingBranchState.selectedBranch || t('session.newWorktree.chooseBranch')}
|
||||
</span>
|
||||
<Icon name="arrow-down-s" className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</Button>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" sideOffset={6} portalToBody className="w-[min(42rem,calc(100vw-2rem))] p-0 max-h-[min(var(--available-height),24rem)] flex flex-col overflow-hidden" ref={existingBranchDropdownContentRef}>
|
||||
<Command shouldFilter={false}>
|
||||
@@ -1825,12 +1829,15 @@ export function NewWorktreeDialog({
|
||||
</label>
|
||||
<DropdownMenu open={sourceBranchDropdownOpen} onOpenChange={setSourceBranchDropdownOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-9 min-w-[220px] max-w-full justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(dropdownTriggerVariants({ size: 'default' }), 'min-w-[220px] max-w-full')}
|
||||
>
|
||||
<span className={cn('truncate', newBranchState.sourceBranch ? 'text-foreground' : 'text-muted-foreground')}>
|
||||
{newBranchState.sourceBranch || t('session.newWorktree.selectSourceBranchPlaceholder')}
|
||||
</span>
|
||||
<Icon name="arrow-down-s" className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</Button>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" portalToBody className="w-[min(42rem,calc(100vw-2rem))] p-0 max-h-[min(var(--available-height),24rem)] flex flex-col overflow-hidden" ref={sourceBranchDropdownContentRef}>
|
||||
<Command shouldFilter={false}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger';
|
||||
import { ScrollShadow } from '@/components/ui/ScrollShadow';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
@@ -701,7 +702,7 @@ const CronScheduleSection: React.FC<{
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-fit max-w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectTrigger size="lg" className="w-fit max-w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{TIMEZONE_OPTIONS.map((timezone) => (
|
||||
<SelectItem key={timezone} value={timezone}>{timezone}</SelectItem>
|
||||
@@ -1226,7 +1227,7 @@ export function ScheduledTaskEditorDialog(props: {
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-fit max-w-full">
|
||||
<SelectTrigger size="lg" className="w-fit max-w-full">
|
||||
<SelectValue>
|
||||
{(value) => value === 'daily'
|
||||
? t('sessions.scheduledTasks.editor.scheduleType.daily')
|
||||
@@ -1257,7 +1258,7 @@ export function ScheduledTaskEditorDialog(props: {
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-9 w-fit max-w-full items-center justify-between gap-2 rounded-md border border-border bg-background px-3 text-left hover:bg-interactive-hover"
|
||||
className={cn(dropdownTriggerVariants({ size: 'default' }), 'w-fit max-w-full')}
|
||||
onClick={() => setIsDatePickerOpen((prev) => !prev)}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
@@ -1386,7 +1387,7 @@ export function ScheduledTaskEditorDialog(props: {
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-fit max-w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectTrigger size="lg" className="w-fit max-w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{TIMEZONE_OPTIONS.map((timezone) => (
|
||||
<SelectItem key={timezone} value={timezone}>{timezone}</SelectItem>
|
||||
@@ -1474,7 +1475,7 @@ export function ScheduledTaskEditorDialog(props: {
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-fit max-w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectTrigger size="lg" className="w-fit max-w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{TIMEZONE_OPTIONS.map((timezone) => (
|
||||
<SelectItem key={timezone} value={timezone}>{timezone}</SelectItem>
|
||||
@@ -1520,7 +1521,7 @@ export function ScheduledTaskEditorDialog(props: {
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-fit max-w-full">
|
||||
<SelectTrigger size="lg" className="w-fit max-w-full">
|
||||
<SelectValue>
|
||||
{(value) => value === '__default'
|
||||
? t('sessions.scheduledTasks.editor.thinkingLevel.default')
|
||||
|
||||
@@ -387,7 +387,7 @@ export function ScheduledTasksDialog() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className={isMobile ? 'w-full' : undefined}>
|
||||
<SelectTrigger size="lg" className={isMobile ? 'w-full' : undefined}>
|
||||
{selectedProject ? (
|
||||
<SelectValue>{renderProjectLabel(selectedProject)}</SelectValue>
|
||||
) : (
|
||||
@@ -414,7 +414,7 @@ export function ScheduledTasksDialog() {
|
||||
const tasksContent = (
|
||||
<div className="space-y-4">
|
||||
{!isMobile ? (
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
{projectSelector}
|
||||
<Button onClick={openNewTaskEditor} disabled={!selectedProjectID}>
|
||||
<Icon name="add" className="mr-1 h-4 w-4" /> {t('sessions.scheduledTasks.dialog.actions.newTask')}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
/**
|
||||
* Single source of truth for every dropdown-style trigger surface in the app:
|
||||
* native Select triggers, DropdownMenu trigger buttons, and custom pickers
|
||||
* (model/agent/project selectors, git branch pickers, …).
|
||||
*
|
||||
* Change shape, border, radius, or interaction colors HERE and every trigger
|
||||
* in every surface follows. Do not re-declare these classes at call sites —
|
||||
* call sites may only add layout (width/min-width/max-width, truncation).
|
||||
*
|
||||
* Sizes:
|
||||
* - `sm` — dense surfaces: chat composer, toolbars, list rows (h-6).
|
||||
* - `default` — forms, dialogs, and settings pages (h-8).
|
||||
*/
|
||||
export const dropdownTriggerVariants = cva(
|
||||
[
|
||||
'border-input flex items-center justify-between gap-2 rounded-md border bg-transparent',
|
||||
'typography-ui-label whitespace-nowrap shadow-none outline-none text-left',
|
||||
'hover:bg-interactive-hover data-[popup-open]:bg-interactive-active',
|
||||
'focus-visible:outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
].join(' '),
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
sm: "h-6 min-h-6 px-2 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
default: "h-8 min-h-8 px-3 [&_svg:not([class*='size-'])]:size-4",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export type DropdownTriggerVariantProps = VariantProps<typeof dropdownTriggerVariants>;
|
||||
export type DropdownTriggerSize = NonNullable<DropdownTriggerVariantProps['size']>;
|
||||
@@ -59,7 +59,7 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
|
||||
ref
|
||||
) => {
|
||||
const { t } = useI18n()
|
||||
const [draft, setDraft] = React.useState(() => (value === undefined ? '' : String(value)))
|
||||
const [draft, setDraft] = React.useState(() => (value == null ? '' : String(value)))
|
||||
const { isMobile } = useDeviceInfo()
|
||||
const ignoreNextClickRef = React.useRef(false)
|
||||
const swallowNextClickCleanupRef = React.useRef<(() => void) | null>(null)
|
||||
@@ -100,7 +100,7 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
setDraft(value === undefined ? '' : String(value))
|
||||
setDraft(value == null ? '' : String(value))
|
||||
}, [value])
|
||||
|
||||
const baseValue = React.useMemo(() => {
|
||||
@@ -158,7 +158,7 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
|
||||
(event: React.FocusEvent<HTMLInputElement>) => {
|
||||
if (draft.trim() === '') {
|
||||
if (!onClear) {
|
||||
setDraft(value === undefined ? '' : String(value))
|
||||
setDraft(value == null ? '' : String(value))
|
||||
}
|
||||
onBlur?.(event)
|
||||
return
|
||||
@@ -166,7 +166,7 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
|
||||
|
||||
const parsed = Number(draft)
|
||||
if (!Number.isFinite(parsed)) {
|
||||
setDraft(value === undefined ? '' : String(value))
|
||||
setDraft(value == null ? '' : String(value))
|
||||
} else {
|
||||
const clamped = clamp(parsed, min, max)
|
||||
const normalized = normalizeToStep(clamped, step)
|
||||
@@ -228,7 +228,7 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
|
||||
<div
|
||||
className={cn(
|
||||
// NOTE: mobile.css enforces min-height:36px on buttons; match it to avoid clipping.
|
||||
"flex h-9 shrink-0 items-stretch overflow-x-hidden overflow-y-hidden rounded-lg border border-border bg-transparent select-none overscroll-contain",
|
||||
"flex h-8 shrink-0 items-stretch overflow-x-hidden overflow-y-hidden rounded-lg border border-border bg-transparent select-none overscroll-contain",
|
||||
"[-webkit-user-select:none] [-webkit-touch-callout:none]",
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
containerClassName
|
||||
@@ -241,7 +241,7 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
|
||||
onTouchStart={handleMobileTouchActivate(handleMobileDecrement)}
|
||||
onClick={handleMobileClickActivate(handleMobileDecrement)}
|
||||
className={cn(
|
||||
"grid h-full min-h-0 w-9 place-items-center overflow-x-hidden overflow-y-hidden border-r border-border p-0 leading-none touch-none",
|
||||
"grid h-full min-h-0 w-9 shrink-0 place-items-center overflow-x-hidden overflow-y-hidden border-r border-border p-0 leading-none touch-none",
|
||||
"text-muted-foreground",
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
!decrementDisabled && "active:bg-interactive-hover"
|
||||
@@ -252,13 +252,13 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full min-w-0 w-14 items-center justify-center bg-transparent px-1.5",
|
||||
"flex h-full min-w-0 flex-1 items-center justify-center bg-transparent px-1.5",
|
||||
"text-center text-[16px] leading-none text-foreground [font-variant-numeric:tabular-nums]",
|
||||
className
|
||||
)}
|
||||
aria-live="polite"
|
||||
>
|
||||
{value === undefined ? emptyLabel : draft}
|
||||
{value == null ? emptyLabel : draft}
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -268,7 +268,7 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
|
||||
onTouchStart={handleMobileTouchActivate(handleMobileIncrement)}
|
||||
onClick={handleMobileClickActivate(handleMobileIncrement)}
|
||||
className={cn(
|
||||
"grid h-full min-h-0 w-9 place-items-center overflow-x-hidden overflow-y-hidden border-l border-border p-0 leading-none touch-none",
|
||||
"grid h-full min-h-0 w-9 shrink-0 place-items-center overflow-x-hidden overflow-y-hidden border-l border-border p-0 leading-none touch-none",
|
||||
"text-muted-foreground",
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
!incrementDisabled && "active:bg-interactive-hover"
|
||||
@@ -283,8 +283,9 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-7 shrink-0 items-stretch overflow-x-hidden overflow-y-hidden rounded-lg border border-border bg-transparent",
|
||||
"flex h-8 shrink-0 items-stretch overflow-x-hidden overflow-y-hidden rounded-md border border-border bg-transparent",
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
"transition-[background-color,border-color,box-shadow] duration-150 ease-in-out",
|
||||
containerClassName
|
||||
)}
|
||||
>
|
||||
@@ -294,9 +295,10 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
|
||||
disabled={decrementDisabled}
|
||||
onClick={() => commitValue(committedValueRef.current - step)}
|
||||
className={cn(
|
||||
"flex h-full w-7 items-center justify-center overflow-x-hidden overflow-y-hidden border-r border-border p-0 leading-none touch-manipulation",
|
||||
"flex h-full w-7 shrink-0 items-center justify-center overflow-x-hidden overflow-y-hidden border-r border-border p-0 leading-none touch-manipulation",
|
||||
"text-muted-foreground hover:bg-interactive-hover hover:text-foreground",
|
||||
"disabled:pointer-events-none disabled:opacity-50"
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
"transition-colors duration-150 ease-in-out"
|
||||
)}
|
||||
>
|
||||
<Icon name="subtract" className="block h-3.5 w-3.5" />
|
||||
@@ -315,7 +317,7 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
className={cn(
|
||||
"h-full min-w-0 w-14 bg-transparent px-1.5 text-center typography-ui-label leading-none text-foreground [font-variant-numeric:tabular-nums]",
|
||||
"h-full min-w-0 w-10 flex-1 bg-transparent px-1.5 text-center typography-ui-label leading-none text-foreground [font-variant-numeric:tabular-nums]",
|
||||
"placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground",
|
||||
"appearance-none outline-none [appearance:textfield] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
||||
"disabled:pointer-events-none disabled:cursor-not-allowed",
|
||||
@@ -328,9 +330,10 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
|
||||
disabled={incrementDisabled}
|
||||
onClick={() => commitValue(committedValueRef.current + step)}
|
||||
className={cn(
|
||||
"flex h-full w-7 items-center justify-center overflow-x-hidden overflow-y-hidden border-l border-border p-0 leading-none touch-manipulation",
|
||||
"flex h-full w-7 shrink-0 items-center justify-center overflow-x-hidden overflow-y-hidden border-l border-border p-0 leading-none touch-manipulation",
|
||||
"text-muted-foreground hover:bg-interactive-hover hover:text-foreground",
|
||||
"disabled:pointer-events-none disabled:opacity-50"
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
"transition-colors duration-150 ease-in-out"
|
||||
)}
|
||||
>
|
||||
<Icon name="add" className="block h-3.5 w-3.5" />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Select as BaseSelect } from "@base-ui/react/select"
|
||||
import type { SelectRootChangeEventDetails } from "@base-ui/react/select";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { dropdownTriggerVariants } from "@/components/ui/dropdown-trigger"
|
||||
import { ScrollableOverlay } from "@/components/ui/ScrollableOverlay";
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
|
||||
@@ -108,7 +109,7 @@ function SelectTrigger({
|
||||
onFocusCapture,
|
||||
...props
|
||||
}: React.ComponentProps<typeof BaseSelect.Trigger> & AsChildProps & {
|
||||
size?: "sm" | "default" | "lg" | "chip"
|
||||
size?: "sm" | "default" | "lg" | "chip" | "settings"
|
||||
}) {
|
||||
const portalContext = React.useContext(SelectPortalContext);
|
||||
|
||||
@@ -128,7 +129,10 @@ function SelectTrigger({
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex w-fit items-center justify-between gap-2 rounded-lg border bg-transparent px-2 py-2 typography-ui-label whitespace-nowrap shadow-none outline-none focus-visible:outline-none hover:bg-interactive-hover data-[popup-open]:bg-interactive-active focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-6 data-[size=sm]:h-6 data-[size=lg]:h-8 data-[size=lg]:py-1.5 data-[size=chip]:h-7 data-[size=chip]:py-1 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
// Shared trigger chrome: one source of truth for every dropdown trigger.
|
||||
// Legacy sizes map onto the two canonical ones: sm (dense) / default (forms).
|
||||
dropdownTriggerVariants({ size: size === 'settings' || size === 'lg' ? 'default' : 'sm' }),
|
||||
"w-fit data-[placeholder]:text-muted-foreground aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2",
|
||||
className
|
||||
)}
|
||||
onPointerDownCapture={(event) => {
|
||||
|
||||
@@ -37,6 +37,10 @@ import { GitPage } from '@/components/sections/git-identities/GitPage';
|
||||
import type { OpenChamberSection } from '@/components/sections/openchamber/types';
|
||||
import { OpenChamberPage } from '@/components/sections/openchamber/OpenChamberPage';
|
||||
import { AboutSettings } from '@/components/sections/openchamber/AboutSettings';
|
||||
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
|
||||
import {
|
||||
SETTINGS_SECTION_TITLE_CLASS,
|
||||
} from '@/components/sections/shared/SettingsSection';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime, isWebRuntime } from '@/lib/desktop';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
@@ -54,16 +58,11 @@ import {
|
||||
} from '@/lib/settings/metadata';
|
||||
import { buildSettingsSearchResults, type SettingsSearchResult } from '@/lib/settings/search';
|
||||
|
||||
// Same constraints as main sidebar
|
||||
const SETTINGS_NAV_MIN_WIDTH = 176;
|
||||
const SETTINGS_NAV_MAX_WIDTH = 280;
|
||||
const SETTINGS_NAV_RESIZE_STEP = 8;
|
||||
// UI Kit: fixed settings navigation width
|
||||
const SETTINGS_NAV_WIDTH = 256;
|
||||
const SETTINGS_SPLIT_SIDEBAR_WIDTH = 280;
|
||||
const SETTINGS_DETAIL_HISTORY_KEY = '__openchamberSettingsDetail';
|
||||
|
||||
function clampSettingsNavWidth(width: number): number {
|
||||
return Math.min(SETTINGS_NAV_MAX_WIDTH, Math.max(SETTINGS_NAV_MIN_WIDTH, width));
|
||||
}
|
||||
|
||||
type MobileStage = 'nav' | 'page-sidebar' | 'page-content';
|
||||
type SettingsDetailHistoryEntry = {
|
||||
page: SettingsPageSlug;
|
||||
@@ -82,30 +81,37 @@ interface SettingsViewProps {
|
||||
}
|
||||
|
||||
const pageOrder: SettingsPageSlug[] = [
|
||||
// 'general' group — OpenChamber
|
||||
'general',
|
||||
'appearance',
|
||||
'chat',
|
||||
'notifications',
|
||||
'sessions',
|
||||
'shortcuts',
|
||||
'git',
|
||||
'magic-prompts',
|
||||
'snippets',
|
||||
'voice',
|
||||
'usage',
|
||||
'about',
|
||||
// 'projects' group — Workspace
|
||||
'projects',
|
||||
'remote-instances',
|
||||
'tunnel',
|
||||
'git',
|
||||
// 'opencode' group — OpenCode
|
||||
'providers',
|
||||
'agents',
|
||||
'behavior',
|
||||
'commands',
|
||||
'mcp',
|
||||
'plugins',
|
||||
'providers',
|
||||
'usage',
|
||||
// 'content' group — Library
|
||||
'magic-prompts',
|
||||
'snippets',
|
||||
'skills.installed',
|
||||
'skills.catalog',
|
||||
'voice',
|
||||
'tunnel',
|
||||
'about',
|
||||
];
|
||||
|
||||
const NAV_GROUP_ORDER = ['general', 'projects', 'opencode', 'content'] as const;
|
||||
|
||||
const SNIPPETS_SETTINGS_ICON = { icon: 'chat-thread' } as const;
|
||||
const ADD_PROVIDER_SETTINGS_ID = '__add_provider__';
|
||||
|
||||
@@ -167,10 +173,12 @@ function getCurrentHistoryState(): Record<string, unknown> {
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function getSettingsNavIcon(slug: SettingsPageSlug): IconName | null {
|
||||
switch (slug) {
|
||||
case 'general':
|
||||
return 'settings-3';
|
||||
case 'projects':
|
||||
return 'folders';
|
||||
case 'remote-instances':
|
||||
return 'server';
|
||||
return 'computer';
|
||||
case 'appearance':
|
||||
return 'palette';
|
||||
case 'chat':
|
||||
@@ -197,7 +205,7 @@ export function getSettingsNavIcon(slug: SettingsPageSlug): IconName | null {
|
||||
case 'mcp':
|
||||
return null;
|
||||
case 'plugins':
|
||||
return 'code-box';
|
||||
return 'plug-2';
|
||||
|
||||
case 'skills.installed':
|
||||
return 'book-open';
|
||||
@@ -212,7 +220,7 @@ export function getSettingsNavIcon(slug: SettingsPageSlug): IconName | null {
|
||||
case 'voice':
|
||||
return 'mic';
|
||||
case 'tunnel':
|
||||
return 'global';
|
||||
return 'home-office';
|
||||
case 'about':
|
||||
return 'information';
|
||||
case 'home':
|
||||
@@ -222,82 +230,6 @@ export function getSettingsNavIcon(slug: SettingsPageSlug): IconName | null {
|
||||
}
|
||||
}
|
||||
|
||||
const SettingsHome: React.FC<{ onOpen: (slug: SettingsPageSlug) => void }> = ({ onOpen }) => {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
<div className="mx-auto w-full max-w-3xl px-6 py-6 space-y-6">
|
||||
<div className="space-y-1">
|
||||
<h1 className="typography-ui-header font-semibold text-foreground">{t('settings.view.home.title')}</h1>
|
||||
<p className="typography-ui text-muted-foreground">{t('settings.view.home.description')}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen('providers')}
|
||||
className={cn(
|
||||
'rounded-lg border border-border bg-[var(--surface-elevated)] p-4 text-left',
|
||||
'hover:bg-[var(--interactive-hover)] transition-colors'
|
||||
)}
|
||||
>
|
||||
<div className="typography-ui-label text-foreground">{t('settings.view.home.cards.providers.title')}</div>
|
||||
<div className="typography-micro text-muted-foreground/70">{t('settings.view.home.cards.providers.description')}</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen('agents')}
|
||||
className={cn(
|
||||
'rounded-lg border border-border bg-[var(--surface-elevated)] p-4 text-left',
|
||||
'hover:bg-[var(--interactive-hover)] transition-colors'
|
||||
)}
|
||||
>
|
||||
<div className="typography-ui-label text-foreground">{t('settings.view.home.cards.agents.title')}</div>
|
||||
<div className="typography-micro text-muted-foreground/70">{t('settings.view.home.cards.agents.description')}</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen('skills.catalog')}
|
||||
className={cn(
|
||||
'rounded-lg border border-border bg-[var(--surface-elevated)] p-4 text-left',
|
||||
'hover:bg-[var(--interactive-hover)] transition-colors'
|
||||
)}
|
||||
>
|
||||
<div className="typography-ui-label text-foreground">{t('settings.view.home.cards.skillsCatalog.title')}</div>
|
||||
<div className="typography-micro text-muted-foreground/70">{t('settings.view.home.cards.skillsCatalog.description')}</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen('mcp')}
|
||||
className={cn(
|
||||
'rounded-lg border border-border bg-[var(--surface-elevated)] p-4 text-left',
|
||||
'hover:bg-[var(--interactive-hover)] transition-colors'
|
||||
)}
|
||||
>
|
||||
<div className="typography-ui-label text-foreground">{t('settings.view.home.cards.mcp.title')}</div>
|
||||
<div className="typography-micro text-muted-foreground/70">{t('settings.view.home.cards.mcp.description')}</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen('usage')}
|
||||
className={cn(
|
||||
'rounded-lg border border-border bg-[var(--surface-elevated)] p-4 text-left',
|
||||
'hover:bg-[var(--interactive-hover)] transition-colors'
|
||||
)}
|
||||
>
|
||||
<div className="typography-ui-label text-foreground">{t('settings.view.home.cards.usage.title')}</div>
|
||||
<div className="typography-micro text-muted-foreground/70">{t('settings.view.home.cards.usage.description')}</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile, isWindowed, visiblePageSlugs, initialMobileStage = 'nav' }) => {
|
||||
const { t } = useI18n();
|
||||
const deviceInfo = useDeviceInfo();
|
||||
@@ -311,14 +243,18 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
const [mobileStage, setMobileStage] = React.useState<MobileStage>(initialMobileStage);
|
||||
const autoNavSlugRef = React.useRef<string | null>(null);
|
||||
|
||||
const [navWidth, setNavWidth] = React.useState(216);
|
||||
// No starter page on desktop: 'home' (fresh state) resolves to General.
|
||||
// settingsPage persists in the UI store, so subsequent opens restore the
|
||||
// last visited page. Mobile keeps 'home' — its entry stage is the nav list.
|
||||
React.useEffect(() => {
|
||||
if (!isMobile && settingsSlug === 'home') {
|
||||
setSettingsPage('general');
|
||||
}
|
||||
}, [isMobile, setSettingsPage, settingsSlug]);
|
||||
|
||||
const [settingsSearchQuery, setSettingsSearchQuery] = React.useState('');
|
||||
const [pendingSearchItemId, setPendingSearchItemId] = React.useState<string | null>(null);
|
||||
const [activeSearchResultIndex, setActiveSearchResultIndex] = React.useState(0);
|
||||
const [hasManuallyResized, setHasManuallyResized] = React.useState(false);
|
||||
const [isResizing, setIsResizing] = React.useState(false);
|
||||
const startXRef = React.useRef(0);
|
||||
const startWidthRef = React.useRef(navWidth);
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const searchResultRefs = React.useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const activeSearchResultIndexRef = React.useRef(0);
|
||||
@@ -362,68 +298,6 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
|
||||
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const handleResize = () => {
|
||||
if (!hasManuallyResized) {
|
||||
const proportionalWidth = clampSettingsNavWidth(Math.floor(window.innerWidth * 0.12));
|
||||
setNavWidth(proportionalWidth);
|
||||
}
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [hasManuallyResized]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isResizing) return;
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
const delta = event.clientX - startXRef.current;
|
||||
const nextWidth = clampSettingsNavWidth(startWidthRef.current + delta);
|
||||
setNavWidth(nextWidth);
|
||||
setHasManuallyResized(true);
|
||||
};
|
||||
const handlePointerUp = () => setIsResizing(false);
|
||||
window.addEventListener('pointermove', handlePointerMove);
|
||||
window.addEventListener('pointerup', handlePointerUp, { once: true });
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', handlePointerMove);
|
||||
window.removeEventListener('pointerup', handlePointerUp);
|
||||
};
|
||||
}, [isResizing]);
|
||||
|
||||
const handlePointerDown = (event: React.PointerEvent) => {
|
||||
setIsResizing(true);
|
||||
startXRef.current = event.clientX;
|
||||
startWidthRef.current = navWidth;
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const handleResizeKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const step = event.shiftKey ? SETTINGS_NAV_RESIZE_STEP * 4 : SETTINGS_NAV_RESIZE_STEP;
|
||||
let nextWidth: number;
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowLeft':
|
||||
nextWidth = navWidth - step;
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
nextWidth = navWidth + step;
|
||||
break;
|
||||
case 'Home':
|
||||
nextWidth = SETTINGS_NAV_MIN_WIDTH;
|
||||
break;
|
||||
case 'End':
|
||||
nextWidth = SETTINGS_NAV_MAX_WIDTH;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
setNavWidth(clampSettingsNavWidth(nextWidth));
|
||||
setHasManuallyResized(true);
|
||||
};
|
||||
|
||||
// Load stores when project changes or when a page becomes active.
|
||||
React.useEffect(() => {
|
||||
if (!isSettingsDialogOpen && !runtimeCtx.isVSCode && !isWindowed) {
|
||||
@@ -476,6 +350,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
// Nav is always open (collapsed state removed)
|
||||
|
||||
const openChamberSectionBySlug: Partial<Record<SettingsPageSlug, OpenChamberSection>> = React.useMemo(() => ({
|
||||
general: 'general',
|
||||
appearance: 'visual',
|
||||
chat: 'chat',
|
||||
shortcuts: 'shortcuts',
|
||||
@@ -487,6 +362,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
|
||||
const getPageTitle = React.useCallback((slug: SettingsPageSlug): string => {
|
||||
switch (slug) {
|
||||
case 'general':
|
||||
return t('settings.page.general.title');
|
||||
case 'projects':
|
||||
return t('settings.page.projects.title');
|
||||
case 'remote-instances':
|
||||
@@ -739,7 +616,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center px-6">
|
||||
<div className="max-w-md text-center">
|
||||
<div className="typography-ui-header font-semibold text-foreground">{t('settings.view.unavailable.title')}</div>
|
||||
<div className={SETTINGS_SECTION_TITLE_CLASS}>{t('settings.view.unavailable.title')}</div>
|
||||
<p className="typography-ui text-muted-foreground mt-1">{t('settings.view.unavailable.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -780,8 +657,6 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
}
|
||||
|
||||
switch (slug) {
|
||||
case 'home':
|
||||
return <SettingsHome onOpen={openPage} />;
|
||||
case 'projects':
|
||||
return <ProjectsPage />;
|
||||
case 'remote-instances':
|
||||
@@ -805,13 +680,18 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
case 'usage':
|
||||
return <UsagePage />;
|
||||
case 'about':
|
||||
return <div className="h-full overflow-auto px-5 py-6"><AboutSettings /></div>;
|
||||
return (
|
||||
<SettingsPageLayout title={t('settings.page.about.title')} showSaveStatus={false}>
|
||||
<AboutSettings />
|
||||
</SettingsPageLayout>
|
||||
);
|
||||
case 'magic-prompts':
|
||||
return <MagicPromptsPage />;
|
||||
case 'snippets':
|
||||
return <SnippetsPage />;
|
||||
case 'git':
|
||||
return <GitPage />;
|
||||
case 'general':
|
||||
case 'appearance':
|
||||
case 'chat':
|
||||
case 'shortcuts':
|
||||
@@ -822,10 +702,11 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
const section = openChamberSectionBySlug[slug] ?? 'visual';
|
||||
return <OpenChamberPage section={section} />;
|
||||
}
|
||||
case 'home':
|
||||
default:
|
||||
return <SettingsHome onOpen={openPage} />;
|
||||
return null;
|
||||
}
|
||||
}, [openChamberSectionBySlug, openPage, renderUnavailable, runtimeCtx]);
|
||||
}, [openChamberSectionBySlug, renderUnavailable, runtimeCtx, t]);
|
||||
|
||||
// Mobile: if opened via deep-link / palette to a non-home page, jump into it once.
|
||||
React.useEffect(() => {
|
||||
@@ -938,7 +819,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="px-2 pt-3">
|
||||
<div className="px-4 pt-3">
|
||||
<div className="flex h-10 items-center gap-1.5 rounded-md border border-border bg-background/70 px-2 text-muted-foreground focus-within:ring-2 focus-within:ring-primary/40 sm:h-8">
|
||||
<Icon name="search" className="h-4 w-4 shrink-0" />
|
||||
<input
|
||||
@@ -964,7 +845,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
|
||||
{/* Scrollable nav items */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden">
|
||||
<div className="flex flex-col gap-0.5 pt-4 pb-2 px-2">
|
||||
<div className="flex flex-col gap-0.5 px-4 pt-4 pb-2">
|
||||
{hasSearchQuery ? (
|
||||
settingsSearchResults.length > 0 ? (() => {
|
||||
let resultIndex = 0;
|
||||
@@ -1010,54 +891,83 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
{t('settings.view.search.noResults')}
|
||||
</div>
|
||||
)
|
||||
) : sortedFilteredPages.map((page) => {
|
||||
const selected = settingsSlug === page.slug;
|
||||
const iconName = getSettingsNavIcon(page.slug);
|
||||
if (!iconName && page.slug !== 'mcp') return null;
|
||||
) : (() => {
|
||||
const pagesByGroup = new Map<string, typeof sortedFilteredPages>();
|
||||
for (const page of sortedFilteredPages) {
|
||||
const group = page.group;
|
||||
const existing = pagesByGroup.get(group);
|
||||
if (existing) {
|
||||
existing.push(page);
|
||||
} else {
|
||||
pagesByGroup.set(group, [page]);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip key={page.slug}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openPage(page.slug)}
|
||||
aria-current={selected ? 'page' : undefined}
|
||||
className={cn(
|
||||
'flex h-8 items-center gap-2 rounded-md px-2 overflow-hidden',
|
||||
selected
|
||||
? 'bg-interactive-selection text-foreground'
|
||||
: 'text-foreground hover:bg-interactive-hover'
|
||||
)}
|
||||
>
|
||||
{page.slug === 'mcp'
|
||||
? <McpIcon className="h-4 w-4 shrink-0" />
|
||||
: <Icon name={iconName!} className="h-4 w-4 shrink-0" />}
|
||||
<span className="flex items-center gap-1.5 whitespace-nowrap overflow-hidden transition-opacity duration-150 opacity-100">
|
||||
<span className="typography-ui-label font-normal truncate">{getPageTitle(page.slug)}</span>
|
||||
{page.slug === 'tunnel' && (
|
||||
<span className="shrink-0 typography-micro px-1 rounded leading-none pb-px text-[var(--status-warning)] bg-[var(--status-warning)]/10">
|
||||
{t('settings.view.badge.beta')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
const visibleGroups = NAV_GROUP_ORDER
|
||||
.map((group) => ({ group, pages: pagesByGroup.get(group) ?? [] }))
|
||||
.filter((entry) => entry.pages.length > 0);
|
||||
|
||||
return visibleGroups.map(({ group, pages }, groupIndex) => (
|
||||
<div key={group} className="space-y-0.5">
|
||||
<div
|
||||
className={cn(
|
||||
'px-3 pb-1 typography-micro font-semibold uppercase tracking-wide text-muted-foreground sm:px-2 sm:pb-0.5',
|
||||
groupIndex === 0 ? 'pt-1' : 'pt-4 sm:pt-3',
|
||||
)}
|
||||
>
|
||||
{t(`settings.view.nav.group.${group}`)}
|
||||
</div>
|
||||
{pages.map((page) => {
|
||||
const selected = settingsSlug === page.slug;
|
||||
const iconName = getSettingsNavIcon(page.slug);
|
||||
if (!iconName && page.slug !== 'mcp') return null;
|
||||
|
||||
return (
|
||||
<Tooltip key={page.slug}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openPage(page.slug)}
|
||||
aria-current={selected ? 'page' : undefined}
|
||||
className={cn(
|
||||
'flex h-11 w-full items-center gap-2.5 rounded-md px-3 overflow-hidden sm:h-8 sm:gap-2 sm:px-2',
|
||||
selected
|
||||
? 'bg-interactive-selection text-foreground'
|
||||
: 'text-foreground hover:bg-interactive-hover'
|
||||
)}
|
||||
>
|
||||
{page.slug === 'mcp'
|
||||
? <McpIcon className="h-[18px] w-[18px] shrink-0 sm:h-4 sm:w-4" />
|
||||
: <Icon name={iconName!} className="h-[18px] w-[18px] shrink-0 sm:h-4 sm:w-4" />}
|
||||
<span className="flex items-center gap-1.5 whitespace-nowrap overflow-hidden transition-opacity duration-150 opacity-100">
|
||||
<span className="typography-ui-label font-normal truncate">{getPageTitle(page.slug)}</span>
|
||||
{page.slug === 'tunnel' && (
|
||||
<span className="shrink-0 typography-micro px-1 rounded leading-none pb-px text-[var(--status-warning)] bg-[var(--status-warning)]/10">
|
||||
{t('settings.view.badge.beta')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="overflow-hidden transition-opacity duration-150 opacity-100">
|
||||
<div className="border-t border-border bg-sidebar px-2 py-1 space-y-0.5">
|
||||
<div className="border-t border-border bg-background px-4 py-1 space-y-0.5 sm:bg-sidebar">
|
||||
{!runtimeCtx.isVSCode && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex h-7 w-full items-center gap-2 rounded-md px-2 overflow-hidden whitespace-nowrap',
|
||||
'flex h-11 w-full items-center gap-2 rounded-md px-3 overflow-hidden whitespace-nowrap sm:h-7 sm:px-2',
|
||||
'text-sm font-semibold text-sidebar-foreground/90',
|
||||
'hover:text-sidebar-foreground hover:bg-interactive-hover',
|
||||
)}
|
||||
@@ -1082,7 +992,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
const renderMobileStage = () => {
|
||||
if (mobileStage === 'nav') {
|
||||
return (
|
||||
<div className={cn('flex-1 min-h-0 overflow-hidden', runtimeCtx.isVSCode ? 'bg-background' : 'bg-sidebar')}>
|
||||
<div className="flex-1 min-h-0 overflow-hidden bg-background">
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<ErrorBoundary>{renderSettingsNav()}</ErrorBoundary>
|
||||
</div>
|
||||
@@ -1105,7 +1015,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={cn('flex-1 min-h-0 overflow-hidden', runtimeCtx.isVSCode ? 'bg-background' : 'bg-sidebar')}>
|
||||
<div className="flex-1 min-h-0 overflow-hidden bg-background">
|
||||
<ErrorBoundary>
|
||||
{renderPageSidebar(settingsSlug, { onItemSelect: handleMobilePageSidebarItemSelect })}
|
||||
</ErrorBoundary>
|
||||
@@ -1125,13 +1035,13 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
|
||||
const renderDesktopContent = () => {
|
||||
if (!activePageMeta || settingsSlug === 'home') {
|
||||
return <SettingsHome onOpen={openPage} />;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (activePageMeta.kind === 'split') {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 overflow-hidden">
|
||||
<div className={cn('w-[264px] min-w-[264px] border-r', runtimeCtx.isVSCode ? 'bg-background' : 'bg-sidebar')} style={{ borderColor: 'var(--interactive-border)' }}>
|
||||
<div className={cn('border-r', runtimeCtx.isVSCode ? 'bg-background' : 'bg-sidebar')} style={{ width: SETTINGS_SPLIT_SIDEBAR_WIDTH, minWidth: SETTINGS_SPLIT_SIDEBAR_WIDTH, borderColor: 'var(--interactive-border)' }}>
|
||||
<ErrorBoundary>{renderPageSidebar(settingsSlug, {})}</ErrorBoundary>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-hidden bg-background">
|
||||
@@ -1242,30 +1152,13 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
: runtimeCtx.isVSCode
|
||||
? 'bg-background'
|
||||
: 'bg-sidebar',
|
||||
isResizing ? '' : 'transition-[width,min-width] duration-200 ease-[cubic-bezier(0.25,0.1,0.25,1)]'
|
||||
)}
|
||||
style={{
|
||||
width: `${navWidth}px`,
|
||||
minWidth: `${navWidth}px`,
|
||||
width: `${SETTINGS_NAV_WIDTH}px`,
|
||||
minWidth: `${SETTINGS_NAV_WIDTH}px`,
|
||||
borderColor: 'var(--interactive-border)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute right-0 top-0 z-20 h-full w-[6px] -mr-[3px] cursor-col-resize',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]',
|
||||
isResizing ? 'bg-primary/30' : 'bg-transparent hover:bg-primary/20'
|
||||
)}
|
||||
tabIndex={0}
|
||||
onPointerDown={handlePointerDown}
|
||||
onKeyDown={handleResizeKeyDown}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-valuemin={SETTINGS_NAV_MIN_WIDTH}
|
||||
aria-valuemax={SETTINGS_NAV_MAX_WIDTH}
|
||||
aria-valuenow={navWidth}
|
||||
aria-label={t('settings.view.actions.resizeNavigation')}
|
||||
/>
|
||||
<ErrorBoundary>
|
||||
{renderSettingsNav()}
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { toast } from '@/components/ui';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger';
|
||||
import { ProviderLogo } from '@/components/ui/ProviderLogo';
|
||||
import { useAgentGroupsStore, type AgentGroup, type AgentGroupSession } from '@/stores/useAgentGroupsStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
@@ -179,9 +180,9 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
|
||||
<div className="flex-1 min-w-0">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-between h-10 px-3"
|
||||
<button
|
||||
type="button"
|
||||
className={cn(dropdownTriggerVariants({ size: 'default' }), 'w-full')}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{selectedSession && (
|
||||
@@ -203,7 +204,7 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
|
||||
)}
|
||||
</div>
|
||||
<Icon name="arrow-down-s" className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
|
||||
</Button>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-[var(--anchor-width)]">
|
||||
{group.sessions.map((session) => (
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { cn } from '@/lib/utils';
|
||||
import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
type OperationType = 'merge' | 'rebase';
|
||||
@@ -300,12 +301,15 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
|
||||
</p>
|
||||
<DropdownMenu open={branchDropdownOpen} onOpenChange={setBranchDropdownOpen} modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="lg" className="w-full justify-between">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(dropdownTriggerVariants({ size: 'default' }), 'w-full')}
|
||||
>
|
||||
<span className={cn('truncate', !selectedBranch && 'text-muted-foreground')}>
|
||||
{selectedBranch || t('gitView.branch.selectBranch')}
|
||||
</span>
|
||||
<Icon name="arrow-down-s" className="size-4 opacity-60 shrink-0" />
|
||||
</Button>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
|
||||
@@ -228,7 +228,7 @@ export const HistorySection: React.FC<HistorySectionProps> = ({
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="data-[size=sm]:h-auto h-7 min-h-7 w-auto justify-between px-2 py-0"
|
||||
className="w-auto"
|
||||
disabled={isLogLoading}
|
||||
>
|
||||
<SelectValue placeholder={t('gitView.history.commitsPlaceholder')} />
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
@@ -361,11 +362,14 @@ export const IntegrateCommitsSection: React.FC<{
|
||||
|
||||
<DropdownMenu open={branchDropdownOpen} onOpenChange={setBranchDropdownOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className={dropdownTriggerVariants({ size: 'default' })}
|
||||
>
|
||||
{t('gitView.integrate.target')}
|
||||
<span className="max-w-[160px] truncate font-mono text-xs text-muted-foreground">{targetBranch}</span>
|
||||
<Icon name="arrow-down-s" className="size-4 opacity-60" />
|
||||
</Button>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
|
||||
@@ -1697,7 +1697,7 @@ export const PullRequestSection: React.FC<{
|
||||
onValueChange={(value) => setMergeMethod(value as MergeMethod)}
|
||||
disabled={isMerging || pr.state !== 'open'}
|
||||
>
|
||||
<SelectTrigger size="lg" className="h-7 w-auto min-w-0">
|
||||
<SelectTrigger size="sm" className="w-auto min-w-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -1761,7 +1761,7 @@ export const PullRequestSection: React.FC<{
|
||||
<div className="typography-micro text-muted-foreground">{t('gitView.pr.field.baseBranch')}</div>
|
||||
{availableBaseBranches.length > 0 ? (
|
||||
<Select value={targetBaseBranch} onValueChange={setTargetBaseBranch}>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectTrigger size="lg">
|
||||
<SelectValue placeholder={t('gitView.pr.placeholder.selectBaseBranch')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
Reference in New Issue
Block a user