feat: redesign settings pages to match canonical flat UI patterns (#493)

* refactor(settings): new IA shell + projects section + skills catalog discoverability

* chore(settings): split providers list by scope; show user before project

* fix: navigation flow in mobile Settings

* feat: redesign settings pages to use modern elevated surface patterns

* feat: replace helper text with tooltips in settings

* ui: redesign update dialog and fix external link routing

- Restructures UpdateDialog to focus on changelog readability with a wider max-w-4xl canvas
- Highlights @username contributor mentions with theme primary color
- Strips excessive vertical padding and right-aligns compact action buttons
- Disables streamdown's internal link safety dialog in favor of direct Tauri shell routing

* feat: refactor Git identities into dedicated Git settings page

* feat: unify sidebar background styling across VS Code and web/mobile

* fix: adjust button styling and layout for mobile settings pages

* feat: add MCP settings page and sidebar

* feat: hide models in provider view (thanks to @nguyenngothuong)

* feat: add "Add new provider" option to model selector dropdown

* fix: local evroc logo + provider dropdown icons

* fix: increase width of provider menu

* fix: dark theme background color for better contrast

* feat: update @opencode-ai/sdk dependency to v1.2.10

* fix: restore session sorting to only use updated time

* fix: added settings for sessions deletion dialog

* fix: adjust padding on settings pages for better layout

* fix: standardize select dropdown height across UI

* fix: agent selector UI and notification settings

* fix: remove redundant helper text from settings pages

* fix: update UI layout for description fields

* fix: remove border-none and shadow-none from textarea classes

* fix: enable context menu on sidebar items

* feat: refactor UI controls and layout patterns across settings pages

* fix: use headerless blocks when page title already provides context

* fix: remove subtask option from command settings

* fix: refactor mcp page settings

* fix: reduce spacing in skills configuration pages

* feat: refactor voice settings

* feat: refactor settings sidebar sections
This commit is contained in:
Bohdan Triapitsyn
2026-02-24 03:28:30 +02:00
committed by GitHub
parent d2d39c48ac
commit d2358c2c03
90 changed files with 8062 additions and 7128 deletions
@@ -0,0 +1,238 @@
---
name: settings-ui-patterns
description: Use when creating or modifying UI components, styling, or visual elements related to Settings in OpenChamber.
license: MIT
compatibility: opencode
---
# Settings UI Patterns Skill
## Purpose
This skill provides instructions for creating or redesigning Settings pages, informational panels, and configuration interfaces within the OpenChamber application.
## Current Canonical Look (2026)
Use this as source of truth for new settings UI work.
- **Flat hierarchy first**: Prefer spacing + typography hierarchy over boxed backgrounds.
- **No unnecessary wrappers**: Avoid extra section wrappers that mix unrelated controls.
- **No redundant section titles**: Do not add headers like `Theme Preferences` or `Scaling & Layout` when controls are already self-explanatory.
- **Compact controls**: Option chips and radio rows should be dense, not tall.
- **Left-leading state icon**: Radio/checkbox state icon appears before text.
- **Subtle state contrast**: Inactive radio labels should be visibly dimmer than active labels.
- **Minimal row chrome**: Avoid row hover/background highlighting by default; keep only where explicitly needed.
## Typography Guidelines
Always utilize the standard OpenChamber typography classes defined in `packages/ui/src/lib/typography.ts`.
- **Page Title**: Use `typography-ui-header font-semibold text-foreground` for the top-most title of a settings page/dialog.
- **Section Header**: Use `typography-ui-header font-medium text-foreground` for settings sections (e.g. `Notification Events`, `Session Defaults`).
- **Control Group Header**: Use `typography-ui-header font-medium text-foreground` (or `font-normal` if it reads too loud) for grouped controls inside a section (e.g. `Default Tool Output`, `Diff Layout`).
- **Values / Primary Text**: Use `typography-ui-label text-foreground`. Add `tabular-nums` if displaying numbers or stats to ensure vertical alignment.
- **Option Labels**: Use non-bold label text in compact option controls (`font-normal` when needed to override).
- **Meta / Helper Text**: Use `typography-meta text-muted-foreground` or `typography-small text-muted-foreground` for supplemental text.
## Layout and Spacing Patterns
### 1. Main Backgrounds
Main wrappers should generally use `bg-background` or `bg-[var(--surface-background)]`. Ensure adequate padding (e.g., `px-5 py-6` or `p-6`).
### 2. Subsection Grouping
Group related controls with vertical spacing, not mandatory cards.
- Use `space-y-3` between logical subsections.
- Use `p-2` for subsection internal padding.
- Avoid adding `bg-[var(--surface-elevated)]` unless there is a clear reason.
- Avoid extra row decorations (`rounded-md`, hover fills) unless there is explicit UX value.
### 3. Header-to-Content Hierarchy (critical)
When removing cards/background wrappers, spacing must be rebalanced so header ownership stays clear.
- Keep **section-to-section spacing larger** than **header-to-own-content spacing**.
- Typical pattern:
- header wrapper `mb-1 px-1`
- content wrapper `pt-0 pb-2 px-2`
- outer section spacing `mb-8`
- Do not leave legacy `mb-3` style gaps after flattening a section; it makes headers look detached.
### 4. Headerless Blocks (when context is obvious)
If the page title already provides enough context, remove redundant local headers and place controls directly below the title.
- Example: project page identity controls can sit directly under project name/path.
- Tighten top gap for this pattern (e.g. top header `mb-4` instead of larger section spacing).
```tsx
<div className="space-y-3">
<section className="p-2">...</section>
<section className="p-2">...</section>
</div>
```
## Structural Patterns
### 1. Segmented Option Buttons (compact)
Use for short option sets where button-style segmented choice reads best (e.g. Default Tool Output).
```tsx
<div className="mt-1 flex flex-wrap items-center gap-1">
<ButtonSmall
variant="outline"
size="xs"
className={cn('!font-normal', isSelected ? 'border-[var(--primary-base)] text-[var(--primary-base)] bg-[var(--primary-base)]/10' : 'text-foreground')}
>
Collapsed
</ButtonSmall>
</div>
```
### 2. Radio Option Lists (compact rows)
Use for mutually exclusive mode/layout settings (e.g. Diff Layout, Diff View Mode).
- Use shared `Radio` component from `@/components/ui/radio`.
- Icon first, label second.
- Row container compact: `py-0.5`.
- Inactive label can use `text-foreground/50`.
```tsx
<div role="radiogroup" aria-label="Diff layout" className="mt-1 space-y-0">
<div className="flex w-full items-center gap-2 py-0.5">
<Radio checked={selected} onChange={onSelect} ariaLabel="Diff layout: Dynamic" />
<span className={cn('typography-ui-label font-normal', selected ? 'text-foreground' : 'text-foreground/50')}>Dynamic</span>
</div>
</div>
```
### 3. Checkbox Setting Rows
Use shared `Checkbox` component from `@/components/ui/checkbox` for boolean toggles.
- Icon first, text immediately after (`gap-2`).
- Typical row spacing for checkbox rows: `py-1.5`.
- Keep row click and keyboard toggle support.
- Prefer checkbox over binary show/hide button pairs for pure boolean state.
```tsx
<div
className="group flex cursor-pointer items-center gap-2 py-1.5"
role="button"
tabIndex={0}
>
<Checkbox checked={value} onChange={setValue} ariaLabel="Show Dotfiles" />
<span className="typography-ui-label text-foreground">Show Dotfiles</span>
</div>
```
### 4. Invisible Two-Column Alignment
Use consistent label/control columns across settings rows so controls align on a shared vertical line.
- Desktop row pattern: `flex items-center gap-8`
- Label column width: `w-56 shrink-0`
- Control cluster: `w-fit`
```tsx
<div className="flex items-center gap-8 py-1.5">
<span className="typography-ui-label text-foreground w-56 shrink-0">Interface Font Size</span>
<div className="flex items-center gap-2 w-fit">...</div>
</div>
```
#### Disabled control rule
If a control is unavailable, disable the control only. Do not dim the label row by default.
#### Width-matching rule
When matching visual widths across different rows, compare full row footprint (control + adjacent action buttons), not just input width.
### 5. Theme Row Composition
For theme controls in Appearance:
- `Color Mode` header on first line; option chips below it.
- `Light Theme` and `Dark Theme` on one row where possible, wrapping on small widths.
- Keep selectors near labels and aligned to existing column rhythm.
- Replace persistent helper text with an info tooltip icon near the related action.
```tsx
<div className="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">Light Theme ...</div>
<div className="flex min-w-0 items-center gap-2">Dark Theme ...</div>
</div>
```
### 6. Numeric Controls in Settings
Use compact stepper input (`- value +`) plus reset button.
- Prefer shared `NumberInput` stepper style over slider + numeric combo in dense settings pages.
- Keep reset button adjacent to control (`gap-2`).
- Avoid using Tailwind `overflow-hidden` on mobile for controls; `packages/ui/src/styles/mobile.css` forces `.overflow-hidden { overflow-y: auto !important; }`.
Use `overflow-x-hidden overflow-y-hidden` if you truly need clipping.
- Touch devices: `packages/ui/src/styles/mobile.css` enforces `min-height: 36px` on `button`. If you build custom segmented controls with `<button>`, ensure the container height can accommodate that (e.g. `h-9`).
#### Optional numeric overrides
For "override unless empty" fields (e.g. agent Temperature/Top P), keep the value optional and provide a fallback for stepping.
```tsx
<NumberInput
value={temperature}
fallbackValue={0.7}
onValueChange={setTemperature}
onClear={() => setTemperature(undefined)}
min={0}
max={2}
step={0.1}
inputMode="decimal"
emptyLabel="—"
/>
```
```tsx
<div className="flex items-center gap-2 w-fit">
<NumberInput value={fontSize} onValueChange={setFontSize} min={50} max={200} step={5} />
<ButtonSmall variant="ghost" className="h-7 w-7 px-0">...</ButtonSmall>
</div>
```
### 7. Inputs and Select Triggers (settings density)
Keep form controls in settings compact and aligned.
- Prefer `Input` with `className="h-7"` in dense settings rows.
- Prefer default `SelectTrigger` sizing (avoid `size="lg"` in settings).
- For icon-only actions next to inputs, use `ButtonSmall` with `h-7 w-7 p-0`.
```tsx
<div className="flex items-center gap-2">
<Input className="h-7" />
<ButtonSmall variant="outline" size="xs" className="h-7 w-7 p-0" aria-label="Browse">
<RiFolderLine className="h-4 w-4" />
</ButtonSmall>
</div>
```
### 8. Template Grids (text fields)
For template-like settings (title/message pairs), use a simple grid and flat cells.
- Grid: `grid grid-cols-1 gap-2 md:grid-cols-2 md:gap-3`
- Cell: `section p-2`
- Field: `Input className="h-7"`
### 9. Icon/Color Picker Rows
For dense icon/color pickers in settings:
- Place options under the field label when they are a palette/grid choice.
- Use stable selected-state styling (`border`/`ring`/subtle background), avoid transform jumps (`scale-*`).
- Keep chip size compact (`h-7 w-7`) and spacing consistent (`gap-2`).
## Control Selection Rules
- **Use compact option buttons** for short, chip-like selection groups.
- **Use radios** for explicit mode/layout choices where list scanning is better.
- **Use checkboxes** for true/false settings.
- **Avoid show/hide button pairs** when a checkbox maps directly to the boolean.
- **Do not couple unrelated toggles** under one synthetic section header; keep hierarchy clear.
## Best Practices
- **Density**: Keep options compact; avoid oversized rows/chips in dense settings pages.
- **Consistency**: Reuse shared controls (`Checkbox`, `Radio`, `ButtonSmall size="xs"`) instead of inline icon logic.
- **Reuse via composition**: Prefer a single settings component with a `visibleSettings` subset (like `OpenChamberVisualSettings`) for multiple tabs (Appearance/Chat) instead of duplicating markup.
- **Hierarchy**: Page title = `font-semibold`; section header = `font-medium`; control group header = `font-medium` (or `font-normal` if needed); option labels = non-bold.
- **Subsection depth**: Nested subgroup headings under a section should usually be one step lighter than parent heading weight.
- **Hierarchy sanity check**: after flattening UI, verify visual grouping by spacing first (not color).
- **Helper blocks**: For small notes/errors under a section, use `mt-1 px-2` with `typography-meta text-muted-foreground/70` (and status token for errors).
- **Truncation**: Always consider long text. Use `min-w-0 flex-1 truncate` on text containers that sit next to buttons or icons to prevent layout breakage.
- **Theme Variables**: *Always* use CSS variables for colors (e.g., `var(--status-success)`) rather than hardcoded hex values or generic Tailwind colors when indicating semantic states.
+4 -4
View File
@@ -32,7 +32,7 @@
"@ibm/plex": "^6.4.1", "@ibm/plex": "^6.4.1",
"@lezer/highlight": "^1.2.3", "@lezer/highlight": "^1.2.3",
"@octokit/rest": "^22.0.1", "@octokit/rest": "^22.0.1",
"@opencode-ai/sdk": "^1.2.5", "@opencode-ai/sdk": "^1.2.10",
"@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -136,7 +136,7 @@
"@fontsource/ibm-plex-sans": "^5.1.1", "@fontsource/ibm-plex-sans": "^5.1.1",
"@ibm/plex": "^6.4.1", "@ibm/plex": "^6.4.1",
"@lezer/highlight": "^1.2.3", "@lezer/highlight": "^1.2.3",
"@opencode-ai/sdk": "^1.2.5", "@opencode-ai/sdk": "^1.2.10",
"@pierre/diffs": "1.1.0-beta.13", "@pierre/diffs": "1.1.0-beta.13",
"@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
@@ -210,7 +210,7 @@
"version": "1.7.3", "version": "1.7.3",
"dependencies": { "dependencies": {
"@openchamber/ui": "workspace:*", "@openchamber/ui": "workspace:*",
"@opencode-ai/sdk": "^1.2.5", "@opencode-ai/sdk": "^1.2.10",
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"jsonc-parser": "^3.3.1", "jsonc-parser": "^3.3.1",
"react": "^19.1.1", "react": "^19.1.1",
@@ -241,7 +241,7 @@
"@fontsource/ibm-plex-sans": "^5.1.1", "@fontsource/ibm-plex-sans": "^5.1.1",
"@ibm/plex": "^6.4.1", "@ibm/plex": "^6.4.1",
"@octokit/rest": "^22.0.1", "@octokit/rest": "^22.0.1",
"@opencode-ai/sdk": "^1.2.5", "@opencode-ai/sdk": "^1.2.10",
"@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
+1 -1
View File
@@ -85,7 +85,7 @@
"@ibm/plex": "^6.4.1", "@ibm/plex": "^6.4.1",
"@lezer/highlight": "^1.2.3", "@lezer/highlight": "^1.2.3",
"@octokit/rest": "^22.0.1", "@octokit/rest": "^22.0.1",
"@opencode-ai/sdk": "^1.2.5", "@opencode-ai/sdk": "^1.2.10",
"@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
+1 -1
View File
@@ -39,7 +39,7 @@
"@fontsource/ibm-plex-sans": "^5.1.1", "@fontsource/ibm-plex-sans": "^5.1.1",
"@ibm/plex": "^6.4.1", "@ibm/plex": "^6.4.1",
"@lezer/highlight": "^1.2.3", "@lezer/highlight": "^1.2.3",
"@opencode-ai/sdk": "^1.2.5", "@opencode-ai/sdk": "^1.2.10",
"@pierre/diffs": "1.1.0-beta.13", "@pierre/diffs": "1.1.0-beta.13",
"@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
@@ -0,0 +1,3 @@
<svg fill="#000000" viewBox="0 0 33 25" role="img" xmlns="http://www.w3.org/2000/svg">
<path d="M79.01 5.863c-4.066 0-6.511 2.92-6.511 6.535 0 3.635 2.445 6.555 6.511 6.555 4.046 0 6.512-2.92 6.512-6.555s-2.466-6.535-6.512-6.535Zm0 10.968c-2.633 0-4.172-1.933-4.172-4.433s1.539-4.455 4.172-4.455c2.635 0 4.151 1.933 4.151 4.434 0 2.521-1.516 4.454-4.15 4.454Zm14.393 2.096c3.393 0 5.542-1.808 5.837-4.539h-2.36c-.316 1.555-1.517 2.437-3.477 2.437-2.423 0-3.878-1.68-3.878-4.433 0-2.774 1.476-4.434 3.878-4.434 1.96 0 3.14.862 3.477 2.5h2.36c-.295-2.773-2.444-4.622-5.837-4.622-3.856 0-6.217 2.669-6.217 6.535 0 3.887 2.36 6.556 6.217 6.556Zm-29.543-.311h2.36v-6.01c0-2.752 1.348-4.244 3.772-4.244h2.276V6.177h-2.255c-2.128 0-3.288.735-3.898 2.605l-.443-.063.527-2.542h-2.36v12.439h.02Zm-24.445-7.332c.106-2.101 1.517-3.53 3.793-3.53 2.276 0 3.646 1.345 3.646 3.53h-7.439Zm9.778.4c0-3.426-2.381-5.821-5.943-5.821-3.73 0-6.174 2.563-6.174 6.535 0 4.013 2.423 6.555 6.28 6.555 2.929 0 5.247-1.597 5.669-3.887h-2.36c-.507 1.156-1.666 1.828-3.31 1.828-2.38 0-3.877-1.408-3.94-3.803h9.694c.042-.588.084-.861.084-1.408Zm5.69 6.932h1.939l5.5-12.44h-2.529L56 15.99l-.316.021-3.793-9.833h-2.508l5.5 12.439ZM32.23 12.35c0-.882-.359-1.701-.99-2.437a8.594 8.594 0 0 1-1.497 1.093c.337.42.527.861.527 1.345 0 2.731-5.837 4.811-14.14 4.811-8.281.021-14.118-2.059-14.118-4.811 0-.463.168-.925.505-1.345a8.13 8.13 0 0 1-1.475-1.093c-.632.736-.99 1.555-.99 2.438 0 4.034 7.207 6.534 16.1 6.534 8.87.021 16.078-2.5 16.078-6.535Zm-3.351 1.534c-.906-.462-1.96-.861-3.16-1.197-1.37.378-2.909.672-4.553.861 2.318.294 4.341.778 5.9 1.408.76-.336 1.37-.693 1.813-1.072Zm-17.849-.357a31.902 31.902 0 0 1-4.467-.84c-1.18.336-2.255.735-3.16 1.197.42.379 1.01.715 1.748 1.05 1.539-.63 3.52-1.113 5.88-1.407Zm21.2-6.808c0-4.013-7.207-6.534-16.079-6.534C7.26.185.051 2.706.051 6.719c0 4.035 7.208 6.535 16.1 6.535 8.872.021 16.079-2.5 16.079-6.535Zm-1.94 0c0 2.732-5.836 4.812-14.139 4.812-8.302.021-14.14-2.06-14.14-4.812 0-2.731 5.838-4.811 14.14-4.811 7.86 0 14.14 2.08 14.14 4.811Zm-3.223 2.564c.758-.336 1.37-.694 1.812-1.072-2.95-1.513-7.544-2.353-12.728-2.353s-9.799.84-12.728 2.353c.422.378 1.012.715 1.75 1.05 2.507-1.05 6.363-1.68 10.978-1.68 4.404 0 8.324.651 10.916 1.702ZM1.042 15.628c-.632.736-.99 1.534-.99 2.438 0 4.034 7.207 6.534 16.1 6.534 8.892 0 16.099-2.521 16.099-6.534 0-.883-.359-1.702-.99-2.438-.422.4-.907.757-1.497 1.093.337.42.527.861.527 1.345 0 2.731-5.837 4.811-14.14 4.811-8.302 0-14.14-2.08-14.14-4.811 0-.463.17-.925.506-1.345a10.73 10.73 0 0 1-1.475-1.093Z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

@@ -733,10 +733,11 @@ export const SimpleMarkdownRenderer: React.FC<{
content: string; content: string;
className?: string; className?: string;
variant?: MarkdownVariant; variant?: MarkdownVariant;
disableLinkSafety?: boolean;
onShowPopup?: (content: ToolPopupContent) => void; onShowPopup?: (content: ToolPopupContent) => void;
mermaidControls?: MermaidControlOptions; mermaidControls?: MermaidControlOptions;
allowMermaidWheelZoom?: boolean; allowMermaidWheelZoom?: boolean;
}> = ({ content, className, variant = 'assistant', onShowPopup, mermaidControls, allowMermaidWheelZoom = false }) => { }> = ({ content, className, variant = 'assistant', disableLinkSafety, onShowPopup, mermaidControls, allowMermaidWheelZoom = false }) => {
const streamdownContainerRef = React.useRef<HTMLDivElement>(null); const streamdownContainerRef = React.useRef<HTMLDivElement>(null);
const mermaidBlocks = React.useMemo(() => extractMermaidBlocks(content), [content]); const mermaidBlocks = React.useMemo(() => extractMermaidBlocks(content), [content]);
useMermaidInlineInteractions({ useMermaidInlineInteractions({
@@ -767,6 +768,8 @@ export const SimpleMarkdownRenderer: React.FC<{
plugins={streamdownPlugins} plugins={streamdownPlugins}
mermaid={mermaidOptions} mermaid={mermaidOptions}
components={streamdownComponents} components={streamdownComponents}
// @ts-expect-error Streamdown type missing linkSafety in older minor
linkSafety={disableLinkSafety ? { enabled: false } : undefined}
> >
{content} {content}
</Streamdown> </Streamdown>
@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import type { ComponentType } from 'react'; import type { ComponentType } from 'react';
import { import {
RiAddLine,
RiAiAgentLine, RiAiAgentLine,
RiArrowDownSLine, RiArrowDownSLine,
RiArrowGoBackLine, RiArrowGoBackLine,
@@ -187,6 +188,8 @@ const CURRENCY_FORMATTER = new Intl.NumberFormat('en-US', {
minimumFractionDigits: 2, minimumFractionDigits: 2,
}); });
const ADD_PROVIDER_ID = '__add_provider__';
const formatTokens = (value?: number | null) => { const formatTokens = (value?: number | null) => {
if (typeof value !== 'number' || Number.isNaN(value)) { if (typeof value !== 'number' || Number.isNaN(value)) {
return '—'; return '—';
@@ -297,6 +300,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
settingsDefaultVariant, settingsDefaultVariant,
settingsDefaultAgent, settingsDefaultAgent,
setProvider, setProvider,
setSelectedProvider,
setModel, setModel,
setCurrentVariant, setCurrentVariant,
getCurrentModelVariants, getCurrentModelVariants,
@@ -362,7 +366,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
addRecentEffort, addRecentEffort,
isModelSelectorOpen, isModelSelectorOpen,
setModelSelectorOpen, setModelSelectorOpen,
setSettingsDialogOpen,
setSettingsPage,
} = useUIStore(); } = useUIStore();
const hiddenModels = useUIStore((state) => state.hiddenModels);
// Separate state for agent selector to avoid conflict with model selector // Separate state for agent selector to avoid conflict with model selector
const [isAgentSelectorOpen, setIsAgentSelectorOpen] = React.useState(false); const [isAgentSelectorOpen, setIsAgentSelectorOpen] = React.useState(false);
@@ -393,6 +400,13 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
// Use global state for model selector (allows Ctrl+M shortcut) // Use global state for model selector (allows Ctrl+M shortcut)
const agentMenuOpen = isModelSelectorOpen; const agentMenuOpen = isModelSelectorOpen;
const setAgentMenuOpen = setModelSelectorOpen; const setAgentMenuOpen = setModelSelectorOpen;
const openAddProviderSettings = React.useCallback(() => {
setSelectedProvider(ADD_PROVIDER_ID);
setSettingsPage('providers');
setSettingsDialogOpen(true);
setAgentMenuOpen(false);
closeMobilePanel();
}, [setSelectedProvider, setSettingsPage, setSettingsDialogOpen, setAgentMenuOpen, closeMobilePanel]);
const [desktopModelQuery, setDesktopModelQuery] = React.useState(''); const [desktopModelQuery, setDesktopModelQuery] = React.useState('');
const [modelSelectedIndex, setModelSelectedIndex] = React.useState(0); const [modelSelectedIndex, setModelSelectedIndex] = React.useState(0);
const modelItemRefs = React.useRef<(HTMLDivElement | null)[]>([]); const modelItemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
@@ -535,6 +549,21 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
const currentProvider = getCurrentProvider(); const currentProvider = getCurrentProvider();
const models = Array.isArray(currentProvider?.models) ? currentProvider.models : []; const models = Array.isArray(currentProvider?.models) ? currentProvider.models : [];
const visibleProviders = React.useMemo(() => {
return providers
.map((provider) => {
const providerModels = Array.isArray(provider.models) ? provider.models : [];
const visibleModels = providerModels.filter((model: ProviderModel) => {
const modelId = typeof model?.id === 'string' ? model.id : '';
return !hiddenModels.some(
(item) => item.providerID === String(provider.id) && item.modelID === modelId
);
});
return { ...provider, models: visibleModels };
})
.filter((provider) => provider.models.length > 0);
}, [providers, hiddenModels]);
const currentMetadata = const currentMetadata =
currentProviderId && currentModelId ? getModelMetadata(currentProviderId, currentModelId) : undefined; currentProviderId && currentModelId ? getModelMetadata(currentProviderId, currentModelId) : undefined;
const currentCapabilityIcons = getCapabilityIcons(currentMetadata); const currentCapabilityIcons = getCapabilityIcons(currentMetadata);
@@ -1439,7 +1468,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
if (!isCompact) return null; if (!isCompact) return null;
const normalizedQuery = mobileModelQuery.trim(); const normalizedQuery = mobileModelQuery.trim();
const filteredProviders = providers const filteredProviders = visibleProviders
.map((provider) => { .map((provider) => {
const providerModels = Array.isArray(provider.models) ? provider.models : []; const providerModels = Array.isArray(provider.models) ? provider.models : [];
const matchesProvider = normalizedQuery.length === 0 const matchesProvider = normalizedQuery.length === 0
@@ -2092,7 +2121,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
}); });
// Filter providers and their models // Filter providers and their models
const filteredProviders = providers const filteredProviders = visibleProviders
.map((provider) => { .map((provider) => {
const providerModels = Array.isArray(provider.models) ? provider.models : []; const providerModels = Array.isArray(provider.models) ? provider.models : [];
const filteredModels = providerModels.filter((model: ProviderModel) => { const filteredModels = providerModels.filter((model: ProviderModel) => {
@@ -2218,6 +2247,26 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
{/* Scrollable content */} {/* Scrollable content */}
<ScrollableOverlay outerClassName="max-h-[min(400px,calc(100dvh-12rem))] flex-1"> <ScrollableOverlay outerClassName="max-h-[min(400px,calc(100dvh-12rem))] flex-1">
<div className="p-1"> <div className="p-1">
<div
role="button"
tabIndex={0}
onClick={openAddProviderSettings}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openAddProviderSettings();
}
}}
className="typography-meta group flex items-center gap-1 rounded-md px-2 py-1.5 cursor-pointer hover:bg-interactive-hover/50"
>
<span className="flex h-4 w-4 items-center justify-center text-muted-foreground">
<RiAddLine className="h-4 w-4 -mr-0.5" />
</span>
<span className="font-medium text-foreground">Add new provider</span>
</div>
<DropdownMenuSeparator />
{!hasResults && ( {!hasResults && (
<div className="px-2 py-4 text-center typography-meta text-muted-foreground"> <div className="px-2 py-4 text-center typography-meta text-muted-foreground">
No models found No models found
+3 -2
View File
@@ -15,7 +15,7 @@ import {
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { AnimatedTabs } from '@/components/ui/animated-tabs'; import { AnimatedTabs } from '@/components/ui/animated-tabs';
import { RiArrowLeftSLine, RiChat4Line, RiCheckLine, RiCloseLine, RiCommandLine, RiFileTextLine, RiFolder6Line, RiFolderAddLine, RiGitBranchLine, RiGithubFill, RiLayoutLeftLine, RiLayoutRightLine, RiMore2Fill, RiPencilLine, RiPlayListAddLine, RiRefreshLine, RiServerLine, RiSettings3Line, RiStackLine, RiTerminalBoxLine, RiTimerLine, type RemixiconComponentType } from '@remixicon/react'; import { RiArrowLeftSLine, RiChat4Line, RiCheckLine, RiCloseLine, RiFileTextLine, RiFolder6Line, RiFolderAddLine, RiGitBranchLine, RiGithubFill, RiLayoutLeftLine, RiLayoutRightLine, RiMore2Fill, RiPencilLine, RiPlayListAddLine, RiRefreshLine, RiServerLine, RiSettings3Line, RiStackLine, RiTerminalBoxLine, RiTimerLine, type RemixiconComponentType } from '@remixicon/react';
import { DiffIcon } from '@/components/icons/DiffIcon'; import { DiffIcon } from '@/components/icons/DiffIcon';
import { useUIStore, type MainTab } from '@/stores/useUIStore'; import { useUIStore, type MainTab } from '@/stores/useUIStore';
import { useUpdateStore } from '@/stores/useUpdateStore'; import { useUpdateStore } from '@/stores/useUpdateStore';
@@ -31,6 +31,7 @@ import { useDeviceInfo } from '@/lib/device';
import { cn, hasModifier, formatDirectoryName } from '@/lib/utils'; import { cn, hasModifier, formatDirectoryName } from '@/lib/utils';
import { useDiffFileCount } from '@/components/views/DiffView'; import { useDiffFileCount } from '@/components/views/DiffView';
import { McpDropdown, McpDropdownContent } from '@/components/mcp/McpDropdown'; import { McpDropdown, McpDropdownContent } from '@/components/mcp/McpDropdown';
import { McpIcon } from '@/components/icons/McpIcon';
import { ProviderLogo } from '@/components/ui/ProviderLogo'; import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { formatPercent, formatWindowLabel, QUOTA_PROVIDERS, calculatePace, calculateExpectedUsagePercent } from '@/lib/quota'; import { formatPercent, formatWindowLabel, QUOTA_PROVIDERS, calculatePace, calculateExpectedUsagePercent } from '@/lib/quota';
import { UsageProgressBar } from '@/components/sections/usage/UsageProgressBar'; import { UsageProgressBar } from '@/components/sections/usage/UsageProgressBar';
@@ -1058,7 +1059,7 @@ export const Header: React.FC = () => {
} }
base.push( base.push(
{ value: 'usage', label: 'Usage', icon: RiTimerLine }, { value: 'usage', label: 'Usage', icon: RiTimerLine },
{ value: 'mcp', label: 'MCP', icon: RiCommandLine } { value: 'mcp', label: 'MCP', icon: McpIcon as unknown as RemixiconComponentType }
); );
return base; return base;
}, [isDesktopApp]); }, [isDesktopApp]);
@@ -1,167 +0,0 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { SIDEBAR_SECTIONS } from '@/constants/sidebar';
import type { SidebarSection } from '@/constants/sidebar';
import { RiArrowLeftSLine } from '@remixicon/react';
import { Button } from '@/components/ui/button';
import { AgentsSidebar } from '@/components/sections/agents/AgentsSidebar';
import { AgentsPage } from '@/components/sections/agents/AgentsPage';
import { CommandsSidebar } from '@/components/sections/commands/CommandsSidebar';
import { CommandsPage } from '@/components/sections/commands/CommandsPage';
import { ProvidersSidebar } from '@/components/sections/providers/ProvidersSidebar';
import { ProvidersPage } from '@/components/sections/providers/ProvidersPage';
import { GitIdentitiesSidebar } from '@/components/sections/git-identities/GitIdentitiesSidebar';
import { GitIdentitiesPage } from '@/components/sections/git-identities/GitIdentitiesPage';
import { OpenChamberPage } from '@/components/sections/openchamber/OpenChamberPage';
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
interface SettingsDialogProps {
isOpen: boolean;
onClose: () => void;
}
const SETTINGS_SECTIONS = (() => {
const filtered = SIDEBAR_SECTIONS.filter(section => section.id !== 'sessions');
const settingsSection = filtered.find(s => s.id === 'settings');
const otherSections = filtered.filter(s => s.id !== 'settings');
return settingsSection ? [settingsSection, ...otherSections] : filtered;
})();
/**
* Mobile-only settings dialog using MobileOverlayPanel.
* Desktop uses SettingsView rendered inline in MainLayout.
*/
export const SettingsDialog: React.FC<SettingsDialogProps> = ({ isOpen, onClose }) => {
const [activeTab, setActiveTab] = React.useState<SidebarSection>('settings');
const [showPageContent, setShowPageContent] = React.useState(false);
React.useEffect(() => {
if (isOpen) {
setActiveTab('settings');
setShowPageContent(false);
}
}, [isOpen]);
const handleTabChange = React.useCallback((tab: SidebarSection) => {
setActiveTab(tab);
setShowPageContent(false);
}, []);
const handleItemSelect = React.useCallback(() => {
setShowPageContent(true);
}, []);
const renderSidebarContent = () => {
if (activeTab === 'settings') {
return null;
}
const content = (() => {
switch (activeTab) {
case 'agents':
return <AgentsSidebar />;
case 'commands':
return <CommandsSidebar />;
case 'providers':
return <ProvidersSidebar />;
case 'git-identities':
return <GitIdentitiesSidebar />;
default:
return null;
}
})();
return <div onClick={handleItemSelect}>{content}</div>;
};
const renderPageContent = () => {
switch (activeTab) {
case 'agents':
return <AgentsPage />;
case 'commands':
return <CommandsPage />;
case 'providers':
return <ProvidersPage />;
case 'git-identities':
return <GitIdentitiesPage />;
case 'settings':
return <OpenChamberPage />;
default:
return null;
}
};
const mainContent = (
<div className="flex flex-col h-full overflow-hidden">
{/* Tab bar */}
<div className="flex flex-wrap items-center gap-1 border-b border-border/40 bg-background/95 px-3 py-1.5">
{SETTINGS_SECTIONS.map(({ id, label, icon: Icon }) => {
const isActive = activeTab === id;
const PhosphorIcon = Icon as React.ComponentType<{ className?: string; weight?: string }>;
return (
<button
key={id}
onClick={() => handleTabChange(id)}
className={cn(
'flex items-center gap-1 rounded-md px-2.5 py-1.5 text-xs font-medium whitespace-nowrap',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
isActive ? 'text-primary' : 'text-muted-foreground hover:text-foreground'
)}
aria-pressed={isActive}
aria-label={label}
>
<PhosphorIcon className="h-5 w-5" weight="regular" />
</button>
);
})}
</div>
{/* Content area - mobile drill-down pattern */}
<div className="flex flex-1 overflow-hidden">
{activeTab !== 'settings' && !showPageContent && (
<div className="w-full overflow-hidden bg-sidebar">
<ErrorBoundary>{renderSidebarContent()}</ErrorBoundary>
</div>
)}
{(activeTab === 'settings' || showPageContent) && (
<div className="w-full flex-1 overflow-hidden bg-background">
<ErrorBoundary>{renderPageContent()}</ErrorBoundary>
</div>
)}
</div>
</div>
);
return (
<MobileOverlayPanel
open={isOpen}
onClose={onClose}
title="Settings"
className="max-w-full"
contentMaxHeightClassName="h-[min(80dvh,720px)]"
renderHeader={(closeButton) => (
<div className="flex items-center justify-between px-3 py-2 border-b border-border/40">
<div className="relative flex flex-1 items-center justify-center">
{showPageContent && (
<Button
variant="ghost"
size="icon"
onClick={() => setShowPageContent(false)}
className="absolute left-0 h-6 w-6 flex-shrink-0"
>
<RiArrowLeftSLine className="h-5 w-5" />
<span className="sr-only">Back to sidebar</span>
</Button>
)}
<span className="typography-ui-header">Settings</span>
</div>
{closeButton}
</div>
)}
>
{mainContent}
</MobileOverlayPanel>
);
};
@@ -1,15 +1,16 @@
import React from 'react'; import React from 'react';
import { Button } from '@/components/ui/button';
import { ButtonSmall } from '@/components/ui/button-small'; import { ButtonSmall } from '@/components/ui/button-small';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { NumberInput } from '@/components/ui/number-input';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { toast } from '@/components/ui'; import { toast } from '@/components/ui';
import { useAgentsStore, type AgentConfig, type AgentScope } from '@/stores/useAgentsStore'; import { useAgentsStore, type AgentConfig, type AgentScope } from '@/stores/useAgentsStore';
import { useConfigStore } from '@/stores/useConfigStore'; import { useConfigStore } from '@/stores/useConfigStore';
import { usePermissionStore } from '@/stores/permissionStore'; import { usePermissionStore } from '@/stores/permissionStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useDeviceInfo } from '@/lib/device';
import { opencodeClient } from '@/lib/opencode/client'; import { opencodeClient } from '@/lib/opencode/client';
import { RiAddLine, RiAiAgentFill, RiAiAgentLine, RiInformationLine, RiRobot2Line, RiRobotLine, RiSaveLine, RiSubtractLine, RiUser3Line, RiFolderLine } from '@remixicon/react'; import { RiCloseLine, RiInformationLine, RiRobot2Line, RiSubtractLine, RiUser3Line, RiFolderLine } from '@remixicon/react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { ModelSelector } from './ModelSelector'; import { ModelSelector } from './ModelSelector';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
@@ -19,6 +20,7 @@ import {
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
type PermissionAction = 'allow' | 'ask' | 'deny'; type PermissionAction = 'allow' | 'ask' | 'deny';
@@ -180,6 +182,7 @@ const buildPermissionConfigWithGlobal = (
export const AgentsPage: React.FC = () => { export const AgentsPage: React.FC = () => {
const { isMobile } = useDeviceInfo();
const { selectedAgentName, getAgentByName, createAgent, updateAgent, agents, agentDraft, setAgentDraft } = useAgentsStore(); const { selectedAgentName, getAgentByName, createAgent, updateAgent, agents, agentDraft, setAgentDraft } = useAgentsStore();
useConfigStore(); useConfigStore();
@@ -593,65 +596,61 @@ export const AgentsPage: React.FC = () => {
return ( return (
<ScrollableOverlay keyboardAvoid outerClassName="h-full" className="w-full"> <ScrollableOverlay keyboardAvoid outerClassName="h-full" className="w-full">
<div className="mx-auto max-w-3xl space-y-6 p-6"> <div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8">
{/* Header */}
<div className="space-y-1">
<h1 className="typography-ui-header font-semibold text-lg">
{isNewAgent ? 'New Agent' : selectedAgentName}
</h1>
</div>
{} {/* Header & Actions */}
<div className="space-y-4"> <div className="mb-4 flex items-center justify-between gap-4">
<div className="space-y-1"> <div className="min-w-0">
<h2 className="typography-ui-header font-semibold text-foreground">Basic Information</h2> <h2 className="typography-ui-header font-semibold text-foreground truncate">
<p className="typography-meta text-muted-foreground/80"> {isNewAgent ? 'New Agent' : selectedAgentName}
Configure agent identity and behavior mode </h2>
<p className="typography-meta text-muted-foreground truncate">
{isNewAgent ? 'Configure a new assistant persona' : 'Edit agent settings'}
</p> </p>
</div> </div>
</div>
{/* Identity & Role */}
<div className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">
Identity & Role
</h3>
</div>
<section className="px-2 pb-2 pt-0 space-y-0">
{isNewAgent && ( {isNewAgent && (
<div className="space-y-2"> <div className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
<label className="typography-ui-label font-medium text-foreground"> <div className="flex min-w-0 flex-col sm:w-56 shrink-0">
Agent Name & Scope <span className="typography-ui-label text-foreground">Agent Name</span>
</label> </div>
<div className="flex items-center gap-2"> <div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial">
<div className="flex items-center flex-1"> <div className="flex items-center">
<span className="typography-ui-label text-muted-foreground mr-1">@</span> <span className="typography-ui-label text-muted-foreground mr-1">@</span>
<Input <Input
value={draftName} value={draftName}
onChange={(e) => setDraftName(e.target.value)} onChange={(e) => setDraftName(e.target.value)}
placeholder="agent-name" placeholder="agent-name"
className="flex-1 text-foreground placeholder:text-muted-foreground" className="h-7 w-40 px-2"
/> />
</div> </div>
<Select value={draftScope} onValueChange={(v) => setDraftScope(v as AgentScope)}> <Select value={draftScope} onValueChange={(v) => setDraftScope(v as AgentScope)}>
<SelectTrigger className="!h-9 w-auto gap-1.5"> <SelectTrigger className="w-fit min-w-[100px]">
{draftScope === 'user' ? ( <SelectValue placeholder="Scope" />
<RiUser3Line className="h-4 w-4" />
) : (
<RiFolderLine className="h-4 w-4" />
)}
<span className="capitalize">{draftScope}</span>
</SelectTrigger> </SelectTrigger>
<SelectContent align="end"> <SelectContent align="end">
<SelectItem value="user" className="pr-2 [&>span:first-child]:hidden"> <SelectItem value="user">
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<RiUser3Line className="h-4 w-4" /> <RiUser3Line className="h-3.5 w-3.5" />
<span>User</span> <span>Global</span>
</div>
<span className="typography-micro text-muted-foreground ml-6">Available in all projects</span>
</div> </div>
</SelectItem> </SelectItem>
<SelectItem value="project" className="pr-2 [&>span:first-child]:hidden"> <SelectItem value="project">
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<RiFolderLine className="h-4 w-4" /> <RiFolderLine className="h-3.5 w-3.5" />
<span>Project</span> <span>Project</span>
</div> </div>
<span className="typography-micro text-muted-foreground ml-6">Only in current project</span>
</div>
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -659,67 +658,94 @@ export const AgentsPage: React.FC = () => {
</div> </div>
)} )}
<div className="space-y-2"> <div className="py-1.5">
<label className="typography-ui-label font-medium text-foreground"> <span className="typography-ui-label text-foreground">Description</span>
Description <div className="mt-1.5">
</label>
<Textarea <Textarea
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
placeholder="What does this agent do?" placeholder="What does this agent do?"
rows={3} rows={2}
className="w-full resize-none min-h-[60px] bg-transparent"
/> />
</div> </div>
</div>
<div className="space-y-2"> <div className="pb-1.5 pt-0.5">
<label className="typography-ui-label font-medium text-foreground"> <div className="flex min-w-0 flex-col gap-1.5">
Mode <div className="flex items-center gap-1.5">
</label> <span className="typography-ui-label text-foreground">Mode</span>
<div className="flex gap-1 w-fit"> <Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
Primary vs Subagent visibility
</TooltipContent>
</Tooltip>
</div>
<div className="flex flex-wrap items-center gap-1">
<ButtonSmall <ButtonSmall
variant={mode === 'primary' ? 'default' : 'outline'} variant="outline"
size="xs"
onClick={() => setMode('primary')} onClick={() => setMode('primary')}
className={cn('gap-2', mode === 'primary' ? undefined : 'text-foreground')} className={cn(
'!font-normal',
mode === 'primary'
? 'border-[var(--primary-base)] text-[var(--primary-base)] bg-[var(--primary-base)]/10 hover:text-[var(--primary-base)]'
: 'text-foreground'
)}
> >
<RiAiAgentLine className="h-3 w-3" />
Primary Primary
</ButtonSmall> </ButtonSmall>
<ButtonSmall <ButtonSmall
variant={mode === 'subagent' ? 'default' : 'outline'} variant="outline"
size="xs"
onClick={() => setMode('subagent')} onClick={() => setMode('subagent')}
className={cn('gap-2', mode === 'subagent' ? undefined : 'text-foreground')} className={cn(
'!font-normal',
mode === 'subagent'
? 'border-[var(--primary-base)] text-[var(--primary-base)] bg-[var(--primary-base)]/10 hover:text-[var(--primary-base)]'
: 'text-foreground'
)}
> >
<RiRobotLine className="h-3 w-3" />
Subagent Subagent
</ButtonSmall> </ButtonSmall>
<ButtonSmall <ButtonSmall
variant={mode === 'all' ? 'default' : 'outline'} variant="outline"
size="xs"
onClick={() => setMode('all')} onClick={() => setMode('all')}
className={cn('gap-2', mode === 'all' ? undefined : 'text-foreground')} className={cn(
'!font-normal',
mode === 'all'
? 'border-[var(--primary-base)] text-[var(--primary-base)] bg-[var(--primary-base)]/10 hover:text-[var(--primary-base)]'
: 'text-foreground'
)}
> >
<RiAiAgentFill className="h-3 w-3" />
All All
</ButtonSmall> </ButtonSmall>
</div> </div>
<p className="typography-meta text-muted-foreground">
Primary: main agent, Subagent: helper agent, All: both modes
</p>
</div> </div>
</div> </div>
{} </section>
<div className="space-y-4">
<div className="space-y-1">
<h2 className="typography-h2 font-semibold text-foreground">Model Configuration</h2>
<p className="typography-meta text-muted-foreground/80">
Configure model and generation parameters
</p>
</div> </div>
<div className="space-y-2"> {/* Model & Parameters */}
<label className="typography-ui-label font-medium text-foreground"> <div className="mb-8">
Model <div className="mb-1 px-1">
</label> <h3 className="typography-ui-header font-medium text-foreground">
Model & Parameters
</h3>
</div>
<section className="px-2 pb-2 pt-0 space-y-0">
<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">Override Model</span>
</div>
<div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial">
<ModelSelector <ModelSelector
providerId={model ? model.split('/')[0] : ''} providerId={model ? model.split('/')[0] : ''}
modelId={model ? model.split('/')[1] : ''} modelId={model ? model.split('/')[1] : ''}
@@ -732,300 +758,218 @@ export const AgentsPage: React.FC = () => {
}} }}
/> />
</div> </div>
</div>
<div className="flex gap-4"> <div className={cn("py-1.5", isMobile ? "flex flex-col gap-3" : "flex items-center gap-8")}>
<div className="space-y-2"> <div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "sm:w-56 shrink-0")}>
<label className="typography-ui-label font-medium text-foreground flex items-center gap-2"> <div className="flex items-center gap-1.5">
Temperature <span className="typography-ui-label text-foreground">Temperature</span>
<Tooltip delayDuration={1000}> <Tooltip delayDuration={1000}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" /> <RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs"> <TooltipContent sideOffset={8} className="max-w-xs">
Controls randomness in responses.<br /> Controls randomness. Higher = creative, Lower = focused.
Higher values make output more creative and unpredictable,<br />
lower values make it more focused and deterministic.
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</label> </div>
<div className="relative w-32"> <span className="typography-meta text-muted-foreground">0.0 to 2.0</span>
<button </div>
type="button" <div className={cn("flex items-center gap-2", isMobile ? "w-full" : "w-fit")}>
onClick={() => { <NumberInput
const current = temperature !== undefined ? temperature : 0.7; value={temperature}
const newValue = Math.max(0, current - 0.1); fallbackValue={0.7}
setTemperature(parseFloat(newValue.toFixed(1))); onValueChange={setTemperature}
}} onClear={() => setTemperature(undefined)}
className="absolute left-2 top-1/2 -translate-y-1/2 flex items-center justify-center h-6 w-6 rounded hover:bg-interactive-hover text-muted-foreground hover:text-foreground" min={0}
> max={2}
<RiSubtractLine className="h-3.5 w-3.5" /> step={0.1}
</button>
<Input
type="text"
inputMode="decimal" inputMode="decimal"
value={temperature !== undefined ? temperature : ''}
onChange={(e) => {
const value = e.target.value;
if (value === '') {
setTemperature(undefined);
return;
}
const parsed = parseFloat(value);
if (!isNaN(parsed) && parsed >= 0 && parsed <= 2) {
setTemperature(parsed);
}
}}
onBlur={(e) => {
const value = e.target.value;
if (value !== '') {
const parsed = parseFloat(value);
if (!isNaN(parsed)) {
const clamped = Math.max(0, Math.min(2, parsed));
setTemperature(parseFloat(clamped.toFixed(1)));
}
}
}}
placeholder="—" placeholder="—"
className="text-center px-10 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" emptyLabel="—"
className="w-16"
/> />
<button {temperature !== undefined && (
<ButtonSmall
type="button" type="button"
onClick={() => { variant="ghost"
const current = temperature !== undefined ? temperature : 0.7; onClick={() => setTemperature(undefined)}
const newValue = Math.min(2, current + 0.1); className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
setTemperature(parseFloat(newValue.toFixed(1))); aria-label="Clear temperature override"
}} title="Clear"
className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center justify-center h-6 w-6 rounded hover:bg-interactive-hover text-muted-foreground hover:text-foreground"
> >
<RiAddLine className="h-3.5 w-3.5" /> <RiCloseLine className="h-3.5 w-3.5" />
</button> </ButtonSmall>
)}
</div> </div>
</div> </div>
<div className="space-y-2"> <div className={cn("py-1.5", isMobile ? "flex flex-col gap-3" : "flex items-center gap-8")}>
<label className="typography-ui-label font-medium text-foreground flex items-center gap-2"> <div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "sm:w-56 shrink-0")}>
Top P <div className="flex items-center gap-1.5">
<span className="typography-ui-label text-foreground">Top P</span>
<Tooltip delayDuration={1000}> <Tooltip delayDuration={1000}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" /> <RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs"> <TooltipContent sideOffset={8} className="max-w-xs">
Controls diversity via nucleus sampling.<br /> Nucleus sampling diversity. Lower = likely tokens only.
Lower values focus on most likely tokens,<br />
higher values consider more possibilities.
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</label> </div>
<div className="relative w-32"> <span className="typography-meta text-muted-foreground">0.0 to 1.0</span>
<button </div>
type="button" <div className={cn("flex items-center gap-2", isMobile ? "w-full" : "w-fit")}>
onClick={() => { <NumberInput
const current = topP !== undefined ? topP : 0.9; value={topP}
const newValue = Math.max(0, current - 0.1); fallbackValue={0.9}
setTopP(parseFloat(newValue.toFixed(1))); onValueChange={setTopP}
}} onClear={() => setTopP(undefined)}
className="absolute left-2 top-1/2 -translate-y-1/2 flex items-center justify-center h-6 w-6 rounded hover:bg-interactive-hover text-muted-foreground hover:text-foreground" min={0}
> max={1}
<RiSubtractLine className="h-3.5 w-3.5" /> step={0.1}
</button>
<Input
type="text"
inputMode="decimal" inputMode="decimal"
value={topP !== undefined ? topP : ''}
onChange={(e) => {
const value = e.target.value;
if (value === '') {
setTopP(undefined);
return;
}
const parsed = parseFloat(value);
if (!isNaN(parsed) && parsed >= 0 && parsed <= 1) {
setTopP(parsed);
}
}}
onBlur={(e) => {
const value = e.target.value;
if (value !== '') {
const parsed = parseFloat(value);
if (!isNaN(parsed)) {
const clamped = Math.max(0, Math.min(1, parsed));
setTopP(parseFloat(clamped.toFixed(1)));
}
}
}}
placeholder="—" placeholder="—"
className="text-center px-10 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" emptyLabel="—"
className="w-16"
/> />
<button {topP !== undefined && (
<ButtonSmall
type="button" type="button"
onClick={() => { variant="ghost"
const current = topP !== undefined ? topP : 0.9; onClick={() => setTopP(undefined)}
const newValue = Math.min(1, current + 0.1); className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
setTopP(parseFloat(newValue.toFixed(1))); aria-label="Clear top p override"
}} title="Clear"
className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center justify-center h-6 w-6 rounded hover:bg-interactive-hover text-muted-foreground hover:text-foreground"
> >
<RiAddLine className="h-3.5 w-3.5" /> <RiCloseLine className="h-3.5 w-3.5" />
</button> </ButtonSmall>
</div> )}
</div>
</div> </div>
</div> </div>
{} </section>
<div className="space-y-4">
<div className="space-y-1">
<h2 className="typography-h2 font-semibold text-foreground">System Prompt</h2>
<p className="typography-meta text-muted-foreground/80">
Override the default system prompt for this agent
</p>
</div> </div>
{/* System Prompt */}
<div className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">
System Prompt
</h3>
</div>
<section className="px-2 pb-2 pt-0">
<Textarea <Textarea
value={prompt} value={prompt}
onChange={(e) => setPrompt(e.target.value)} onChange={(e) => setPrompt(e.target.value)}
placeholder="Custom system prompt for this agent..." placeholder="You are an expert coding assistant..."
rows={8} rows={8}
className="font-mono typography-meta" className="w-full font-mono typography-meta min-h-[120px] max-h-[60vh] bg-transparent resize-y"
/> />
</section>
</div> </div>
{} {/* Tool Permissions */}
<div className="space-y-4"> <div className="mb-2">
<div className="space-y-1"> <div className="mb-1 px-1 flex items-center justify-between gap-4">
<h2 className="typography-h2 font-semibold text-foreground">Tool Access</h2> <h3 className="typography-ui-header font-medium text-foreground">
<p className="typography-meta text-muted-foreground/80"> Tool Permissions
OpenCode v1.1.1+ configures tool access via the permissions below. </h3>
</p> <ButtonSmall
</div>
</div>
{}
<div className="space-y-4">
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<h2 className="typography-h2 font-semibold text-foreground">Permissions</h2>
<Button
size="sm"
variant="outline" variant="outline"
size="xs"
className="!font-normal"
onClick={() => setShowPermissionEditor((prev) => !prev)} onClick={() => setShowPermissionEditor((prev) => !prev)}
className="h-8"
> >
{showPermissionEditor ? 'Hide' : 'Edit'} {showPermissionEditor ? 'Hide Editor' : 'Advanced Editor'}
</Button> </ButtonSmall>
</div>
<p className="typography-meta text-muted-foreground/80">
{showPermissionEditor
? 'Set a global default; tools only saved when different from global.'
: 'Summary shows default actions; edit to manage granular rules.'}
</p>
</div> </div>
{!showPermissionEditor ? ( {!showPermissionEditor ? (
<div className="rounded-lg border border-border/40 divide-y divide-border/40"> <section className="px-2 pb-2 pt-0 space-y-0">
{summaryPermissionNames.map((permissionName) => { {summaryPermissionNames.map((permissionName, index) => {
const { defaultAction, patternRulesCount, patternSummary, hasDefaultHint } = getPermissionSummary(permissionName); const { defaultAction, patternRulesCount, patternSummary, hasDefaultHint } = getPermissionSummary(permissionName);
const label = formatPermissionLabel(permissionName); const label = formatPermissionLabel(permissionName);
const summary = hasDefaultHint const summary = hasDefaultHint ? `${defaultAction} (env blocked)` : defaultAction;
? `${defaultAction} (env blocked)`
: defaultAction;
return ( return (
<div key={permissionName} className="flex items-center justify-between gap-3 px-3 py-2"> <div key={permissionName} className={cn("flex flex-col gap-1 py-1.5 sm:flex-row sm:items-center sm:justify-between sm:gap-8", index > 0 && "border-t border-[var(--surface-subtle)]")}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="typography-ui-label font-medium text-foreground">{label}</span> <span className="typography-ui-label text-foreground">{label}</span>
<span className="typography-micro text-muted-foreground font-mono">{permissionName}</span> <span className="typography-micro text-muted-foreground/70 font-mono hidden sm:inline-block">{permissionName}</span>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{patternRulesCount > 0 ? ( {patternRulesCount > 0 ? (
<span className="typography-micro text-muted-foreground">Global: {summary}</span> <span className="typography-micro text-muted-foreground bg-[var(--surface-muted)] px-1.5 py-0.5 rounded">Global: {summary}</span>
) : ( ) : (
<span className="typography-micro text-muted-foreground">{summary}</span> <span className={cn("typography-micro capitalize px-1.5 py-0.5 rounded", summary === 'allow' ? "text-[var(--status-success)] bg-[var(--status-success)]/10" : summary === 'deny' ? "text-[var(--status-error)] bg-[var(--status-error)]/10" : "text-[var(--status-warning)] bg-[var(--status-warning)]/10")}>{summary}</span>
)}
{patternRulesCount > 0 && (
<span className="typography-micro text-muted-foreground bg-[var(--surface-muted)] px-1.5 py-0.5 rounded">Rules: {patternSummary}</span>
)} )}
{patternRulesCount > 0 ? (
<span className="typography-micro text-muted-foreground">Patterns: {patternSummary}</span>
) : null}
</div> </div>
</div> </div>
); );
})} })}
</div> </section>
) : ( ) : (
<div className="space-y-6"> <div className="space-y-6 px-2">
<div className="space-y-2"> <div className="flex items-center justify-between gap-4 py-1.5">
<h3 className="typography-ui-header font-semibold text-foreground">All Rules</h3>
<p className="typography-meta text-muted-foreground/80">
Each rule is shown as permission + pattern. Editing updates the action only.
</p>
</div>
<div className="rounded-lg border border-border/40">
<div className="flex items-center justify-between gap-3 px-3 py-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="typography-ui-label font-medium text-foreground">Global</span> <span className="typography-ui-label text-foreground">Global Default</span>
<span className="typography-micro text-muted-foreground font-mono">*</span> <span className="typography-micro text-muted-foreground/70 font-mono">*</span>
</div> </div>
<Select <Select
value={globalPermission} value={globalPermission}
onValueChange={(value) => setGlobalPermissionAndPrune(value as PermissionAction)} onValueChange={(value) => setGlobalPermissionAndPrune(value as PermissionAction)}
> >
<SelectTrigger className="h-6 w-24 text-xs"> <SelectTrigger className="w-[100px]">
<span className="capitalize">{globalPermission}</span> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="allow" className="pr-2 [&>span:first-child]:hidden">Allow</SelectItem> <SelectItem value="allow">Allow</SelectItem>
<SelectItem value="ask" className="pr-2 [&>span:first-child]:hidden">Ask</SelectItem> <SelectItem value="ask">Ask</SelectItem>
<SelectItem value="deny" className="pr-2 [&>span:first-child]:hidden">Deny</SelectItem> <SelectItem value="deny">Deny</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
</div>
<div className="space-y-6"> <div className="space-y-4">
{summaryPermissionNames.filter((name) => name !== '*').map((permissionName) => { {summaryPermissionNames.filter((name) => name !== '*').map((permissionName) => {
const label = formatPermissionLabel(permissionName); const label = formatPermissionLabel(permissionName);
const { defaultAction, patternRulesCount, patternSummary } = getPermissionSummary(permissionName); const { defaultAction, patternRulesCount } = getPermissionSummary(permissionName);
const wildcardOverride = getWildcardOverride(permissionName); const wildcardOverride = getWildcardOverride(permissionName);
const wildcardValue: string = wildcardOverride ?? 'global'; const wildcardValue: string = wildcardOverride ?? 'global';
const patternRules = getPatternRules(permissionName); const patternRules = getPatternRules(permissionName);
const wildcardOptions = (['allow', 'ask', 'deny'] as const).filter((action) => action !== globalPermission); const wildcardOptions = (['allow', 'ask', 'deny'] as const).filter((action) => action !== globalPermission);
return ( return (
<div key={permissionName} className="rounded-lg border border-border/40"> <div key={permissionName} className="border-t border-[var(--surface-subtle)] pt-2">
<div className="flex items-center justify-between gap-3 px-3 py-2"> <div className="flex items-center justify-between py-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="typography-ui-label font-medium text-foreground">{label}</span> <span className="typography-ui-label text-foreground">{label}</span>
<span className="typography-micro text-muted-foreground font-mono">{permissionName}</span> <span className="typography-micro text-muted-foreground/70 font-mono">{permissionName}</span>
</div> </div>
<div className="flex items-center gap-3"> <div className="typography-micro text-muted-foreground">
{patternRulesCount > 0 ? ( {patternRulesCount > 0 ? `Global: ${defaultAction}` : defaultAction}
<span className="typography-micro text-muted-foreground">Global: {defaultAction}</span>
) : (
<span className="typography-micro text-muted-foreground">{defaultAction}</span>
)}
{patternRulesCount > 0 ? (
<span className="typography-micro text-muted-foreground">Patterns: {patternSummary}</span>
) : null}
</div> </div>
</div> </div>
<div className="space-y-2 border-t border-border/40 px-3 py-2"> <div className="space-y-1 pl-2 mt-1">
<div className="flex flex-wrap items-center justify-between gap-2 p-2"> <div className="flex flex-wrap items-center justify-between gap-2 py-0.5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="typography-micro text-muted-foreground">Pattern</span> <span className="typography-micro text-muted-foreground">Pattern</span>
<span className="typography-micro font-mono text-foreground">*</span> <span className="typography-micro font-mono text-foreground bg-[var(--surface-muted)] px-1 rounded">*</span>
{wildcardOverride ? ( {wildcardOverride && (
<Button <ButtonSmall
size="sm" variant="ghost"
variant="outline"
onClick={() => revertRule(permissionName, '*')} onClick={() => revertRule(permissionName, '*')}
className="h-5 px-2 text-[11px] gap-1" className="px-1.5 py-0 h-5"
> >
<RiSubtractLine className="h-3 w-3" /> <RiSubtractLine className="h-3.5 w-3.5 text-muted-foreground hover:text-foreground" />
Revert </ButtonSmall>
</Button> )}
) : null}
</div> </div>
<Select <Select
value={wildcardValue} value={wildcardValue}
onValueChange={(value) => { onValueChange={(value) => {
@@ -1036,14 +980,14 @@ export const AgentsPage: React.FC = () => {
upsertRule(permissionName, '*', value as PermissionAction); upsertRule(permissionName, '*', value as PermissionAction);
}} }}
> >
<SelectTrigger className="h-6 w-24 text-xs"> <SelectTrigger className="w-[90px]">
<span className="capitalize">{wildcardValue === 'global' ? 'Global' : wildcardValue}</span> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="global" className="pr-2 [&>span:first-child]:hidden">Global</SelectItem> <SelectItem value="global">Global</SelectItem>
{wildcardOptions.map((action) => ( {wildcardOptions.map((action) => (
<SelectItem key={action} value={action} className="pr-2 [&>span:first-child]:hidden"> <SelectItem key={action} value={action} className="capitalize">
{action.charAt(0).toUpperCase() + action.slice(1)} {action}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -1057,50 +1001,33 @@ export const AgentsPage: React.FC = () => {
const isModified = Boolean(baselineRule && baselineRule.action !== rule.action); const isModified = Boolean(baselineRule && baselineRule.action !== rule.action);
return ( return (
<div key={ruleKey} className="flex flex-wrap items-center justify-between gap-2 p-2"> <div key={ruleKey} className="flex flex-wrap items-center justify-between gap-2 py-0.5 border-t border-[var(--surface-subtle)]">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="typography-micro text-muted-foreground">Pattern</span> <span className="typography-micro text-muted-foreground">Pattern</span>
<span className="typography-micro font-mono text-foreground">{rule.pattern}</span> <span className="typography-micro font-mono text-foreground bg-[var(--surface-muted)] px-1 rounded">{rule.pattern}</span>
{isAdded ? ( {isAdded && <span className="typography-micro text-[var(--status-success)]">New</span>}
<span className="typography-micro text-emerald-500">New</span> {isModified && <span className="typography-micro text-[var(--status-warning)]">Modified</span>}
) : null} {(isAdded || isModified) && (
{isModified ? ( <ButtonSmall
<span className="typography-micro text-amber-500">Modified</span> variant="ghost"
) : null} onClick={() => isAdded ? removeRule(rule.permission, rule.pattern) : revertRule(rule.permission, rule.pattern)}
{isAdded ? ( className="px-1.5 py-0 h-5"
<Button
size="sm"
variant="outline"
onClick={() => removeRule(rule.permission, rule.pattern)}
className="h-5 px-2 text-[11px] gap-1"
> >
<RiSubtractLine className="h-3 w-3" /> <RiSubtractLine className="h-3.5 w-3.5 text-muted-foreground hover:text-foreground" />
Remove </ButtonSmall>
</Button> )}
) : isModified ? (
<Button
size="sm"
variant="outline"
onClick={() => revertRule(rule.permission, rule.pattern)}
className="h-5 px-2 text-[11px] gap-1"
>
<RiSubtractLine className="h-3 w-3" />
Revert
</Button>
) : null}
</div> </div>
<Select <Select
value={rule.action} value={rule.action}
onValueChange={(value) => setRuleAction(rule.permission, rule.pattern, value as PermissionAction)} onValueChange={(value) => setRuleAction(rule.permission, rule.pattern, value as PermissionAction)}
> >
<SelectTrigger className="h-6 w-24 text-xs"> <SelectTrigger className="w-[90px]">
<span className="capitalize">{rule.action}</span> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="allow" className="pr-2 [&>span:first-child]:hidden">Allow</SelectItem> <SelectItem value="allow">Allow</SelectItem>
<SelectItem value="ask" className="pr-2 [&>span:first-child]:hidden">Ask</SelectItem> <SelectItem value="ask">Ask</SelectItem>
<SelectItem value="deny" className="pr-2 [&>span:first-child]:hidden">Deny</SelectItem> <SelectItem value="deny">Deny</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -1112,27 +1039,22 @@ export const AgentsPage: React.FC = () => {
})} })}
</div> </div>
<div className="space-y-2"> <div className="border-t border-[var(--surface-subtle)] pt-3">
<h3 className="typography-ui-header font-semibold text-foreground">Add Rule</h3> <h4 className="typography-ui-label text-foreground mb-2">Add Custom Rule</h4>
<p className="typography-meta text-muted-foreground/80"> <div className="flex flex-col sm:flex-row items-end sm:items-center gap-2">
Choose a permission key, set a pattern, and pick an action.
</p>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Select value={pendingRuleName} onValueChange={setPendingRuleName}> <Select value={pendingRuleName} onValueChange={setPendingRuleName}>
<SelectTrigger className="h-8 min-h-8 w-full sm:w-64"> <SelectTrigger className="w-full sm:w-[160px]">
{pendingRuleName ? ( {pendingRuleName ? (
<span className="truncate">{formatPermissionLabel(pendingRuleName)}</span> <span className="truncate">{formatPermissionLabel(pendingRuleName)}</span>
) : ( ) : (
<span className="text-muted-foreground">Permission…</span> <span className="text-muted-foreground">Permission...</span>
)} )}
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{availablePermissionNames.map((name) => ( {availablePermissionNames.map((name) => (
<SelectItem key={name} value={name} className="pr-2 [&>span:first-child]:hidden"> <SelectItem key={name} value={name}>
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-2 w-full">
<span>{formatPermissionLabel(name)}</span> <span>{formatPermissionLabel(name)}</span>
<span className="typography-micro text-muted-foreground font-mono">{name}</span>
</div> </div>
</SelectItem> </SelectItem>
))} ))}
@@ -1143,54 +1065,32 @@ export const AgentsPage: React.FC = () => {
value={pendingRulePattern} value={pendingRulePattern}
onChange={(e) => setPendingRulePattern(e.target.value)} onChange={(e) => setPendingRulePattern(e.target.value)}
placeholder="Pattern (e.g. *)" placeholder="Pattern (e.g. *)"
className="h-8 sm:w-64 font-mono" className="h-7 flex-1 font-mono text-xs"
/> />
<div className="flex gap-1 w-fit"> <div className="flex gap-1">
<Button <ButtonSmall variant="outline" size="xs" className="!font-normal" onClick={() => applyPendingRule('allow')}>Allow</ButtonSmall>
size="sm" <ButtonSmall variant="outline" size="xs" className="!font-normal" onClick={() => applyPendingRule('ask')}>Ask</ButtonSmall>
variant="outline" <ButtonSmall variant="outline" size="xs" className="!font-normal" onClick={() => applyPendingRule('deny')}>Deny</ButtonSmall>
onClick={() => applyPendingRule('allow')}
className="h-8 px-3 text-xs"
>
Allow
</Button>
<Button
size="sm"
variant="outline"
onClick={() => applyPendingRule('ask')}
className="h-8 px-3 text-xs"
>
Ask
</Button>
<Button
size="sm"
variant="outline"
onClick={() => applyPendingRule('deny')}
className="h-8 px-3 text-xs"
>
Deny
</Button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
)} )}
</div>
{} {/* Save action */}
<div className="flex justify-end border-t border-border/40 pt-4"> <div className="px-2 py-1">
<Button <ButtonSmall
size="sm"
variant="default"
onClick={handleSave} onClick={handleSave}
disabled={isSaving || !isDirty} disabled={isSaving || !isDirty}
className="gap-2 h-6 px-2 text-xs w-fit" size="xs"
className="!font-normal"
> >
<RiSaveLine className="h-3 w-3" />
{isSaving ? 'Saving...' : 'Save Changes'} {isSaving ? 'Saving...' : 'Save Changes'}
</Button> </ButtonSmall>
</div>
</div> </div>
</div> </div>
</ScrollableOverlay> </ScrollableOverlay>
); );
@@ -1,8 +1,9 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { Button } from '@/components/ui/button'; import { ButtonSmall } from '@/components/ui/button-small';
import { ButtonLarge } from '@/components/ui/button-large'; import { ButtonLarge } from '@/components/ui/button-large';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { toast } from '@/components/ui'; import { toast } from '@/components/ui';
import { isMobileDeviceViaCSS } from '@/lib/device';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -19,12 +20,10 @@ import {
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { RiAddLine, RiAiAgentFill, RiAiAgentLine, RiDeleteBinLine, RiFileCopyLine, RiMore2Line, RiRobot2Line, RiRobotLine, RiRestartLine, RiEditLine } from '@remixicon/react'; import { RiAddLine, RiAiAgentFill, RiAiAgentLine, RiDeleteBinLine, RiFileCopyLine, RiMore2Line, RiRobot2Line, RiRobotLine, RiRestartLine, RiEditLine } from '@remixicon/react';
import { useAgentsStore, isAgentBuiltIn, isAgentHidden, type AgentScope, type AgentDraft } from '@/stores/useAgentsStore'; import { useAgentsStore, isAgentBuiltIn, isAgentHidden, type AgentScope, type AgentDraft } from '@/stores/useAgentsStore';
import { useUIStore } from '@/stores/useUIStore';
import { useDeviceInfo } from '@/lib/device';
import { isVSCodeRuntime } from '@/lib/desktop';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { Agent } from '@opencode-ai/sdk/v2'; import type { Agent } from '@opencode-ai/sdk/v2';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { SettingsProjectSelector } from '@/components/sections/shared/SettingsProjectSelector';
import { SidebarGroup } from '@/components/sections/shared/SidebarGroup'; import { SidebarGroup } from '@/components/sections/shared/SidebarGroup';
interface AgentsSidebarProps { interface AgentsSidebarProps {
@@ -106,6 +105,7 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
const [confirmActionAgent, setConfirmActionAgent] = React.useState<Agent | null>(null); const [confirmActionAgent, setConfirmActionAgent] = React.useState<Agent | null>(null);
const [confirmActionType, setConfirmActionType] = React.useState<'delete' | 'reset' | null>(null); const [confirmActionType, setConfirmActionType] = React.useState<'delete' | 'reset' | null>(null);
const [isConfirmActionPending, setIsConfirmActionPending] = React.useState(false); const [isConfirmActionPending, setIsConfirmActionPending] = React.useState(false);
const [openMenuAgent, setOpenMenuAgent] = React.useState<string | null>(null);
const { const {
selectedAgentName, selectedAgentName,
@@ -117,16 +117,11 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
loadAgents, loadAgents,
} = useAgentsStore(); } = useAgentsStore();
const { setSidebarOpen } = useUIStore();
const { isMobile } = useDeviceInfo();
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
React.useEffect(() => { React.useEffect(() => {
loadAgents(); loadAgents();
}, [loadAgents]); }, [loadAgents]);
const bgClass = isVSCode ? 'bg-background' : 'bg-sidebar'; const bgClass = 'bg-background';
const handleCreateNew = () => { const handleCreateNew = () => {
// Generate unique name // Generate unique name
@@ -143,9 +138,6 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
setSelectedAgent(newName); setSelectedAgent(newName);
onItemSelect?.(); onItemSelect?.();
if (isMobile) {
setSidebarOpen(false);
}
}; };
const handleDeleteAgent = async (agent: Agent) => { const handleDeleteAgent = async (agent: Agent) => {
@@ -226,9 +218,7 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
}); });
setSelectedAgent(newName); setSelectedAgent(newName);
if (isMobile) {
setSidebarOpen(false);
}
}; };
const handleOpenRenameDialog = (agent: Agent) => { const handleOpenRenameDialog = (agent: Agent) => {
@@ -329,18 +319,18 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
return ( return (
<div className={cn('flex h-full flex-col', bgClass)}> <div className={cn('flex h-full flex-col', bgClass)}>
<div className={cn('border-b px-3', isMobile ? 'mt-2 py-3' : 'py-3')}> <div className="border-b px-3 pt-4 pb-3">
<h2 className="text-base font-semibold text-foreground mb-3">Agents</h2>
<SettingsProjectSelector className="mb-3" />
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground">Total {visibleAgents.length}</span> <span className="typography-meta text-muted-foreground">Total {visibleAgents.length}</span>
<Button <ButtonSmall
type="button"
variant="ghost" variant="ghost"
size="icon" className="h-7 w-7 px-0 -my-1 text-muted-foreground"
className="h-7 w-7 -my-1 text-muted-foreground"
onClick={handleCreateNew} onClick={handleCreateNew}
> >
<RiAddLine className="size-4" /> <RiAddLine className="h-3.5 w-3.5" />
</Button> </ButtonSmall>
</div> </div>
</div> </div>
@@ -366,13 +356,13 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
onSelect={() => { onSelect={() => {
setSelectedAgent(agent.name); setSelectedAgent(agent.name);
onItemSelect?.(); onItemSelect?.();
if (isMobile) {
setSidebarOpen(false);
}
}} }}
onReset={() => handleResetAgent(agent)} onReset={() => handleResetAgent(agent)}
onDuplicate={() => handleDuplicateAgent(agent)} onDuplicate={() => handleDuplicateAgent(agent)}
getAgentModeIcon={getAgentModeIcon} getAgentModeIcon={getAgentModeIcon}
isMenuOpen={openMenuAgent === agent.name}
onMenuOpenChange={(open) => setOpenMenuAgent(open ? agent.name : null)}
/> />
))} ))}
</> </>
@@ -400,14 +390,14 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
onSelect={() => { onSelect={() => {
setSelectedAgent(agent.name); setSelectedAgent(agent.name);
onItemSelect?.(); onItemSelect?.();
if (isMobile) {
setSidebarOpen(false);
}
}} }}
onRename={() => handleOpenRenameDialog(agent)} onRename={() => handleOpenRenameDialog(agent)}
onDelete={() => handleDeleteAgent(agent)} onDelete={() => handleDeleteAgent(agent)}
onDuplicate={() => handleDuplicateAgent(agent)} onDuplicate={() => handleDuplicateAgent(agent)}
getAgentModeIcon={getAgentModeIcon} getAgentModeIcon={getAgentModeIcon}
isMenuOpen={openMenuAgent === agent.name}
onMenuOpenChange={(open) => setOpenMenuAgent(open ? agent.name : null)}
/> />
))} ))}
</SidebarGroup> </SidebarGroup>
@@ -422,14 +412,14 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
onSelect={() => { onSelect={() => {
setSelectedAgent(agent.name); setSelectedAgent(agent.name);
onItemSelect?.(); onItemSelect?.();
if (isMobile) {
setSidebarOpen(false);
}
}} }}
onRename={() => handleOpenRenameDialog(agent)} onRename={() => handleOpenRenameDialog(agent)}
onDelete={() => handleDeleteAgent(agent)} onDelete={() => handleDeleteAgent(agent)}
onDuplicate={() => handleDuplicateAgent(agent)} onDuplicate={() => handleDuplicateAgent(agent)}
getAgentModeIcon={getAgentModeIcon} getAgentModeIcon={getAgentModeIcon}
isMenuOpen={openMenuAgent === agent.name}
onMenuOpenChange={(open) => setOpenMenuAgent(open ? agent.name : null)}
/> />
))} ))}
</> </>
@@ -456,14 +446,13 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button <ButtonLarge
variant="ghost" variant="ghost"
onClick={closeConfirmActionDialog} onClick={closeConfirmActionDialog}
disabled={isConfirmActionPending} disabled={isConfirmActionPending}
className="text-foreground hover:bg-interactive-hover hover:text-foreground"
> >
Cancel Cancel
</Button> </ButtonLarge>
<ButtonLarge onClick={handleConfirmAction} disabled={isConfirmActionPending}> <ButtonLarge onClick={handleConfirmAction} disabled={isConfirmActionPending}>
{confirmActionType === 'delete' ? 'Delete' : 'Reset'} {confirmActionType === 'delete' ? 'Delete' : 'Reset'}
</ButtonLarge> </ButtonLarge>
@@ -492,13 +481,12 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
}} }}
/> />
<DialogFooter> <DialogFooter>
<Button <ButtonLarge
variant="ghost" variant="ghost"
onClick={() => setRenameDialogAgent(null)} onClick={() => setRenameDialogAgent(null)}
className="text-foreground hover:bg-interactive-hover hover:text-foreground"
> >
Cancel Cancel
</Button> </ButtonLarge>
<ButtonLarge onClick={handleRenameAgent}> <ButtonLarge onClick={handleRenameAgent}>
Rename Rename
</ButtonLarge> </ButtonLarge>
@@ -518,6 +506,8 @@ interface AgentListItemProps {
onRename?: () => void; onRename?: () => void;
onDuplicate: () => void; onDuplicate: () => void;
getAgentModeIcon: (mode?: string) => React.ReactNode; getAgentModeIcon: (mode?: string) => React.ReactNode;
isMenuOpen: boolean;
onMenuOpenChange: (open: boolean) => void;
} }
const AgentListItem: React.FC<AgentListItemProps> = ({ const AgentListItem: React.FC<AgentListItemProps> = ({
@@ -529,15 +519,22 @@ const AgentListItem: React.FC<AgentListItemProps> = ({
onRename, onRename,
onDuplicate, onDuplicate,
getAgentModeIcon, getAgentModeIcon,
isMenuOpen,
onMenuOpenChange,
}) => { }) => {
const extAgent = agent as Agent & { scope?: AgentScope }; const extAgent = agent as Agent & { scope?: AgentScope };
const isMobile = isMobileDeviceViaCSS();
return ( return (
<div <div
className={cn( className={cn(
'group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200', 'group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200 select-none',
isSelected ? 'bg-interactive-selection' : 'hover:bg-interactive-hover' isSelected ? 'bg-interactive-selection' : 'hover:bg-interactive-hover'
)} )}
onContextMenu={!isMobile ? (e) => {
e.preventDefault();
onMenuOpenChange(true);
} : undefined}
> >
<div className="flex min-w-0 flex-1 items-center"> <div className="flex min-w-0 flex-1 items-center">
<button <button
@@ -564,15 +561,14 @@ const AgentListItem: React.FC<AgentListItemProps> = ({
)} )}
</button> </button>
<DropdownMenu> <DropdownMenu open={isMenuOpen} onOpenChange={onMenuOpenChange}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <ButtonSmall
size="icon"
variant="ghost" variant="ghost"
className="h-6 w-6 flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100" className="h-6 w-6 px-0 flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"
> >
<RiMore2Line className="h-3.5 w-3.5" /> <RiMore2Line className="h-3.5 w-3.5" />
</Button> </ButtonSmall>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit min-w-20"> <DropdownMenuContent align="end" className="w-fit min-w-20">
{onRename && ( {onRename && (
@@ -57,6 +57,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
}) => { }) => {
const { providers, modelsMetadata } = useConfigStore(); const { providers, modelsMetadata } = useConfigStore();
const isMobile = useUIStore(state => state.isMobile); const isMobile = useUIStore(state => state.isMobile);
const hiddenModels = useUIStore(state => state.hiddenModels);
const { toggleFavoriteModel, isFavoriteModel, addRecentModel } = useUIStore(); const { toggleFavoriteModel, isFavoriteModel, addRecentModel } = useUIStore();
const { favoriteModelsList, recentModelsList } = useModelLists(); const { favoriteModelsList, recentModelsList } = useModelLists();
const { isMobile: deviceIsMobile } = useDeviceInfo(); const { isMobile: deviceIsMobile } = useDeviceInfo();
@@ -77,11 +78,23 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
}, [allowedProviderIds]); }, [allowedProviderIds]);
const visibleProviders = React.useMemo(() => { const visibleProviders = React.useMemo(() => {
if (!allowedProviderSet) { const baseProviders = allowedProviderSet
return providers; ? providers.filter((provider) => allowedProviderSet.has(String(provider.id)))
} : providers;
return providers.filter((provider) => allowedProviderSet.has(String(provider.id)));
}, [providers, allowedProviderSet]); return baseProviders
.map((provider) => {
const providerModels = Array.isArray(provider.models) ? provider.models : [];
const filteredModels = providerModels.filter((model: ProviderModel) => {
const modelId = typeof model?.id === 'string' ? model.id : '';
return !hiddenModels.some(
(hidden) => hidden.providerID === String(provider.id) && hidden.modelID === modelId
);
});
return { ...provider, models: filteredModels };
})
.filter((provider) => provider.models.length > 0);
}, [providers, allowedProviderSet, hiddenModels]);
const closeMobilePanel = () => setIsMobilePanelOpen(false); const closeMobilePanel = () => setIsMobilePanelOpen(false);
const toggleMobileProviderExpansion = (provId: string) => { const toggleMobileProviderExpansion = (provId: string) => {
@@ -250,7 +263,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
<MobileOverlayPanel <MobileOverlayPanel
open={isMobilePanelOpen} open={isMobilePanelOpen}
onClose={closeMobilePanel} onClose={closeMobilePanel}
title="Select Model" title="Select model"
> >
<div className="space-y-1"> <div className="space-y-1">
{/* Favorites Section for Mobile */} {/* Favorites Section for Mobile */}
@@ -499,24 +512,24 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
<DropdownMenu open={isDropdownOpen} onOpenChange={setIsDropdownOpen}> <DropdownMenu open={isDropdownOpen} onOpenChange={setIsDropdownOpen}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<div className={cn( <div className={cn(
'flex items-center gap-2 px-2 rounded-lg bg-interactive-selection/20 border border-border/20 cursor-pointer hover:bg-interactive-hover/30 h-6 w-fit', 'border-input data-[placeholder]:text-muted-foreground flex 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-[state=open]:bg-interactive-active h-6 w-fit',
className className
)}> )}>
{providerId ? ( {providerId ? (
<> <>
<ProviderLogo <ProviderLogo
providerId={providerId} providerId={providerId}
className="h-3 w-3 flex-shrink-0" className="h-3.5 w-3.5 flex-shrink-0"
/> />
<RiPencilAiLine className="h-3 w-3 text-primary/60 hidden" /> <RiPencilAiLine className="h-3 w-3 text-primary/60 hidden" />
</> </>
) : ( ) : (
<RiPencilAiLine className="h-3 w-3 text-muted-foreground" /> <RiPencilAiLine className="h-3.5 w-3.5 text-muted-foreground" />
)} )}
<span className="typography-micro font-medium whitespace-nowrap"> <span className="typography-ui-label font-normal whitespace-nowrap text-foreground">
{providerId && modelId ? `${providerId}/${modelId}` : (placeholder || 'Not selected')} {providerId && modelId ? `${providerId}/${modelId}` : (placeholder || 'Not selected')}
</span> </span>
<RiArrowDownSLine className="h-3 w-3 flex-shrink-0 text-muted-foreground" /> <RiArrowDownSLine className="h-4 w-4 flex-shrink-0 text-muted-foreground/50" />
</div> </div>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent className="w-[min(380px,calc(100vw-2rem))] p-0 flex flex-col" align="start"> <DropdownMenuContent className="w-[min(380px,calc(100vw-2rem))] p-0 flex flex-col" align="start">
@@ -48,9 +48,23 @@ export const AgentSelector: React.FC<AgentSelectorProps> = ({
<MobileOverlayPanel <MobileOverlayPanel
open={isMobilePanelOpen} open={isMobilePanelOpen}
onClose={closeMobilePanel} onClose={closeMobilePanel}
title="Select Agent" title="Select agent"
> >
<div className="space-y-1"> <div className="space-y-1">
<button
type="button"
className={cn(
'flex w-full items-center justify-between rounded-lg border border-border/40 bg-background/95 px-2 py-1.5 text-left',
!agentName ? 'bg-primary/10 text-primary' : 'text-foreground'
)}
onClick={() => {
handleAgentChange('');
closeMobilePanel();
}}
>
<span className={cn('typography-meta', !agentName ? 'font-medium' : 'text-muted-foreground')}>Not selected</span>
{!agentName && <div className="h-2 w-2 rounded-full bg-primary" />}
</button>
{agents.map((agent) => { {agents.map((agent) => {
const isSelected = agent.name === agentName; const isSelected = agent.name === agentName;
@@ -81,17 +95,6 @@ export const AgentSelector: React.FC<AgentSelectorProps> = ({
</button> </button>
); );
})} })}
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border border-border/40 bg-background/95 px-2 py-1.5 text-left"
onClick={() => {
handleAgentChange('');
closeMobilePanel();
}}
>
<span className="typography-meta text-muted-foreground">No agent (optional)</span>
</button>
</div> </div>
</MobileOverlayPanel> </MobileOverlayPanel>
); );
@@ -131,6 +134,12 @@ export const AgentSelector: React.FC<AgentSelectorProps> = ({
</div> </div>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent className="max-w-[300px]"> <DropdownMenuContent className="max-w-[300px]">
<DropdownMenuItem
className="typography-meta"
onSelect={() => handleAgentChange('')}
>
<span className="text-muted-foreground">Not selected</span>
</DropdownMenuItem>
{agents.map((agent) => ( {agents.map((agent) => (
<DropdownMenuItem <DropdownMenuItem
key={agent.name} key={agent.name}
@@ -140,12 +149,6 @@ export const AgentSelector: React.FC<AgentSelectorProps> = ({
<span className="font-medium">{agent.name}</span> <span className="font-medium">{agent.name}</span>
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
<DropdownMenuItem
className="typography-meta"
onSelect={() => handleAgentChange('')}
>
<span className="text-muted-foreground">No agent (optional)</span>
</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
)} )}
@@ -1,20 +1,19 @@
import React from 'react'; import React from 'react';
import { Button } from '@/components/ui/button'; import { ButtonSmall } from '@/components/ui/button-small';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Checkbox } from '@/components/ui/checkbox';
import { toast } from '@/components/ui'; import { toast } from '@/components/ui';
import { useCommandsStore, type CommandConfig, type CommandScope } from '@/stores/useCommandsStore'; import { useCommandsStore, type CommandConfig, type CommandScope } from '@/stores/useCommandsStore';
import { RiInformationLine, RiSaveLine, RiTerminalBoxLine, RiUser3Line, RiFolderLine } from '@remixicon/react'; import { RiTerminalBoxLine, RiUser3Line, RiFolderLine } from '@remixicon/react';
import { ModelSelector } from '../agents/ModelSelector'; import { ModelSelector } from '../agents/ModelSelector';
import { AgentSelector } from './AgentSelector'; import { AgentSelector } from './AgentSelector';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
export const CommandsPage: React.FC = () => { export const CommandsPage: React.FC = () => {
@@ -29,7 +28,6 @@ export const CommandsPage: React.FC = () => {
const [agent, setAgent] = React.useState(''); const [agent, setAgent] = React.useState('');
const [model, setModel] = React.useState(''); const [model, setModel] = React.useState('');
const [template, setTemplate] = React.useState(''); const [template, setTemplate] = React.useState('');
const [subtask, setSubtask] = React.useState(false);
const [isSaving, setIsSaving] = React.useState(false); const [isSaving, setIsSaving] = React.useState(false);
const initialStateRef = React.useRef<{ const initialStateRef = React.useRef<{
draftName: string; draftName: string;
@@ -38,27 +36,22 @@ export const CommandsPage: React.FC = () => {
agent: string; agent: string;
model: string; model: string;
template: string; template: string;
subtask: boolean;
} | null>(null); } | null>(null);
React.useEffect(() => { React.useEffect(() => {
if (isNewCommand && commandDraft) { if (isNewCommand && commandDraft) {
// Prefill from draft (for new or duplicated commands)
const draftNameValue = commandDraft.name || ''; const draftNameValue = commandDraft.name || '';
const draftScopeValue = commandDraft.scope || 'user'; const draftScopeValue = commandDraft.scope || 'user';
const descriptionValue = commandDraft.description || ''; const descriptionValue = commandDraft.description || '';
const agentValue = commandDraft.agent || ''; const agentValue = commandDraft.agent || '';
const modelValue = commandDraft.model || ''; const modelValue = commandDraft.model || '';
const templateValue = commandDraft.template || ''; const templateValue = commandDraft.template || '';
const subtaskValue = commandDraft.subtask || false;
setDraftName(draftNameValue); setDraftName(draftNameValue);
setDraftScope(draftScopeValue); setDraftScope(draftScopeValue);
setDescription(descriptionValue); setDescription(descriptionValue);
setAgent(agentValue); setAgent(agentValue);
setModel(modelValue); setModel(modelValue);
setTemplate(templateValue); setTemplate(templateValue);
setSubtask(subtaskValue);
initialStateRef.current = { initialStateRef.current = {
draftName: draftNameValue, draftName: draftNameValue,
@@ -67,20 +60,16 @@ export const CommandsPage: React.FC = () => {
agent: agentValue, agent: agentValue,
model: modelValue, model: modelValue,
template: templateValue, template: templateValue,
subtask: subtaskValue,
}; };
} else if (selectedCommand) { } else if (selectedCommand) {
const descriptionValue = selectedCommand.description || ''; const descriptionValue = selectedCommand.description || '';
const agentValue = selectedCommand.agent || ''; const agentValue = selectedCommand.agent || '';
const modelValue = selectedCommand.model || ''; const modelValue = selectedCommand.model || '';
const templateValue = selectedCommand.template || ''; const templateValue = selectedCommand.template || '';
const subtaskValue = selectedCommand.subtask || false;
setDescription(descriptionValue); setDescription(descriptionValue);
setAgent(agentValue); setAgent(agentValue);
setModel(modelValue); setModel(modelValue);
setTemplate(templateValue); setTemplate(templateValue);
setSubtask(subtaskValue);
initialStateRef.current = { initialStateRef.current = {
draftName: '', draftName: '',
@@ -89,7 +78,6 @@ export const CommandsPage: React.FC = () => {
agent: agentValue, agent: agentValue,
model: modelValue, model: modelValue,
template: templateValue, template: templateValue,
subtask: subtaskValue,
}; };
} }
}, [selectedCommand, isNewCommand, selectedCommandName, commands, commandDraft]); }, [selectedCommand, isNewCommand, selectedCommandName, commands, commandDraft]);
@@ -109,10 +97,8 @@ export const CommandsPage: React.FC = () => {
if (agent !== initial.agent) return true; if (agent !== initial.agent) return true;
if (model !== initial.model) return true; if (model !== initial.model) return true;
if (template !== initial.template) return true; if (template !== initial.template) return true;
if (subtask !== initial.subtask) return true;
return false; return false;
}, [agent, description, draftName, draftScope, isNewCommand, model, subtask, template]); }, [agent, description, draftName, draftScope, isNewCommand, model, template]);
const handleSave = async () => { const handleSave = async () => {
const commandName = isNewCommand ? draftName.trim().replace(/\s+/g, '-') : selectedCommandName?.trim(); const commandName = isNewCommand ? draftName.trim().replace(/\s+/g, '-') : selectedCommandName?.trim();
@@ -127,7 +113,6 @@ export const CommandsPage: React.FC = () => {
return; return;
} }
// Check for duplicate name when creating new command
if (isNewCommand && commands.some((cmd) => cmd.name === commandName)) { if (isNewCommand && commands.some((cmd) => cmd.name === commandName)) {
toast.error('A command with this name already exists'); toast.error('A command with this name already exists');
return; return;
@@ -145,7 +130,6 @@ export const CommandsPage: React.FC = () => {
agent: trimmedAgent === '' ? null : trimmedAgent, agent: trimmedAgent === '' ? null : trimmedAgent,
model: trimmedModel === '' ? null : trimmedModel, model: trimmedModel === '' ? null : trimmedModel,
template: trimmedTemplate, template: trimmedTemplate,
subtask,
scope: isNewCommand ? draftScope : undefined, scope: isNewCommand ? draftScope : undefined,
}; };
@@ -153,7 +137,7 @@ export const CommandsPage: React.FC = () => {
if (isNewCommand) { if (isNewCommand) {
success = await createCommand(config); success = await createCommand(config);
if (success) { if (success) {
setCommandDraft(null); // Clear draft after successful creation setCommandDraft(null);
} }
} else { } else {
success = await updateCommand(commandName, config); success = await updateCommand(commandName, config);
@@ -186,65 +170,61 @@ export const CommandsPage: React.FC = () => {
return ( return (
<ScrollableOverlay keyboardAvoid outerClassName="h-full" className="w-full"> <ScrollableOverlay keyboardAvoid outerClassName="h-full" className="w-full">
<div className="mx-auto max-w-3xl space-y-6 p-6"> <div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8">
{/* Header */}
<div className="space-y-1">
<h1 className="typography-ui-header font-semibold text-lg">
{isNewCommand ? 'New Command' : `/${selectedCommandName}`}
</h1>
</div>
{} {/* Header */}
<div className="space-y-4"> <div className="mb-4 flex items-center justify-between gap-4">
<div className="space-y-1"> <div className="min-w-0">
<h2 className="typography-ui-header font-semibold text-foreground">Basic Information</h2> <h2 className="typography-ui-header font-semibold text-foreground truncate">
<p className="typography-meta text-muted-foreground/80"> {isNewCommand ? 'New Command' : `/${selectedCommandName}`}
Configure command identity and metadata </h2>
<p className="typography-meta text-muted-foreground truncate">
{isNewCommand ? 'Configure a new slash command' : 'Edit command settings'}
</p> </p>
</div> </div>
</div>
{/* Identity */}
<div className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">
Identity
</h3>
</div>
<section className="px-2 pb-2 pt-0 space-y-0">
{isNewCommand && ( {isNewCommand && (
<div className="space-y-2"> <div className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
<label className="typography-ui-label font-medium text-foreground"> <div className="flex min-w-0 flex-col sm:w-56 shrink-0">
Command Name & Scope <span className="typography-ui-label text-foreground">Command Name</span>
</label> </div>
<div className="flex items-center gap-2"> <div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial">
<div className="flex items-center flex-1"> <div className="flex items-center">
<span className="typography-ui-label text-muted-foreground mr-1">/</span> <span className="typography-ui-label text-muted-foreground mr-1">/</span>
<Input <Input
value={draftName} value={draftName}
onChange={(e) => setDraftName(e.target.value)} onChange={(e) => setDraftName(e.target.value)}
placeholder="command-name" placeholder="command-name"
className="flex-1 text-foreground placeholder:text-muted-foreground" className="h-7 w-40 px-2"
/> />
</div> </div>
<Select value={draftScope} onValueChange={(v) => setDraftScope(v as CommandScope)}> <Select value={draftScope} onValueChange={(v) => setDraftScope(v as CommandScope)}>
<SelectTrigger className="!h-9 w-auto gap-1.5"> <SelectTrigger className="w-fit min-w-[100px]">
{draftScope === 'user' ? ( <SelectValue placeholder="Scope" />
<RiUser3Line className="h-4 w-4" />
) : (
<RiFolderLine className="h-4 w-4" />
)}
<span className="capitalize">{draftScope}</span>
</SelectTrigger> </SelectTrigger>
<SelectContent align="end"> <SelectContent align="end">
<SelectItem value="user" className="pr-2 [&>span:first-child]:hidden"> <SelectItem value="user">
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<RiUser3Line className="h-4 w-4" /> <RiUser3Line className="h-3.5 w-3.5" />
<span>User</span> <span>Global</span>
</div>
<span className="typography-micro text-muted-foreground ml-6">Available in all projects</span>
</div> </div>
</SelectItem> </SelectItem>
<SelectItem value="project" className="pr-2 [&>span:first-child]:hidden"> <SelectItem value="project">
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<RiFolderLine className="h-4 w-4" /> <RiFolderLine className="h-3.5 w-3.5" />
<span>Project</span> <span>Project</span>
</div> </div>
<span className="typography-micro text-muted-foreground ml-6">Only in current project</span>
</div>
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -252,45 +232,49 @@ export const CommandsPage: React.FC = () => {
</div> </div>
)} )}
<div className="space-y-2"> <div className="py-1.5">
<label className="typography-ui-label font-medium text-foreground"> <span className="typography-ui-label text-foreground">Description</span>
Description <div className="mt-1.5">
</label>
<Textarea <Textarea
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
placeholder="What does this command do?" placeholder="What does this command do?"
rows={3} rows={2}
className="w-full resize-none min-h-[60px] bg-transparent"
/> />
</div> </div>
</div> </div>
{} </section>
<div className="space-y-4">
<div className="space-y-1">
<h2 className="typography-h2 font-semibold text-foreground">Model & Agent Configuration</h2>
<p className="typography-meta text-muted-foreground/80">
Configure model and agent for command execution
</p>
</div> </div>
<div className="space-y-2"> {/* Execution Context */}
<label className="typography-ui-label font-medium text-foreground"> <div className="mb-8">
Agent <div className="mb-1 px-1">
</label> <h3 className="typography-ui-header font-medium text-foreground">
Execution Context
</h3>
</div>
<section className="px-2 pb-2 pt-0 space-y-0">
<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">Override Agent</span>
</div>
<div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial">
<AgentSelector <AgentSelector
agentName={agent} agentName={agent}
onChange={(agentName: string) => setAgent(agentName)} onChange={(agentName: string) => setAgent(agentName)}
/> />
<p className="typography-meta text-muted-foreground"> </div>
Agent to execute this command (optional)
</p>
</div> </div>
<div className="space-y-2"> <div className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
<label className="typography-ui-label font-medium text-foreground"> <div className="flex min-w-0 flex-col sm:w-56 shrink-0">
Model <span className="typography-ui-label text-foreground">Override Model</span>
</label> </div>
<div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial">
<ModelSelector <ModelSelector
providerId={model ? model.split('/')[0] : ''} providerId={model ? model.split('/')[0] : ''}
modelId={model ? model.split('/')[1] : ''} modelId={model ? model.split('/')[1] : ''}
@@ -302,115 +286,51 @@ export const CommandsPage: React.FC = () => {
} }
}} }}
/> />
<p className="typography-meta text-muted-foreground">
Default model for this command (optional)
</p>
</div>
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground flex items-center gap-2 cursor-pointer">
<Checkbox
checked={subtask}
onChange={(checked) => setSubtask(checked)}
/>
Force Subagent Invocation
</label>
<div className="flex items-center gap-2">
<p className="typography-meta text-muted-foreground">
Force command to run in a subagent context
</p>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
When enabled, this command will always execute in a subagent context,<br/>
even if triggered from main agent.<br/>
Useful for isolating command logic and maintaining clean separation of concerns.
</TooltipContent>
</Tooltip>
</div>
</div> </div>
</div> </div>
{} </section>
<div className="space-y-4">
<div className="space-y-1">
<h2 className="typography-h2 font-semibold text-foreground">Command Template</h2>
<p className="typography-meta text-muted-foreground/80">
Define the prompt template for this command. Use $ARGUMENTS for user input.
</p>
</div> </div>
{/* Command Template */}
<div className="mb-2">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">
Command Template
</h3>
</div>
<section className="px-2 pb-2 pt-0">
<Textarea <Textarea
value={template} value={template}
onChange={(e) => setTemplate(e.target.value)} onChange={(e) => setTemplate(e.target.value)}
placeholder={`Your command template here... placeholder={`Your command template here...\n\nUse $ARGUMENTS to reference user input.\nUse !\`shell command\` to inject shell output.\nUse @filename to include file contents.`}
Use $ARGUMENTS to reference user input.
Use !\`shell command\` to inject shell output.
Use @filename to include file contents.`}
rows={12} rows={12}
className="font-mono typography-meta" className="w-full font-mono typography-meta min-h-[160px] max-h-[60vh] bg-transparent resize-y"
/> />
<div className="typography-meta text-muted-foreground/80 space-y-1"> </section>
<p className="font-medium">Template Features:</p>
<ul className="list-disc list-inside space-y-0.5 ml-2"> <div className="mt-2 px-2">
<li className="flex items-center gap-2"> <p className="typography-meta text-muted-foreground">
<code className="bg-muted px-1 rounded">$ARGUMENTS</code> <code className="text-foreground">$ARGUMENTS</code> user input &middot;{' '}
<span>- User input after command</span> <code className="text-foreground">!`cmd`</code> shell output &middot;{' '}
<Tooltip delayDuration={1000}> <code className="text-foreground">@file</code> file contents
<TooltipTrigger asChild> </p>
<RiInformationLine className="h-3 w-3 text-muted-foreground/60 cursor-help" /> </div>
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
Replaced with everything the user types after the command name.<br/>
Example: "/deploy staging" makes $ARGUMENTS = "staging"
</TooltipContent>
</Tooltip>
</li>
<li className="flex items-center gap-2">
<code className="bg-muted px-1 rounded">!`command`</code>
<span>- Inject shell command output</span>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3 w-3 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
Executes shell command and replaces this placeholder with its output.<br/>
Example: !`git branch --show-current` gets current branch name
</TooltipContent>
</Tooltip>
</li>
<li className="flex items-center gap-2">
<code className="bg-muted px-1 rounded">@filename</code>
<span>- Include file contents</span>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3 w-3 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
Replaces with the full contents of the specified file.<br/>
Example: @package.json includes the package.json content in the prompt
</TooltipContent>
</Tooltip>
</li>
</ul>
</div> </div>
{} {/* Save action */}
<div className="flex justify-end border-t border-border/40 pt-4"> <div className="px-2 py-1">
<Button <ButtonSmall
size="sm"
variant="default"
onClick={handleSave} onClick={handleSave}
disabled={isSaving || !isDirty} disabled={isSaving || !isDirty}
className="gap-2 h-6 px-2 text-xs w-fit" size="xs"
className="!font-normal"
> >
<RiSaveLine className="h-3 w-3" />
{isSaving ? 'Saving...' : 'Save Changes'} {isSaving ? 'Saving...' : 'Save Changes'}
</Button> </ButtonSmall>
</div>
</div> </div>
</div> </div>
</ScrollableOverlay> </ScrollableOverlay>
); );
@@ -1,8 +1,9 @@
import React from 'react'; import React from 'react';
import { Button } from '@/components/ui/button'; import { ButtonSmall } from '@/components/ui/button-small';
import { ButtonLarge } from '@/components/ui/button-large'; import { ButtonLarge } from '@/components/ui/button-large';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { toast } from '@/components/ui'; import { toast } from '@/components/ui';
import { isMobileDeviceViaCSS } from '@/lib/device';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -20,11 +21,9 @@ import {
import { RiAddLine, RiTerminalBoxLine, RiMore2Line, RiDeleteBinLine, RiFileCopyLine, RiRestartLine, RiEditLine } from '@remixicon/react'; import { RiAddLine, RiTerminalBoxLine, RiMore2Line, RiDeleteBinLine, RiFileCopyLine, RiRestartLine, RiEditLine } from '@remixicon/react';
import { useCommandsStore, isCommandBuiltIn, type Command } from '@/stores/useCommandsStore'; import { useCommandsStore, isCommandBuiltIn, type Command } from '@/stores/useCommandsStore';
import { useSkillsStore } from '@/stores/useSkillsStore'; import { useSkillsStore } from '@/stores/useSkillsStore';
import { useUIStore } from '@/stores/useUIStore';
import { useDeviceInfo } from '@/lib/device';
import { isVSCodeRuntime } from '@/lib/desktop';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { SettingsProjectSelector } from '@/components/sections/shared/SettingsProjectSelector';
interface CommandsSidebarProps { interface CommandsSidebarProps {
onItemSelect?: () => void; onItemSelect?: () => void;
@@ -36,6 +35,7 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
const [confirmActionCommand, setConfirmActionCommand] = React.useState<Command | null>(null); const [confirmActionCommand, setConfirmActionCommand] = React.useState<Command | null>(null);
const [confirmActionType, setConfirmActionType] = React.useState<'delete' | 'reset' | null>(null); const [confirmActionType, setConfirmActionType] = React.useState<'delete' | 'reset' | null>(null);
const [isConfirmActionPending, setIsConfirmActionPending] = React.useState(false); const [isConfirmActionPending, setIsConfirmActionPending] = React.useState(false);
const [openMenuCommand, setOpenMenuCommand] = React.useState<string | null>(null);
const { const {
selectedCommandName, selectedCommandName,
@@ -48,11 +48,6 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
} = useCommandsStore(); } = useCommandsStore();
const { skills, loadSkills } = useSkillsStore(); const { skills, loadSkills } = useSkillsStore();
const { setSidebarOpen } = useUIStore();
const { isMobile } = useDeviceInfo();
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
React.useEffect(() => { React.useEffect(() => {
loadCommands(); loadCommands();
loadSkills(); loadSkills();
@@ -74,7 +69,7 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
} }
}, [selectedCommandName, setSelectedCommand, skillNames]); }, [selectedCommandName, setSelectedCommand, skillNames]);
const bgClass = isVSCode ? 'bg-background' : 'bg-sidebar'; const bgClass = 'bg-background';
const handleCreateNew = () => { const handleCreateNew = () => {
// Generate unique name // Generate unique name
@@ -91,9 +86,7 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
setSelectedCommand(newName); setSelectedCommand(newName);
onItemSelect?.(); onItemSelect?.();
if (isMobile) {
setSidebarOpen(false);
}
}; };
const handleDeleteCommand = async (command: Command) => { const handleDeleteCommand = async (command: Command) => {
@@ -162,13 +155,10 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
template: command.template, template: command.template,
agent: command.agent, agent: command.agent,
model: command.model, model: command.model,
subtask: command.subtask,
}); });
setSelectedCommand(newName); setSelectedCommand(newName);
if (isMobile) {
setSidebarOpen(false);
}
}; };
const handleOpenRenameDialog = (command: Command) => { const handleOpenRenameDialog = (command: Command) => {
@@ -203,7 +193,6 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
template: renameDialogCommand.template, template: renameDialogCommand.template,
agent: renameDialogCommand.agent, agent: renameDialogCommand.agent,
model: renameDialogCommand.model, model: renameDialogCommand.model,
subtask: renameDialogCommand.subtask,
}); });
if (success) { if (success) {
@@ -227,18 +216,18 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
return ( return (
<div className={cn('flex h-full flex-col', bgClass)}> <div className={cn('flex h-full flex-col', bgClass)}>
<div className={cn('border-b px-3', isMobile ? 'mt-2 py-3' : 'py-3')}> <div className="border-b px-3 pt-4 pb-3">
<h2 className="text-base font-semibold text-foreground mb-3">Commands</h2>
<SettingsProjectSelector className="mb-3" />
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground">Total {commandOnlyItems.length}</span> <span className="typography-meta text-muted-foreground">Total {commandOnlyItems.length}</span>
<Button <ButtonSmall
type="button"
variant="ghost" variant="ghost"
size="icon" className="h-7 w-7 px-0 -my-1 text-muted-foreground"
className="h-7 w-7 -my-1 text-muted-foreground"
onClick={handleCreateNew} onClick={handleCreateNew}
> >
<RiAddLine className="size-4" /> <RiAddLine className="h-3.5 w-3.5" />
</Button> </ButtonSmall>
</div> </div>
</div> </div>
@@ -264,12 +253,12 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
onSelect={() => { onSelect={() => {
setSelectedCommand(command.name); setSelectedCommand(command.name);
onItemSelect?.(); onItemSelect?.();
if (isMobile) {
setSidebarOpen(false);
}
}} }}
onReset={() => handleResetCommand(command)} onReset={() => handleResetCommand(command)}
onDuplicate={() => handleDuplicateCommand(command)} onDuplicate={() => handleDuplicateCommand(command)}
isMenuOpen={openMenuCommand === command.name}
onMenuOpenChange={(open) => setOpenMenuCommand(open ? command.name : null)}
/> />
))} ))}
</> </>
@@ -288,13 +277,13 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
onSelect={() => { onSelect={() => {
setSelectedCommand(command.name); setSelectedCommand(command.name);
onItemSelect?.(); onItemSelect?.();
if (isMobile) {
setSidebarOpen(false);
}
}} }}
onRename={() => handleOpenRenameDialog(command)} onRename={() => handleOpenRenameDialog(command)}
onDelete={() => handleDeleteCommand(command)} onDelete={() => handleDeleteCommand(command)}
onDuplicate={() => handleDuplicateCommand(command)} onDuplicate={() => handleDuplicateCommand(command)}
isMenuOpen={openMenuCommand === command.name}
onMenuOpenChange={(open) => setOpenMenuCommand(open ? command.name : null)}
/> />
))} ))}
</> </>
@@ -321,14 +310,13 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button <ButtonLarge
variant="ghost" variant="ghost"
onClick={closeConfirmActionDialog} onClick={closeConfirmActionDialog}
disabled={isConfirmActionPending} disabled={isConfirmActionPending}
className="text-foreground hover:bg-interactive-hover hover:text-foreground"
> >
Cancel Cancel
</Button> </ButtonLarge>
<ButtonLarge onClick={handleConfirmAction} disabled={isConfirmActionPending}> <ButtonLarge onClick={handleConfirmAction} disabled={isConfirmActionPending}>
{confirmActionType === 'delete' ? 'Delete' : 'Reset'} {confirmActionType === 'delete' ? 'Delete' : 'Reset'}
</ButtonLarge> </ButtonLarge>
@@ -357,13 +345,12 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
}} }}
/> />
<DialogFooter> <DialogFooter>
<Button <ButtonLarge
variant="ghost" variant="ghost"
onClick={() => setRenameDialogCommand(null)} onClick={() => setRenameDialogCommand(null)}
className="text-foreground hover:bg-interactive-hover hover:text-foreground"
> >
Cancel Cancel
</Button> </ButtonLarge>
<ButtonLarge onClick={handleRenameCommand}> <ButtonLarge onClick={handleRenameCommand}>
Rename Rename
</ButtonLarge> </ButtonLarge>
@@ -382,6 +369,8 @@ interface CommandListItemProps {
onReset?: () => void; onReset?: () => void;
onRename?: () => void; onRename?: () => void;
onDuplicate: () => void; onDuplicate: () => void;
isMenuOpen: boolean;
onMenuOpenChange: (open: boolean) => void;
} }
const CommandListItem: React.FC<CommandListItemProps> = ({ const CommandListItem: React.FC<CommandListItemProps> = ({
@@ -392,13 +381,20 @@ const CommandListItem: React.FC<CommandListItemProps> = ({
onReset, onReset,
onRename, onRename,
onDuplicate, onDuplicate,
isMenuOpen,
onMenuOpenChange,
}) => { }) => {
const isMobile = isMobileDeviceViaCSS();
return ( return (
<div <div
className={cn( className={cn(
'group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200', 'group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200 select-none',
isSelected ? 'bg-interactive-selection' : 'hover:bg-interactive-hover' isSelected ? 'bg-interactive-selection' : 'hover:bg-interactive-hover'
)} )}
onContextMenu={!isMobile ? (e) => {
e.preventDefault();
onMenuOpenChange(true);
} : undefined}
> >
<div className="flex min-w-0 flex-1 items-center"> <div className="flex min-w-0 flex-1 items-center">
<button <button
@@ -424,15 +420,14 @@ const CommandListItem: React.FC<CommandListItemProps> = ({
)} )}
</button> </button>
<DropdownMenu> <DropdownMenu open={isMenuOpen} onOpenChange={onMenuOpenChange}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <ButtonSmall
size="icon"
variant="ghost" variant="ghost"
className="h-6 w-6 flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100" className="h-6 w-6 px-0 flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"
> >
<RiMore2Line className="h-3.5 w-3.5" /> <RiMore2Line className="h-3.5 w-3.5" />
</Button> </ButtonSmall>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit min-w-20"> <DropdownMenuContent align="end" className="w-fit min-w-20">
{onRename && ( {onRename && (
@@ -1,533 +0,0 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { toast } from '@/components/ui';
import { useGitIdentitiesStore, type GitIdentityProfile, type GitIdentityAuthType } from '@/stores/useGitIdentitiesStore';
import {
RiUser3Line,
RiSaveLine,
RiDeleteBinLine,
RiGitBranchLine,
RiBriefcaseLine,
RiHomeLine,
RiGraduationCapLine,
RiCodeLine,
RiInformationLine,
RiKeyLine,
RiLock2Line
} from '@remixicon/react';
import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
const PROFILE_COLORS = [
{ key: 'keyword', label: 'Green', cssVar: 'var(--syntax-keyword)' },
{ key: 'error', label: 'Red', cssVar: 'var(--status-error)' },
{ key: 'string', label: 'Cyan', cssVar: 'var(--syntax-string)' },
{ key: 'function', label: 'Orange', cssVar: 'var(--syntax-function)' },
{ key: 'type', label: 'Yellow', cssVar: 'var(--syntax-type)' },
];
const PROFILE_ICONS = [
{ key: 'branch', Icon: RiGitBranchLine, label: 'Branch' },
{ key: 'briefcase', Icon: RiBriefcaseLine, label: 'Work' },
{ key: 'house', Icon: RiHomeLine, label: 'Personal' },
{ key: 'graduation', Icon: RiGraduationCapLine, label: 'School' },
{ key: 'code', Icon: RiCodeLine, label: 'Code' },
];
export const GitIdentitiesPage: React.FC = () => {
const {
selectedProfileId,
getProfileById,
createProfile,
updateProfile,
deleteProfile,
} = useGitIdentitiesStore();
// Parse import: prefix for credential import flow
const importData = React.useMemo(() => {
if (selectedProfileId?.startsWith('import:')) {
const [, host, username] = selectedProfileId.split(':');
return { host, username };
}
return null;
}, [selectedProfileId]);
const selectedProfile = React.useMemo(() =>
selectedProfileId && selectedProfileId !== 'new' && !importData ? getProfileById(selectedProfileId) : null,
[selectedProfileId, getProfileById, importData]
);
const isNewProfile = selectedProfileId === 'new' || importData !== null;
const isGlobalProfile = selectedProfileId === 'global';
const [name, setName] = React.useState('');
const [userName, setUserName] = React.useState('');
const [userEmail, setUserEmail] = React.useState('');
const [authType, setAuthType] = React.useState<GitIdentityAuthType>('ssh');
const [sshKey, setSshKey] = React.useState('');
const [host, setHost] = React.useState('');
const [color, setColor] = React.useState('keyword');
const [icon, setIcon] = React.useState('branch');
const [isSaving, setIsSaving] = React.useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false);
const [isDeleting, setIsDeleting] = React.useState(false);
React.useEffect(() => {
if (importData) {
const parts = importData.host.split('/');
const displayName = parts.length >= 3 ? parts[parts.length - 1] : importData.host;
setName(displayName);
setUserName(importData.username);
setUserEmail('');
setAuthType('token');
setSshKey('');
setHost(importData.host);
setColor('string');
setIcon('code');
} else if (isNewProfile) {
setName('');
setUserName('');
setUserEmail('');
setAuthType('ssh');
setSshKey('');
setHost('');
setColor('keyword');
setIcon('branch');
} else if (selectedProfile) {
setName(selectedProfile.name);
setUserName(selectedProfile.userName);
setUserEmail(selectedProfile.userEmail);
setAuthType(selectedProfile.authType || 'ssh');
setSshKey(selectedProfile.sshKey || '');
setHost(selectedProfile.host || '');
setColor(selectedProfile.color || 'keyword');
setIcon(selectedProfile.icon || 'branch');
}
}, [selectedProfile, isNewProfile, selectedProfileId, importData]);
const handleSave = async () => {
if (!userName.trim() || !userEmail.trim()) {
toast.error('User name and email are required');
return;
}
if (authType === 'token' && !host.trim()) {
toast.error('Host is required for token-based authentication');
return;
}
setIsSaving(true);
try {
const profileData: Omit<GitIdentityProfile, 'id'> & { id?: string } = {
name: name.trim() || userName.trim(),
userName: userName.trim(),
userEmail: userEmail.trim(),
authType,
sshKey: authType === 'ssh' ? (sshKey.trim() || null) : null,
host: authType === 'token' ? (host.trim() || null) : null,
color,
icon,
};
let success: boolean;
if (isNewProfile) {
success = await createProfile(profileData);
} else if (selectedProfileId) {
success = await updateProfile(selectedProfileId, profileData);
} else {
return;
}
if (success) {
toast.success(isNewProfile ? 'Profile created successfully' : 'Profile updated successfully');
} else {
toast.error(isNewProfile ? 'Failed to create profile' : 'Failed to update profile');
}
} catch (error) {
console.error('Error saving profile:', error);
toast.error('An error occurred while saving');
} finally {
setIsSaving(false);
}
};
const handleDelete = () => {
if (!selectedProfileId || isNewProfile) return;
setIsDeleteDialogOpen(true);
};
const handleConfirmDelete = async () => {
if (!selectedProfileId || isNewProfile) {
return;
}
setIsDeleting(true);
try {
const success = await deleteProfile(selectedProfileId);
if (success) {
toast.success('Profile deleted successfully');
setIsDeleteDialogOpen(false);
} else {
toast.error('Failed to delete profile');
}
} catch (error) {
console.error('Error deleting profile:', error);
toast.error('An error occurred while deleting');
} finally {
setIsDeleting(false);
}
};
const currentColorValue = React.useMemo(() => {
const colorConfig = PROFILE_COLORS.find(c => c.key === color);
return colorConfig?.cssVar || 'var(--syntax-keyword)';
}, [color]);
if (!selectedProfileId) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center text-muted-foreground">
<RiUser3Line className="mx-auto mb-3 h-12 w-12 opacity-50" />
<p className="typography-body">Select a profile from the sidebar</p>
<p className="typography-meta mt-1 opacity-75">or create a new one</p>
</div>
</div>
);
}
return (
<ScrollableOverlay outerClassName="h-full" className="w-full">
<div className="mx-auto max-w-3xl space-y-6 p-6">
{/* Header */}
<div className="space-y-1">
<h1 className="typography-ui-header font-semibold text-lg">
{importData ? 'Import Credential' : isNewProfile ? 'New Git Profile' : isGlobalProfile ? 'Global Identity' : name || 'Edit Profile'}
</h1>
<p className="typography-body text-muted-foreground mt-1">
{importData
? `Import token credential for ${importData.host} - please fill in your email address`
: isNewProfile
? 'Create a new Git identity profile for your repositories'
: isGlobalProfile
? 'System-wide Git identity from global configuration (read-only)'
: 'Configure Git identity settings for this profile'}
</p>
</div>
{}
{!isGlobalProfile && (
<div className="space-y-4">
<div className="space-y-1">
<h2 className="typography-ui-header font-semibold text-foreground">Profile Information</h2>
<p className="typography-meta text-muted-foreground/80">
Basic profile settings and display name
</p>
</div>
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground">
Display Name
</label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Work Profile, Personal, etc."
/>
<p className="typography-meta text-muted-foreground">
Friendly name to identify this profile (optional, defaults to user name)
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground">
Color
</label>
<div className="flex gap-2 flex-wrap">
{PROFILE_COLORS.map((c) => (
<button
key={c.key}
onClick={() => setColor(c.key)}
className={cn(
'w-8 h-8 rounded-lg border-2 transition-all',
color === c.key
? 'border-foreground scale-110'
: 'border-transparent hover:border-border'
)}
style={{ backgroundColor: c.cssVar }}
title={c.label}
/>
))}
</div>
</div>
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground">
Icon
</label>
<div className="flex gap-2 flex-wrap">
{PROFILE_ICONS.map((i) => {
const IconComponent = i.Icon;
return (
<button
key={i.key}
onClick={() => setIcon(i.key)}
className={cn(
'w-8 h-8 rounded-lg border-2 transition-all flex items-center justify-center',
icon === i.key
? 'border-primary bg-accent scale-110'
: 'border-border hover:border-primary/50'
)}
title={i.label}
>
<IconComponent
className="w-4 h-4"
style={{ color: currentColorValue }}
/>
</button>
);
})}
</div>
</div>
</div>
</div>
)}
{}
<div className="space-y-4">
<div className="space-y-1">
<h2 className="typography-h2 font-semibold text-foreground">Git Configuration</h2>
<p className="typography-meta text-muted-foreground/80">
Git user settings that will be applied to repositories
</p>
</div>
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground flex items-center gap-2">
User Name {!isGlobalProfile && <span className="text-destructive">*</span>}
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
The name that will appear in Git commit messages.<br/>
This is the author name shown in git log and GitHub/GitLab interfaces.
</TooltipContent>
</Tooltip>
</label>
<Input
value={userName}
onChange={(e) => setUserName(e.target.value)}
placeholder="John Doe"
required={!isGlobalProfile}
readOnly={isGlobalProfile}
disabled={isGlobalProfile}
/>
<p className="typography-meta text-muted-foreground">
Git user.name configuration value
</p>
</div>
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground flex items-center gap-2">
User Email {!isGlobalProfile && <span className="text-destructive">*</span>}
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
The email address for Git commits.<br/>
This should match your email in GitHub/GitLab<br/>
to ensure proper attribution of commits.
</TooltipContent>
</Tooltip>
</label>
<Input
type="email"
value={userEmail}
onChange={(e) => setUserEmail(e.target.value)}
placeholder="john@example.com"
required={!isGlobalProfile}
readOnly={isGlobalProfile}
disabled={isGlobalProfile}
/>
<p className="typography-meta text-muted-foreground">
Git user.email configuration value
</p>
</div>
{/* Auth Type Selector */}
{!isGlobalProfile && (
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground flex items-center gap-2">
Authentication Type
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
SSH: Uses SSH key for authentication<br/>
Token: Uses personal access token from ~/.git-credentials
</TooltipContent>
</Tooltip>
</label>
<div className="flex gap-2">
<button
type="button"
onClick={() => setAuthType('ssh')}
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-md border transition-all',
authType === 'ssh'
? 'border-primary bg-accent'
: 'border-border hover:border-primary/50'
)}
>
<RiLock2Line className="w-4 h-4" />
<span className="typography-ui-label">SSH Key</span>
</button>
<button
type="button"
onClick={() => setAuthType('token')}
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-md border transition-all',
authType === 'token'
? 'border-primary bg-accent'
: 'border-border hover:border-primary/50'
)}
>
<RiKeyLine className="w-4 h-4" />
<span className="typography-ui-label">Token (HTTPS)</span>
</button>
</div>
</div>
)}
{/* SSH Key Path - only for SSH auth type */}
{authType === 'ssh' && (
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground flex items-center gap-2">
SSH Key Path
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
Path to SSH private key used for Git authentication.<br/>
This key will be used for SSH Git operations.<br/>
Common paths: ~/.ssh/id_rsa, ~/.ssh/id_ed25519
</TooltipContent>
</Tooltip>
</label>
<Input
value={sshKey}
onChange={(e) => setSshKey(e.target.value)}
placeholder="/Users/username/.ssh/id_rsa"
readOnly={isGlobalProfile}
disabled={isGlobalProfile}
/>
<p className="typography-meta text-muted-foreground">
Path to SSH private key for authentication (optional)
</p>
</div>
)}
{/* Host - only for Token auth type */}
{authType === 'token' && !isGlobalProfile && (
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground flex items-center gap-2">
Host {<span className="text-destructive">*</span>}
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
The Git host this credential applies to.<br/>
Token will be read from ~/.git-credentials for this host.<br/>
Examples: github.com, gitlab.com
</TooltipContent>
</Tooltip>
</label>
<Input
value={host}
onChange={(e) => setHost(e.target.value)}
placeholder="github.com"
required
/>
<p className="typography-meta text-muted-foreground">
Git host for token authentication (from ~/.git-credentials)
</p>
</div>
)}
{}
{!isGlobalProfile && (
<div className="flex justify-between border-t border-border/40 pt-4">
{!isNewProfile && (
<Button
size="sm"
variant="destructive"
onClick={handleDelete}
className="gap-2 h-6 px-2 text-xs"
>
<RiDeleteBinLine className="h-3 w-3" />
Delete Profile
</Button>
)}
<div className={cn('flex gap-2', isNewProfile && 'ml-auto')}>
<Button
size="sm"
variant="default"
onClick={handleSave}
disabled={isSaving}
className="gap-2 h-6 px-2 text-xs"
>
<RiSaveLine className="h-3 w-3" />
{isSaving ? 'Saving...' : 'Save Profile'}
</Button>
</div>
</div>
)}
</div>
<Dialog
open={isDeleteDialogOpen}
onOpenChange={(open) => {
if (!isDeleting) {
setIsDeleteDialogOpen(open);
}
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Delete Profile</DialogTitle>
<DialogDescription>
Are you sure you want to delete profile "{selectedProfile?.name || name || 'this profile'}"?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => setIsDeleteDialogOpen(false)} disabled={isDeleting}>
Cancel
</Button>
<Button variant="destructive" onClick={() => void handleConfirmDelete()} disabled={isDeleting}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</ScrollableOverlay>
);
};
@@ -1,416 +0,0 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
RiAddLine,
RiGitBranchLine,
RiMore2Line,
RiDeleteBinLine,
RiBriefcaseLine,
RiHomeLine,
RiGraduationCapLine,
RiCodeLine,
RiHeartLine,
RiDownloadLine,
} from '@remixicon/react';
import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore';
import { useUIStore } from '@/stores/useUIStore';
import { useDeviceInfo } from '@/lib/device';
import { isVSCodeRuntime } from '@/lib/desktop';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { cn } from '@/lib/utils';
import type { GitIdentityProfile, DiscoveredGitCredential } from '@/stores/useGitIdentitiesStore';
const ICON_MAP: Record<string, React.ComponentType<{ className?: string; style?: React.CSSProperties }>> = {
branch: RiGitBranchLine,
briefcase: RiBriefcaseLine,
house: RiHomeLine,
graduation: RiGraduationCapLine,
code: RiCodeLine,
heart: RiHeartLine,
};
const COLOR_MAP: Record<string, string> = {
keyword: 'var(--syntax-keyword)',
error: 'var(--status-error)',
string: 'var(--syntax-string)',
function: 'var(--syntax-function)',
type: 'var(--syntax-type)',
};
interface GitIdentitiesSidebarProps {
onItemSelect?: () => void;
}
export const GitIdentitiesSidebar: React.FC<GitIdentitiesSidebarProps> = ({ onItemSelect }) => {
const [deleteDialogProfile, setDeleteDialogProfile] = React.useState<GitIdentityProfile | null>(null);
const [isDeletePending, setIsDeletePending] = React.useState(false);
const {
selectedProfileId,
defaultGitIdentityId,
profiles,
globalIdentity,
setSelectedProfile,
deleteProfile,
loadProfiles,
loadGlobalIdentity,
loadDiscoveredCredentials,
loadDefaultGitIdentityId,
setDefaultGitIdentityId,
getUnimportedCredentials,
} = useGitIdentitiesStore();
const { setSidebarOpen } = useUIStore();
const { isMobile } = useDeviceInfo();
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
const unimportedCredentials = getUnimportedCredentials();
React.useEffect(() => {
loadProfiles();
loadGlobalIdentity();
loadDiscoveredCredentials();
loadDefaultGitIdentityId();
}, [loadProfiles, loadGlobalIdentity, loadDiscoveredCredentials, loadDefaultGitIdentityId]);
const handleImportCredential = (credential: DiscoveredGitCredential) => {
// Set a special "import" selection that carries the credential data
// The form will read this and pre-fill fields
setSelectedProfile(`import:${credential.host}:${credential.username}`);
onItemSelect?.();
if (isMobile) {
setSidebarOpen(false);
}
};
const bgClass = isVSCode ? 'bg-background' : 'bg-sidebar';
const handleCreateProfile = () => {
setSelectedProfile('new');
onItemSelect?.();
if (isMobile) {
setSidebarOpen(false);
}
};
const handleDeleteProfile = async (profile: GitIdentityProfile) => {
setDeleteDialogProfile(profile);
};
const handleConfirmDeleteProfile = async () => {
if (!deleteDialogProfile) {
return;
}
setIsDeletePending(true);
const success = await deleteProfile(deleteDialogProfile.id);
if (success) {
toast.success(`Profile "${deleteDialogProfile.name}" deleted successfully`);
setDeleteDialogProfile(null);
} else {
toast.error('Failed to delete profile');
}
setIsDeletePending(false);
};
const handleToggleDefault = async (profileId: string) => {
const next = defaultGitIdentityId === profileId ? null : profileId;
const ok = await setDefaultGitIdentityId(next);
if (!ok) {
toast.error('Failed to update default identity');
return;
}
toast.success(next ? 'Default identity updated' : 'Default identity unset');
};
return (
<div className={cn('flex h-full flex-col', bgClass)}>
<div className={cn('border-b px-3', isMobile ? 'mt-2 py-3' : 'py-3')}>
<div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground">Total {profiles.length}</span>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 -my-1 text-muted-foreground"
onClick={handleCreateProfile}
aria-label="Create new profile"
>
<RiAddLine className="size-4" />
</Button>
</div>
</div>
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="space-y-1 px-3 py-2">
{}
{globalIdentity && (
<>
<div className="px-2 pb-1.5 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
System Default
</div>
<ProfileListItem
profile={globalIdentity}
isSelected={selectedProfileId === 'global'}
isDefault={defaultGitIdentityId === 'global'}
onSelect={() => {
setSelectedProfile('global');
onItemSelect?.();
if (isMobile) {
setSidebarOpen(false);
}
}}
onToggleDefault={() => handleToggleDefault('global')}
onDelete={undefined}
isReadOnly
/>
</>
)}
{}
{profiles.length > 0 && (
<div className="px-2 pb-1.5 pt-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Custom Profiles
</div>
)}
{profiles.length === 0 && !globalIdentity && unimportedCredentials.length === 0 ? (
<div className="py-12 px-4 text-center text-muted-foreground">
<RiGitBranchLine className="mx-auto mb-3 h-10 w-10 opacity-50" />
<p className="typography-ui-label font-medium">No profiles configured</p>
<p className="typography-meta mt-1 opacity-75">Use the + button above to create one</p>
</div>
) : (
<>
{profiles.map((profile) => (
<ProfileListItem
key={profile.id}
profile={profile}
isSelected={selectedProfileId === profile.id}
isDefault={defaultGitIdentityId === profile.id}
onSelect={() => {
setSelectedProfile(profile.id);
onItemSelect?.();
if (isMobile) {
setSidebarOpen(false);
}
}}
onToggleDefault={() => handleToggleDefault(profile.id)}
onDelete={() => handleDeleteProfile(profile)}
/>
))}
</>
)}
{/* Discovered Credentials Section */}
{unimportedCredentials.length > 0 && (
<>
<div className="px-2 pb-1.5 pt-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Discovered Credentials
</div>
<p className="px-2 pb-2 typography-micro text-muted-foreground/60">
Found in ~/.git-credentials
</p>
{unimportedCredentials.map((cred) => (
<DiscoveredCredentialItem
key={`${cred.host}-${cred.username}`}
credential={cred}
onImport={() => handleImportCredential(cred)}
/>
))}
</>
)}
</ScrollableOverlay>
<Dialog
open={deleteDialogProfile !== null}
onOpenChange={(open) => {
if (!open && !isDeletePending) {
setDeleteDialogProfile(null);
}
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Delete Profile</DialogTitle>
<DialogDescription>
Are you sure you want to delete profile "{deleteDialogProfile?.name}"?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => setDeleteDialogProfile(null)} disabled={isDeletePending}>
Cancel
</Button>
<Button variant="destructive" onClick={() => void handleConfirmDeleteProfile()} disabled={isDeletePending}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
interface ProfileListItemProps {
profile: GitIdentityProfile;
isSelected: boolean;
isDefault?: boolean;
onSelect: () => void;
onToggleDefault?: () => void | Promise<void>;
onDelete?: () => void;
isReadOnly?: boolean;
}
const ProfileListItem: React.FC<ProfileListItemProps> = ({
profile,
isSelected,
isDefault = false,
onSelect,
onToggleDefault,
onDelete,
isReadOnly = false,
}) => {
const IconComponent = ICON_MAP[profile.icon || 'branch'] || RiGitBranchLine;
const iconColor = COLOR_MAP[profile.color || ''];
const authType = profile.authType || 'ssh';
return (
<div
className={cn(
'group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200',
isSelected ? 'bg-interactive-selection' : 'hover:bg-interactive-hover'
)}
>
<div className="flex min-w-0 flex-1 items-center">
<button
onClick={onSelect}
className="flex min-w-0 flex-1 flex-col gap-0 rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
tabIndex={0}
>
<div className="flex items-center gap-1.5">
<IconComponent
className="w-4 h-4 flex-shrink-0"
style={{ color: iconColor }}
/>
<span className="typography-ui-label font-normal truncate text-foreground">
{profile.name}
</span>
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
{authType}
</span>
{isDefault && (
<span className="typography-micro text-primary bg-primary/12 px-1 rounded flex-shrink-0 leading-none pb-px border border-primary/25">
default
</span>
)}
</div>
<div className="typography-micro text-muted-foreground/60 truncate leading-tight">
{authType === 'token' && profile.host ? profile.host : profile.userEmail}
</div>
</button>
{(onToggleDefault || (!isReadOnly && onDelete)) && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"
aria-label="Profile actions"
>
<RiMore2Line className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit min-w-28">
{onToggleDefault && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
void onToggleDefault();
}}
>
{isDefault ? 'Unset default' : 'Set as default'}
</DropdownMenuItem>
)}
{!isReadOnly && onDelete && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="text-destructive focus:text-destructive"
>
<RiDeleteBinLine className="h-4 w-4 mr-px" />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
);
};
interface DiscoveredCredentialItemProps {
credential: DiscoveredGitCredential;
onImport: () => void;
}
const getCredentialDisplayName = (host: string): string => {
const parts = host.split('/');
if (parts.length >= 3) {
return parts[parts.length - 1];
}
return host;
};
const DiscoveredCredentialItem: React.FC<DiscoveredCredentialItemProps> = ({
credential,
onImport,
}) => {
const displayName = getCredentialDisplayName(credential.host);
const isRepoSpecific = credential.host.includes('/');
return (
<div className="group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200 hover:bg-interactive-hover">
<div className="flex min-w-0 flex-1 items-center">
<div className="flex min-w-0 flex-1 flex-col gap-0">
<div className="flex items-center gap-1.5">
<span className="typography-ui-label font-normal truncate text-foreground">
{displayName}
</span>
</div>
<div className="typography-micro text-muted-foreground/60 truncate leading-tight">
{isRepoSpecific ? credential.host : credential.username}
</div>
</div>
<Button
size="sm"
variant="ghost"
onClick={onImport}
className="h-6 px-2 text-xs gap-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"
>
<RiDownloadLine className="h-3 w-3" />
Import
</Button>
</div>
</div>
);
};
@@ -0,0 +1,478 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { ButtonSmall } from '@/components/ui/button-small';
import { Input } from '@/components/ui/input';
import { toast } from '@/components/ui';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useGitIdentitiesStore, type GitIdentityProfile, type GitIdentityAuthType } from '@/stores/useGitIdentitiesStore';
import {
RiDeleteBinLine,
RiGitBranchLine,
RiBriefcaseLine,
RiHomeLine,
RiGraduationCapLine,
RiCodeLine,
RiInformationLine,
RiKeyLine,
RiLock2Line,
} from '@remixicon/react';
import { cn } from '@/lib/utils';
const PROFILE_COLORS = [
{ key: 'keyword', label: 'Green', cssVar: 'var(--syntax-keyword)' },
{ key: 'error', label: 'Red', cssVar: 'var(--status-error)' },
{ key: 'string', label: 'Cyan', cssVar: 'var(--syntax-string)' },
{ key: 'function', label: 'Orange', cssVar: 'var(--syntax-function)' },
{ key: 'type', label: 'Yellow', cssVar: 'var(--syntax-type)' },
];
const PROFILE_ICONS = [
{ key: 'branch', Icon: RiGitBranchLine, label: 'Branch' },
{ key: 'briefcase', Icon: RiBriefcaseLine, label: 'Work' },
{ key: 'house', Icon: RiHomeLine, label: 'Personal' },
{ key: 'graduation', Icon: RiGraduationCapLine, label: 'School' },
{ key: 'code', Icon: RiCodeLine, label: 'Code' },
];
interface GitIdentityEditorDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Profile ID to edit, 'new' for creation, or null */
profileId: string | null;
/** Pre-fill data for importing a discovered credential */
importData?: { host: string; username: string } | null;
}
export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = ({
open,
onOpenChange,
profileId,
importData,
}) => {
const {
getProfileById,
createProfile,
updateProfile,
deleteProfile,
} = useGitIdentitiesStore();
const selectedProfile = React.useMemo(() =>
profileId && profileId !== 'new' && !importData ? getProfileById(profileId) : null,
[profileId, getProfileById, importData]
);
const isNewProfile = profileId === 'new' || importData != null;
const isGlobalProfile = profileId === 'global';
const [name, setName] = React.useState('');
const [userName, setUserName] = React.useState('');
const [userEmail, setUserEmail] = React.useState('');
const [authType, setAuthType] = React.useState<GitIdentityAuthType>('ssh');
const [sshKey, setSshKey] = React.useState('');
const [host, setHost] = React.useState('');
const [color, setColor] = React.useState('keyword');
const [icon, setIcon] = React.useState('branch');
const [isSaving, setIsSaving] = React.useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false);
const [isDeleting, setIsDeleting] = React.useState(false);
React.useEffect(() => {
if (!open) return;
if (importData) {
const parts = importData.host.split('/');
const displayName = parts.length >= 3 ? parts[parts.length - 1] : importData.host;
setName(displayName);
setUserName(importData.username);
setUserEmail('');
setAuthType('token');
setSshKey('');
setHost(importData.host);
setColor('string');
setIcon('code');
} else if (isNewProfile) {
setName('');
setUserName('');
setUserEmail('');
setAuthType('ssh');
setSshKey('');
setHost('');
setColor('keyword');
setIcon('branch');
} else if (selectedProfile) {
setName(selectedProfile.name);
setUserName(selectedProfile.userName);
setUserEmail(selectedProfile.userEmail);
setAuthType(selectedProfile.authType || 'ssh');
setSshKey(selectedProfile.sshKey || '');
setHost(selectedProfile.host || '');
setColor(selectedProfile.color || 'keyword');
setIcon(selectedProfile.icon || 'branch');
} else if (isGlobalProfile) {
const global = getProfileById('global');
if (global) {
setName(global.name);
setUserName(global.userName);
setUserEmail(global.userEmail);
setAuthType(global.authType || 'ssh');
setSshKey(global.sshKey || '');
setHost(global.host || '');
setColor(global.color || 'keyword');
setIcon(global.icon || 'branch');
}
}
}, [open, profileId, selectedProfile, isNewProfile, importData, isGlobalProfile, getProfileById]);
const handleSave = async () => {
if (!userName.trim() || !userEmail.trim()) {
toast.error('User name and email are required');
return;
}
if (authType === 'token' && !host.trim()) {
toast.error('Host is required for token-based authentication');
return;
}
setIsSaving(true);
try {
const profileData: Omit<GitIdentityProfile, 'id'> & { id?: string } = {
name: name.trim() || userName.trim(),
userName: userName.trim(),
userEmail: userEmail.trim(),
authType,
sshKey: authType === 'ssh' ? (sshKey.trim() || null) : null,
host: authType === 'token' ? (host.trim() || null) : null,
color,
icon,
};
let success: boolean;
if (isNewProfile) {
success = await createProfile(profileData);
} else if (profileId) {
success = await updateProfile(profileId, profileData);
} else {
return;
}
if (success) {
toast.success(isNewProfile ? 'Profile created' : 'Profile updated');
onOpenChange(false);
} else {
toast.error(isNewProfile ? 'Failed to create profile' : 'Failed to update profile');
}
} catch (error) {
console.error('Error saving profile:', error);
toast.error('An error occurred while saving');
} finally {
setIsSaving(false);
}
};
const handleConfirmDelete = async () => {
if (!profileId || isNewProfile) return;
setIsDeleting(true);
try {
const success = await deleteProfile(profileId);
if (success) {
toast.success('Profile deleted');
setIsDeleteDialogOpen(false);
onOpenChange(false);
} else {
toast.error('Failed to delete profile');
}
} catch (error) {
console.error('Error deleting profile:', error);
toast.error('An error occurred while deleting');
} finally {
setIsDeleting(false);
}
};
const currentColorValue = React.useMemo(() => {
const colorConfig = PROFILE_COLORS.find(c => c.key === color);
return colorConfig?.cssVar || 'var(--syntax-keyword)';
}, [color]);
const title = importData
? 'Import Credential'
: isNewProfile
? 'New Identity'
: isGlobalProfile
? 'Global Identity'
: (selectedProfile?.name || 'Edit Identity');
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>
{isGlobalProfile
? 'System-wide Git identity (read-only)'
: isNewProfile
? 'Create a new Git identity profile'
: 'Edit identity profile settings'}
</DialogDescription>
</DialogHeader>
<div className="space-y-5 py-2">
{/* Profile Display */}
{!isGlobalProfile && (
<div className="space-y-3">
<div>
<label className="typography-ui-label text-foreground block mb-1.5">Profile Name</label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Work Profile, Personal, etc."
className="h-8"
/>
</div>
<div className="flex items-center justify-between gap-4">
<span className="typography-ui-label text-foreground">Color</span>
<div className="flex gap-1.5">
{PROFILE_COLORS.map((c) => (
<button
key={c.key}
type="button"
onClick={() => setColor(c.key)}
className={cn(
'w-6 h-6 rounded-md border-2 transition-all cursor-pointer',
color === c.key
? 'border-foreground scale-110'
: 'border-transparent hover:border-border'
)}
style={{ backgroundColor: c.cssVar }}
title={c.label}
/>
))}
</div>
</div>
<div className="flex items-center justify-between gap-4">
<span className="typography-ui-label text-foreground">Icon</span>
<div className="flex gap-1.5">
{PROFILE_ICONS.map((i) => {
const IconComponent = i.Icon;
return (
<button
key={i.key}
type="button"
onClick={() => setIcon(i.key)}
className={cn(
'w-7 h-7 rounded-md border-2 transition-all flex items-center justify-center cursor-pointer',
icon === i.key
? 'border-[var(--interactive-border)] bg-[var(--surface-muted)]'
: 'border-transparent hover:border-[var(--interactive-border)] hover:bg-[var(--surface-muted)]/50'
)}
title={i.label}
>
<IconComponent
className="w-3.5 h-3.5"
style={{ color: icon === i.key ? currentColorValue : 'var(--surface-muted-foreground)' }}
/>
</button>
);
})}
</div>
</div>
</div>
)}
{/* Separator */}
{!isGlobalProfile && <div className="border-t border-border/40" />}
{/* Git Author */}
<div className="space-y-3">
<div>
<div className="flex items-center gap-1.5 mb-1.5">
<label className="typography-ui-label text-foreground">User Name</label>
{!isGlobalProfile && <span className="text-[var(--status-error)] text-xs">*</span>}
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
The name that will appear in Git commit messages.
</TooltipContent>
</Tooltip>
</div>
<Input
value={userName}
onChange={(e) => setUserName(e.target.value)}
placeholder="John Doe"
required={!isGlobalProfile}
readOnly={isGlobalProfile}
disabled={isGlobalProfile}
className="h-8"
/>
</div>
<div>
<div className="flex items-center gap-1.5 mb-1.5">
<label className="typography-ui-label text-foreground">Email Address</label>
{!isGlobalProfile && <span className="text-[var(--status-error)] text-xs">*</span>}
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
Should match your email in GitHub/GitLab for proper attribution.
</TooltipContent>
</Tooltip>
</div>
<Input
type="email"
value={userEmail}
onChange={(e) => setUserEmail(e.target.value)}
placeholder="john@example.com"
required={!isGlobalProfile}
readOnly={isGlobalProfile}
disabled={isGlobalProfile}
className="h-8"
/>
</div>
</div>
{/* Authentication */}
{!isGlobalProfile && (
<>
<div className="border-t border-border/40" />
<div className="space-y-3">
<div className="flex items-center justify-between gap-4">
<span className="typography-ui-label text-foreground">Auth Method</span>
<div className="flex items-center gap-1">
<ButtonSmall
type="button"
variant="outline"
onClick={() => setAuthType('ssh')}
className={cn(
authType === 'ssh'
? 'border-[var(--primary-base)] text-[var(--primary-base)] bg-[var(--primary-base)]/10 hover:text-[var(--primary-base)]'
: 'text-foreground'
)}
>
<RiLock2Line className="w-3.5 h-3.5 mr-1" /> SSH
</ButtonSmall>
<ButtonSmall
type="button"
variant="outline"
onClick={() => setAuthType('token')}
className={cn(
authType === 'token'
? 'border-[var(--primary-base)] text-[var(--primary-base)] bg-[var(--primary-base)]/10 hover:text-[var(--primary-base)]'
: 'text-foreground'
)}
>
<RiKeyLine className="w-3.5 h-3.5 mr-1" /> Token
</ButtonSmall>
</div>
</div>
{authType === 'ssh' && (
<div>
<div className="flex items-center gap-1.5 mb-1.5">
<label className="typography-ui-label text-foreground">SSH Key Path</label>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
Optional path to private key. e.g. ~/.ssh/id_ed25519
</TooltipContent>
</Tooltip>
</div>
<Input
value={sshKey}
onChange={(e) => setSshKey(e.target.value)}
placeholder="~/.ssh/id_ed25519"
className="h-8 font-mono text-xs"
/>
</div>
)}
{authType === 'token' && (
<div>
<div className="flex items-center gap-1.5 mb-1.5">
<label className="typography-ui-label text-foreground">Host</label>
<span className="text-[var(--status-error)] text-xs">*</span>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
Token will be read from ~/.git-credentials for this host.
</TooltipContent>
</Tooltip>
</div>
<Input
value={host}
onChange={(e) => setHost(e.target.value)}
placeholder="github.com"
required
className="h-8 font-mono text-xs"
/>
</div>
)}
</div>
</>
)}
</div>
<DialogFooter className="gap-2">
{!isGlobalProfile && !isNewProfile && (
<Button
variant="outline"
size="sm"
onClick={() => setIsDeleteDialogOpen(true)}
className="text-[var(--status-error)] hover:text-[var(--status-error)] border-[var(--status-error)]/30 hover:bg-[var(--status-error)]/10 mr-auto"
>
<RiDeleteBinLine className="w-3.5 h-3.5 mr-1" /> Delete
</Button>
)}
<Button variant="ghost" size="sm" onClick={() => onOpenChange(false)} className="text-foreground hover:bg-interactive-hover hover:text-foreground">
{isGlobalProfile ? 'Close' : 'Cancel'}
</Button>
{!isGlobalProfile && (
<Button size="sm" onClick={handleSave} disabled={isSaving}>
{isSaving ? 'Saving...' : isNewProfile ? 'Create' : 'Save'}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete confirmation */}
<Dialog
open={isDeleteDialogOpen}
onOpenChange={(o) => { if (!isDeleting) setIsDeleteDialogOpen(o); }}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Delete Profile</DialogTitle>
<DialogDescription>
Are you sure you want to delete "{selectedProfile?.name || name}"?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => setIsDeleteDialogOpen(false)} disabled={isDeleting}>
Cancel
</Button>
<Button size="sm" onClick={() => void handleConfirmDelete()} disabled={isDeleting} className="bg-[var(--status-error)] hover:bg-[var(--status-error)]/90 text-white border-0">
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
@@ -0,0 +1,349 @@
import React from 'react';
import { ButtonSmall } from '@/components/ui/button-small';
import { toast } from '@/components/ui';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { ButtonLarge } from '@/components/ui/button-large';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
RiAddLine,
RiGitBranchLine,
RiBriefcaseLine,
RiHomeLine,
RiGraduationCapLine,
RiCodeLine,
RiHeartLine,
RiMore2Line,
RiDeleteBinLine,
RiDownloadLine,
RiShieldKeyholeLine,
} from '@remixicon/react';
import { useGitIdentitiesStore, type GitIdentityProfile, type DiscoveredGitCredential } from '@/stores/useGitIdentitiesStore';
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 { cn } from '@/lib/utils';
const ICON_MAP: Record<string, React.ComponentType<{ className?: string; style?: React.CSSProperties }>> = {
branch: RiGitBranchLine,
briefcase: RiBriefcaseLine,
house: RiHomeLine,
graduation: RiGraduationCapLine,
code: RiCodeLine,
heart: RiHeartLine,
};
const COLOR_MAP: Record<string, string> = {
keyword: 'var(--syntax-keyword)',
error: 'var(--status-error)',
string: 'var(--syntax-string)',
function: 'var(--syntax-function)',
type: 'var(--syntax-type)',
};
export const GitPage: React.FC = () => {
const {
profiles,
globalIdentity,
defaultGitIdentityId,
deleteProfile,
loadProfiles,
loadGlobalIdentity,
loadDiscoveredCredentials,
loadDefaultGitIdentityId,
setDefaultGitIdentityId,
getUnimportedCredentials,
} = useGitIdentitiesStore();
const [editorOpen, setEditorOpen] = React.useState(false);
const [editorProfileId, setEditorProfileId] = React.useState<string | null>(null);
const [editorImportData, setEditorImportData] = React.useState<{ host: string; username: string } | null>(null);
const [deleteDialogProfile, setDeleteDialogProfile] = React.useState<GitIdentityProfile | null>(null);
const [isDeletePending, setIsDeletePending] = React.useState(false);
React.useEffect(() => {
loadProfiles();
loadGlobalIdentity();
loadDiscoveredCredentials();
loadDefaultGitIdentityId();
}, [loadProfiles, loadGlobalIdentity, loadDiscoveredCredentials, loadDefaultGitIdentityId]);
const unimportedCredentials = getUnimportedCredentials();
const openEditor = (id: string | null, importData?: { host: string; username: string } | null) => {
setEditorProfileId(id);
setEditorImportData(importData ?? null);
setEditorOpen(true);
};
const handleToggleDefault = async (profileId: string) => {
const next = defaultGitIdentityId === profileId ? null : profileId;
const ok = await setDefaultGitIdentityId(next);
if (!ok) {
toast.error('Failed to update default identity');
return;
}
toast.success(next ? 'Default identity updated' : 'Default identity unset');
};
const handleConfirmDelete = async () => {
if (!deleteDialogProfile) return;
setIsDeletePending(true);
const success = await deleteProfile(deleteDialogProfile.id);
if (success) {
toast.success(`Profile "${deleteDialogProfile.name}" deleted`);
setDeleteDialogProfile(null);
} else {
toast.error('Failed to delete profile');
}
setIsDeletePending(false);
};
return (
<>
<ScrollableOverlay keyboardAvoid 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">
<GitHubSettings />
{/* Identities Section */}
<div 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">Identities</h3>
</div>
<ButtonSmall variant="outline" onClick={() => openEditor('new')}>
<RiAddLine className="w-3.5 h-3.5 mr-1" /> New
</ButtonSmall>
</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">
<RiShieldKeyholeLine className="mx-auto mb-2 h-8 w-8 opacity-40" />
<p className="typography-ui-label">No identities configured</p>
<p className="typography-meta mt-1 opacity-75">Create one to manage Git author settings per project</p>
</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">
Found in ~/.git-credentials
</span>
</div>
{unimportedCredentials.map((cred, i) => (
<DiscoveredRow
key={`${cred.host}-${cred.username}`}
credential={cred}
onImport={() => openEditor('new', { host: cred.host, username: cred.username })}
hasBorder={i < unimportedCredentials.length - 1}
/>
))}
</>
)}
</div>
</div>
<GitSettings />
</div>
</ScrollableOverlay>
{/* Editor dialog */}
<GitIdentityEditorDialog
open={editorOpen}
onOpenChange={setEditorOpen}
profileId={editorProfileId}
importData={editorImportData}
/>
{/* Delete confirmation */}
<Dialog
open={deleteDialogProfile !== null}
onOpenChange={(o) => { if (!isDeletePending) { if (!o) setDeleteDialogProfile(null); } }}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Delete Profile</DialogTitle>
<DialogDescription>
Are you sure you want to delete "{deleteDialogProfile?.name}"?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => setDeleteDialogProfile(null)} disabled={isDeletePending}>
Cancel
</Button>
<ButtonLarge onClick={() => void handleConfirmDelete()} disabled={isDeletePending} className="bg-[var(--status-error)] hover:bg-[var(--status-error)]/90 text-white border-0">
Delete
</ButtonLarge>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
// --- Identity row ---
interface IdentityRowProps {
profile: GitIdentityProfile;
isDefault: boolean;
onEdit: () => void;
onToggleDefault: () => void;
onDelete?: () => void;
isReadOnly?: boolean;
hasBorder?: boolean;
}
const IdentityRow: React.FC<IdentityRowProps> = ({
profile,
isDefault,
onEdit,
onToggleDefault,
onDelete,
isReadOnly,
hasBorder,
}) => {
const IconComponent = ICON_MAP[profile.icon || 'branch'] || RiGitBranchLine;
const iconColor = COLOR_MAP[profile.color || ''];
const authType = profile.authType || 'ssh';
return (
<div
className={cn(
'group flex items-center justify-between gap-3 px-4 py-2.5 transition-colors hover:bg-[var(--interactive-hover)]/30 cursor-pointer',
hasBorder && 'border-b border-[var(--surface-subtle)]'
)}
onClick={onEdit}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onEdit(); }}
>
<div className="flex items-center gap-3 min-w-0">
<IconComponent className="w-4 h-4 shrink-0" style={{ color: iconColor }} />
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span className="typography-ui-label text-foreground truncate">{profile.name}</span>
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
{authType}
</span>
{isDefault && (
<span className="typography-micro text-primary bg-primary/12 px-1 rounded flex-shrink-0 leading-none pb-px border border-primary/25">
default
</span>
)}
{isReadOnly && (
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
system
</span>
)}
</div>
<div className="typography-micro text-muted-foreground/60 truncate leading-tight">
{authType === 'token' && profile.host ? profile.host : profile.userEmail}
</div>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 shrink-0 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"
onClick={(e) => e.stopPropagation()}
>
<RiMore2Line className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit min-w-28">
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onToggleDefault(); }}>
{isDefault ? 'Unset default' : 'Set as default'}
</DropdownMenuItem>
{!isReadOnly && onDelete && (
<DropdownMenuItem
onClick={(e) => { e.stopPropagation(); onDelete(); }}
className="text-destructive focus:text-destructive"
>
<RiDeleteBinLine className="h-4 w-4 mr-px" />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
};
// --- Discovered credential row ---
interface DiscoveredRowProps {
credential: DiscoveredGitCredential;
onImport: () => void;
hasBorder?: boolean;
}
const DiscoveredRow: React.FC<DiscoveredRowProps> = ({ credential, onImport, hasBorder }) => {
const parts = credential.host.split('/');
const displayName = parts.length >= 3 ? parts[parts.length - 1] : credential.host;
const isRepoSpecific = credential.host.includes('/');
return (
<div
className={cn(
'flex items-center justify-between gap-3 px-4 py-2.5 transition-colors hover:bg-[var(--interactive-hover)]/30',
hasBorder && 'border-b border-[var(--surface-subtle)]'
)}
>
<div className="min-w-0">
<span className="typography-ui-label text-foreground truncate block">{displayName}</span>
<span className="typography-micro text-muted-foreground/60 truncate block leading-tight">
{isRepoSpecific ? credential.host : credential.username}
</span>
</div>
<ButtonSmall variant="ghost" onClick={onImport} className="gap-1 shrink-0">
<RiDownloadLine className="h-3 w-3" />
Import
</ButtonSmall>
</div>
);
};
@@ -1,6 +1,8 @@
import React from 'react'; import React from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { ButtonLarge } from '@/components/ui/button-large'; import { ButtonLarge } from '@/components/ui/button-large';
import { ButtonSmall } from '@/components/ui/button-small';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { toast } from '@/components/ui'; import { toast } from '@/components/ui';
@@ -20,12 +22,10 @@ import {
RiEyeOffLine, RiEyeOffLine,
RiFolderLine, RiFolderLine,
RiPlugLine, RiPlugLine,
RiSaveLine,
RiUser3Line, RiUser3Line,
} from '@remixicon/react'; } from '@remixicon/react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { ButtonSmall } from '@/components/ui/button-small';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -109,21 +109,18 @@ const CommandTextarea: React.FC<CommandTextareaProps> = ({ value, onChange }) =>
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-end gap-2">
<p className="typography-micro text-muted-foreground"> <ButtonSmall
One argument per line. Blank lines are ignored.
</p>
<Button
variant="ghost" variant="ghost"
size="sm" size="xs"
className="h-6 gap-1 px-2 typography-micro text-muted-foreground" className="!font-normal gap-1 text-muted-foreground"
onClick={handlePasteFromClipboard} onClick={handlePasteFromClipboard}
type="button" type="button"
title="Paste full command from clipboard and auto-split" title="Paste full command from clipboard and auto-split"
> >
<RiClipboardLine className="h-3 w-3" /> <RiClipboardLine className="h-3 w-3" />
Paste command Paste command
</Button> </ButtonSmall>
</div> </div>
<Textarea <Textarea
@@ -255,17 +252,17 @@ const EnvEditor: React.FC<EnvEditorProps> = ({ value, onChange }) => {
<span className="typography-micro text-muted-foreground w-32 shrink-0">Key</span> <span className="typography-micro text-muted-foreground w-32 shrink-0">Key</span>
<span className="typography-micro text-muted-foreground">Value</span> <span className="typography-micro text-muted-foreground">Value</span>
</div> </div>
<Button <ButtonSmall
variant="ghost" variant="ghost"
size="sm" size="xs"
className="h-6 gap-1 px-2 typography-micro text-muted-foreground" className="!font-normal gap-1 text-muted-foreground"
onClick={handlePasteDotEnv} onClick={handlePasteDotEnv}
type="button" type="button"
title="Paste KEY=VALUE lines from clipboard" title="Paste KEY=VALUE lines from clipboard"
> >
<RiClipboardLine className="h-3 w-3" /> <RiClipboardLine className="h-3 w-3" />
Paste .env Paste .env
</Button> </ButtonSmall>
</div> </div>
{/* Rows */} {/* Rows */}
@@ -302,28 +299,27 @@ const EnvEditor: React.FC<EnvEditorProps> = ({ value, onChange }) => {
</button> </button>
</div> </div>
{/* Remove */} {/* Remove */}
<Button <ButtonSmall
size="icon"
variant="ghost" variant="ghost"
className="h-8 w-7 shrink-0 text-muted-foreground hover:text-destructive" className="h-7 w-7 px-0 shrink-0 text-muted-foreground hover:text-[var(--status-error)]"
onClick={() => removeRow(idx)} onClick={() => removeRow(idx)}
> >
<RiDeleteBinLine className="h-3.5 w-3.5" /> <RiDeleteBinLine className="h-3.5 w-3.5" />
</Button> </ButtonSmall>
</div> </div>
))} ))}
</div> </div>
<Button <ButtonSmall
variant="outline" variant="outline"
size="sm" size="xs"
className="gap-1.5 h-7 typography-meta" className="!font-normal gap-1.5"
onClick={addRow} onClick={addRow}
type="button" type="button"
> >
<RiAddLine className="h-3.5 w-3.5" /> <RiAddLine className="h-3.5 w-3.5" />
Add variable Add variable
</Button> </ButtonSmall>
{hasSensitiveValues && ( {hasSensitiveValues && (
<p className="typography-micro text-muted-foreground/60"> <p className="typography-micro text-muted-foreground/60">
@@ -345,16 +341,14 @@ const STATUS_LABEL: Record<string, string> = {
}; };
const StatusBadge: React.FC<{ status: string | undefined; enabled: boolean }> = ({ status, enabled }) => { const StatusBadge: React.FC<{ status: string | undefined; enabled: boolean }> = ({ status, enabled }) => {
if (!enabled) { if (!enabled) return null;
return <span className="typography-micro text-muted-foreground/50">Disabled</span>;
}
if (!status) return null; if (!status) return null;
const colorMap: Record<string, string> = { const colorMap: Record<string, string> = {
connected: 'text-green-600 dark:text-green-400', connected: 'text-[var(--status-success)]',
failed: 'text-destructive', failed: 'text-[var(--status-error)]',
needs_auth: 'text-yellow-600 dark:text-yellow-400', needs_auth: 'text-[var(--status-warning)]',
needs_client_registration: 'text-yellow-600 dark:text-yellow-400', needs_client_registration: 'text-[var(--status-warning)]',
}; };
return ( return (
@@ -523,195 +517,200 @@ export const McpPage: React.FC = () => {
return ( return (
<ScrollableOverlay keyboardAvoid outerClassName="h-full" className="w-full"> <ScrollableOverlay keyboardAvoid outerClassName="h-full" className="w-full">
<div className="mx-auto max-w-2xl space-y-5 p-6"> <div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8">
{/* ── Header card: name + status + enabled + connect ── */} {/* Header */}
<div className="rounded-xl border border-[var(--interactive-border)] bg-[var(--surface-elevated)] px-4 py-3 space-y-3"> <div className="mb-4">
{/* Row 1: name + connect button */}
<div className="flex items-center justify-between gap-3 min-w-0">
<div className="min-w-0"> <div className="min-w-0">
{isNewServer ? ( {isNewServer ? (
<h2 className="typography-ui-header font-semibold text-foreground truncate">New MCP Server</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={runtimeStatus?.status} enabled={enabled} />
</div>
)}
<div className="flex items-center gap-2 mt-0.5">
<p className="typography-meta text-muted-foreground truncate">
{isNewServer ? 'Configure a new MCP server' : `${mcpType === 'local' ? 'Local · stdio' : 'Remote · SSE'} transport`}
</p>
{!isNewServer && (
<ButtonSmall
variant={isConnected ? 'outline' : 'default'}
size="xs"
className="!font-normal"
onClick={handleToggleConnect}
disabled={isConnecting || !enabled}
>
{isConnecting ? 'Working...' : isConnected ? 'Disconnect' : 'Connect'}
</ButtonSmall>
)}
</div>
</div>
</div>
{/* Server Identity */}
<div className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">Server</h3>
</div>
<section className="px-2 pb-2 pt-0 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">Server Name</span>
</div>
<div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial">
<Input <Input
value={draftName} value={draftName}
onChange={(e) => setDraftName(e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, '-'))} onChange={(e) => setDraftName(e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, '-'))}
placeholder="my-mcp-server" placeholder="my-mcp-server"
className="font-mono text-base h-8 w-64" className="h-7 w-48 font-mono px-2"
autoFocus autoFocus
/> />
) : (
<h1 className="typography-ui-header font-semibold truncate">{selectedMcpName}</h1>
)}
{isNewServer && (
<p className="typography-micro text-muted-foreground mt-0.5">
Lowercase, numbers, hyphens and underscores only
</p>
)}
</div>
{isNewServer && (
<Select value={draftScope} onValueChange={(value) => setDraftScope(value as McpScope)}> <Select value={draftScope} onValueChange={(value) => setDraftScope(value as McpScope)}>
<SelectTrigger className="!h-8 w-auto gap-1.5"> <SelectTrigger className="!h-7 !w-7 !min-w-0 !px-0 !py-0 justify-center [&>svg:last-child]:hidden" title={draftScope === 'user' ? 'User scope' : 'Project scope'}>
{draftScope === 'user' ? ( {draftScope === 'user' ? <RiUser3Line className="h-3.5 w-3.5" /> : <RiFolderLine className="h-3.5 w-3.5" />}
<RiUser3Line className="h-4 w-4" />
) : (
<RiFolderLine className="h-4 w-4" />
)}
<span className="capitalize">{draftScope}</span>
</SelectTrigger> </SelectTrigger>
<SelectContent align="end"> <SelectContent align="end">
<SelectItem value="user" className="pr-2 [&>span:first-child]:hidden"> <SelectItem value="user">
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<RiUser3Line className="h-4 w-4" /> <RiUser3Line className="h-3.5 w-3.5" />
<span>User</span> <span>User</span>
</div> </div>
<span className="typography-micro text-muted-foreground ml-6">Available in all projects</span>
</div>
</SelectItem> </SelectItem>
<SelectItem value="project" className="pr-2 [&>span:first-child]:hidden"> <SelectItem value="project">
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<RiFolderLine className="h-4 w-4" /> <RiFolderLine className="h-3.5 w-3.5" />
<span>Project</span> <span>Project</span>
</div> </div>
<span className="typography-micro text-muted-foreground ml-6">Only in current project</span>
</div>
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div>
</div>
)} )}
{!isNewServer && ( <div
<Button className="group flex cursor-pointer items-center gap-2 py-1.5"
size="sm" role="button"
variant={isConnected ? 'outline' : 'default'} tabIndex={0}
onClick={handleToggleConnect} aria-pressed={enabled}
disabled={isConnecting || !enabled}
className="h-7 shrink-0"
>
{isConnecting ? 'Working…' : isConnected ? 'Disconnect' : 'Connect'}
</Button>
)}
</div>
{/* Row 2: status + type badge + enabled toggle */}
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<StatusBadge status={runtimeStatus?.status} enabled={enabled} />
<span className="typography-micro text-muted-foreground/40">·</span>
<span className="typography-micro text-muted-foreground bg-muted px-1.5 py-0.5 rounded border border-border/50">
{mcpType === 'local' ? 'stdio' : 'remote'}
</span>
</div>
{/* Enabled toggle */}
<div className="flex items-center gap-2">
<span className={cn('typography-micro', enabled ? 'text-foreground' : 'text-muted-foreground/60')}>
{enabled ? 'Enabled' : 'Disabled'}
</span>
<button
type="button"
role="switch"
aria-checked={enabled}
onClick={() => setEnabled(!enabled)} onClick={() => setEnabled(!enabled)}
className={cn( onKeyDown={(event) => {
'relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary', if (event.key === ' ' || event.key === 'Enter') {
enabled ? 'bg-primary' : 'bg-muted', event.preventDefault();
)} setEnabled(!enabled);
}
}}
> >
<span className={cn( <Checkbox
'pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform', checked={enabled}
enabled ? 'translate-x-4' : 'translate-x-0', onChange={setEnabled}
)} /> ariaLabel="Enable server"
</button> />
</div> <span className="typography-ui-label text-foreground">Enable Server</span>
</div> </div>
{/* Row 3: type selector — always visible so user can switch type */} <div className="pb-1.5 pt-0.5">
<div className="flex gap-1 pt-1 border-t border-[var(--interactive-border)]"> <div className="flex min-w-0 flex-col gap-1.5">
<span className="typography-ui-label text-foreground">Transport Mode</span>
<div className="flex flex-wrap items-center gap-1">
<ButtonSmall <ButtonSmall
variant={mcpType === 'local' ? 'default' : 'outline'} variant="outline"
size="xs"
onClick={() => setMcpType('local')} onClick={() => setMcpType('local')}
className={cn(mcpType !== 'local' && 'text-foreground')} className={cn(
'!font-normal',
mcpType === 'local'
? 'border-[var(--primary-base)] text-[var(--primary-base)] bg-[var(--primary-base)]/10 hover:text-[var(--primary-base)]'
: 'text-foreground'
)}
> >
Local · stdio Local · stdio
</ButtonSmall> </ButtonSmall>
<ButtonSmall <ButtonSmall
variant={mcpType === 'remote' ? 'default' : 'outline'} variant="outline"
size="xs"
onClick={() => setMcpType('remote')} onClick={() => setMcpType('remote')}
className={cn(mcpType !== 'remote' && 'text-foreground')} className={cn(
'!font-normal',
mcpType === 'remote'
? 'border-[var(--primary-base)] text-[var(--primary-base)] bg-[var(--primary-base)]/10 hover:text-[var(--primary-base)]'
: 'text-foreground'
)}
> >
Remote · SSE Remote · SSE
</ButtonSmall> </ButtonSmall>
</div> </div>
</div> </div>
</div>
{/* ── Connection ── */} </section>
<div className="space-y-2"> </div>
{/* Connection */}
<div className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">
{mcpType === 'local' ? 'Command' : 'Server URL'}
</h3>
</div>
<section className="px-2 pb-2 pt-0">
{mcpType === 'local' ? ( {mcpType === 'local' ? (
<>
<label className="typography-ui-label font-medium text-foreground">
Command
</label>
<CommandTextarea value={command} onChange={setCommand} /> <CommandTextarea value={command} onChange={setCommand} />
</>
) : ( ) : (
<>
<label className="typography-ui-label font-medium text-foreground">
Server URL
</label>
<Input <Input
value={url} value={url}
onChange={(e) => setUrl(e.target.value)} onChange={(e) => setUrl(e.target.value)}
placeholder="https://mcp.example.com/mcp" placeholder="https://mcp.example.com/mcp"
className="font-mono typography-meta" className="font-mono typography-meta"
/> />
<p className="typography-micro text-muted-foreground">
SSE endpoint URL of the remote MCP server
</p>
</>
)} )}
</section>
</div> </div>
{/* ── Environment Variables ── */} {/* Environment Variables */}
<div className="space-y-2"> <div className="mb-2">
<div className="flex items-baseline justify-between"> <div className="mb-1 px-1">
<label className="typography-ui-label font-medium text-foreground"> <h3 className="typography-ui-header font-medium text-foreground">
Environment Variables Environment Variables
{envEntries.length > 0 && ( {envEntries.length > 0 && (
<span className="ml-1.5 typography-micro text-muted-foreground font-normal"> <span className="ml-1.5 typography-micro text-muted-foreground font-normal">
({envEntries.length}) ({envEntries.length})
</span> </span>
)} )}
</label> </h3>
</div> </div>
<section className="px-2 pb-2 pt-0">
<EnvEditor value={envEntries} onChange={setEnvEntries} /> <EnvEditor value={envEntries} onChange={setEnvEntries} />
</section>
</div> </div>
{/* ── Actions ── */} {/* Actions */}
<div className="flex items-center justify-between border-t border-[var(--interactive-border)] pt-4 gap-4"> <div className="flex items-center gap-2 px-2 py-1">
{!isNewServer ? ( <ButtonSmall
<Button
size="sm"
variant="ghost"
onClick={() => setShowDeleteConfirm(true)}
className="h-7 gap-1.5 typography-meta text-destructive hover:text-destructive hover:bg-destructive/10"
>
<RiDeleteBinLine className="h-3.5 w-3.5" />
Delete
</Button>
) : <div />}
<Button
size="sm"
onClick={handleSave} onClick={handleSave}
disabled={isSaving || (!isDirty && !isNewServer)} disabled={isSaving || (!isDirty && !isNewServer)}
className="h-7 gap-1.5 typography-meta" size="xs"
className="!font-normal"
> >
<RiSaveLine className="h-3.5 w-3.5" /> {isSaving ? 'Saving...' : isNewServer ? 'Create' : 'Save Changes'}
{isSaving ? 'Saving…' : isNewServer ? 'Create' : 'Save changes'} </ButtonSmall>
</Button> {!isNewServer && (
<ButtonSmall
variant="ghost"
size="xs"
className="!font-normal text-[var(--status-error)] hover:text-[var(--status-error)]"
onClick={() => setShowDeleteConfirm(true)}
>
Delete
</ButtonSmall>
)}
</div> </div>
</div> </div>
@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { Button } from '@/components/ui/button'; import { ButtonSmall } from '@/components/ui/button-small';
import { ButtonLarge } from '@/components/ui/button-large'; import { ButtonLarge } from '@/components/ui/button-large';
import { import {
Dialog, Dialog,
@@ -9,14 +9,15 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { RiAddLine, RiDeleteBinLine, RiMore2Line, RiPlugLine, RiServerLine } from '@remixicon/react'; import { RiAddLine, RiDeleteBinLine, RiMore2Line, RiPlugLine } from '@remixicon/react';
import { useMcpConfigStore, type McpDraft, type McpServerConfig } from '@/stores/useMcpConfigStore'; import { useMcpConfigStore, type McpDraft, type McpServerConfig } from '@/stores/useMcpConfigStore';
import { useMcpStore } from '@/stores/useMcpStore'; import { useMcpStore } from '@/stores/useMcpStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { isVSCodeRuntime } from '@/lib/desktop'; import { isMobileDeviceViaCSS } from '@/lib/device';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { toast } from '@/components/ui'; import { toast } from '@/components/ui';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { SettingsProjectSelector } from '@/components/sections/shared/SettingsProjectSelector';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -48,9 +49,9 @@ const StatusDot: React.FC<{ tone: StatusTone; enabled: boolean }> = ({ tone, ena
); );
} }
const classes: Record<StatusTone, string> = { const classes: Record<StatusTone, string> = {
success: 'bg-green-500', success: 'bg-[var(--status-success)]',
error: 'bg-destructive', error: 'bg-[var(--status-error)]',
warning: 'bg-yellow-500', warning: 'bg-[var(--status-warning)]',
idle: 'bg-muted-foreground/40', idle: 'bg-muted-foreground/40',
}; };
return ( return (
@@ -59,8 +60,7 @@ const StatusDot: React.FC<{ tone: StatusTone; enabled: boolean }> = ({ tone, ena
}; };
export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => { export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []); const bgClass = 'bg-background';
const bgClass = isVSCode ? 'bg-background' : 'bg-sidebar';
const { mcpServers, selectedMcpName, setSelectedMcp, setMcpDraft, loadMcpConfigs, deleteMcp } = const { mcpServers, selectedMcpName, setSelectedMcp, setMcpDraft, loadMcpConfigs, deleteMcp } =
useMcpConfigStore(); useMcpConfigStore();
@@ -70,6 +70,16 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
const [deleteTarget, setDeleteTarget] = React.useState<McpServerConfig | null>(null); const [deleteTarget, setDeleteTarget] = React.useState<McpServerConfig | null>(null);
const [isDeleting, setIsDeleting] = React.useState(false); const [isDeleting, setIsDeleting] = React.useState(false);
const [openMenuMcp, setOpenMenuMcp] = React.useState<string | null>(null);
const projectServers = React.useMemo(
() => mcpServers.filter((server) => server.scope === 'project'),
[mcpServers]
);
const userServers = React.useMemo(
() => mcpServers.filter((server) => server.scope !== 'project'),
[mcpServers]
);
React.useEffect(() => { React.useEffect(() => {
void loadMcpConfigs(); void loadMcpConfigs();
@@ -113,22 +123,21 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
return ( return (
<div className={cn('flex h-full flex-col', bgClass)}> <div className={cn('flex h-full flex-col', bgClass)}>
{/* Header */} <div className="border-b px-3 pt-4 pb-3">
<div className="border-b px-3 py-3"> <h2 className="text-base font-semibold text-foreground mb-3">MCP Servers</h2>
<SettingsProjectSelector className="mb-3" />
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground"> <span className="typography-meta text-muted-foreground">
{mcpServers.length} server{mcpServers.length !== 1 ? 's' : ''} Total {mcpServers.length}
</span> </span>
<Button <ButtonSmall
type="button"
variant="ghost" variant="ghost"
size="icon" className="h-7 w-7 px-0 -my-1 text-muted-foreground"
className="h-7 w-7 -my-1 text-muted-foreground"
onClick={handleCreateNew} onClick={handleCreateNew}
title="Add MCP server" title="Add MCP server"
> >
<RiAddLine className="size-4" /> <RiAddLine className="h-3.5 w-3.5" />
</Button> </ButtonSmall>
</div> </div>
</div> </div>
@@ -141,18 +150,29 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
<p className="typography-meta mt-1 opacity-75">Use the + button above to add one</p> <p className="typography-meta mt-1 opacity-75">Use the + button above to add one</p>
</div> </div>
) : ( ) : (
mcpServers.map((server) => { <>
{projectServers.length > 0 && (
<>
<div className="px-2 pb-1.5 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Project Servers
</div>
{projectServers.map((server) => {
const runtimeStatus = mcpStatus[server.name]; const runtimeStatus = mcpStatus[server.name];
const tone = statusToneFromMcp(runtimeStatus?.status); const tone = statusToneFromMcp(runtimeStatus?.status);
const isSelected = selectedMcpName === server.name; const isSelected = selectedMcpName === server.name;
const isMobile = isMobileDeviceViaCSS();
return ( return (
<div <div
key={server.name} key={server.name}
className={cn( className={cn(
'group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200', 'group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200 select-none',
isSelected ? 'bg-interactive-selection' : 'hover:bg-interactive-hover', isSelected ? 'bg-interactive-selection' : 'hover:bg-interactive-hover',
)} )}
onContextMenu={!isMobile ? (e) => {
e.preventDefault();
setOpenMenuMcp(server.name);
} : undefined}
> >
<button <button
onClick={() => { onClick={() => {
@@ -164,17 +184,10 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<StatusDot tone={tone} enabled={server.enabled} /> <StatusDot tone={tone} enabled={server.enabled} />
<span className="typography-ui-label font-normal truncate text-foreground"> <span className="typography-ui-label font-normal truncate text-foreground">{server.name}</span>
{server.name}
</span>
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50"> <span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
{server.type} {server.type}
</span> </span>
{!server.enabled && (
<span className="typography-micro text-muted-foreground/60 flex-shrink-0">
off
</span>
)}
</div> </div>
<div className="typography-micro text-muted-foreground/60 truncate leading-tight pl-4"> <div className="typography-micro text-muted-foreground/60 truncate leading-tight pl-4">
{server.type === 'local' {server.type === 'local'
@@ -183,15 +196,11 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
</div> </div>
</button> </button>
<DropdownMenu> <DropdownMenu open={openMenuMcp === server.name} onOpenChange={(open) => setOpenMenuMcp(open ? server.name : null)}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <ButtonSmall variant="ghost" className="h-6 w-6 px-0 flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100">
size="icon"
variant="ghost"
className="h-6 w-6 flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"
>
<RiMore2Line className="h-3.5 w-3.5" /> <RiMore2Line className="h-3.5 w-3.5" />
</Button> </ButtonSmall>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit min-w-20"> <DropdownMenuContent align="end" className="w-fit min-w-20">
<DropdownMenuItem <DropdownMenuItem
@@ -208,7 +217,80 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
</DropdownMenu> </DropdownMenu>
</div> </div>
); );
}) })}
</>
)}
{userServers.length > 0 && (
<>
<div className="px-2 pb-1.5 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
User Servers
</div>
{userServers.map((server) => {
const runtimeStatus = mcpStatus[server.name];
const tone = statusToneFromMcp(runtimeStatus?.status);
const isSelected = selectedMcpName === server.name;
const isMobile = isMobileDeviceViaCSS();
return (
<div
key={server.name}
className={cn(
'group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200 select-none',
isSelected ? 'bg-interactive-selection' : 'hover:bg-interactive-hover',
)}
onContextMenu={!isMobile ? (e) => {
e.preventDefault();
setOpenMenuMcp(server.name);
} : undefined}
>
<button
onClick={() => {
setSelectedMcp(server.name);
setMcpDraft(null);
onItemSelect?.();
}}
className="flex min-w-0 flex-1 flex-col gap-0 rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
>
<div className="flex items-center gap-2">
<StatusDot tone={tone} enabled={server.enabled} />
<span className="typography-ui-label font-normal truncate text-foreground">{server.name}</span>
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
{server.type}
</span>
</div>
<div className="typography-micro text-muted-foreground/60 truncate leading-tight pl-4">
{server.type === 'local'
? (server as { command?: string[] }).command?.join(' ') ?? ''
: (server as { url?: string }).url ?? ''}
</div>
</button>
<DropdownMenu open={openMenuMcp === server.name} onOpenChange={(open) => setOpenMenuMcp(open ? server.name : null)}>
<DropdownMenuTrigger asChild>
<ButtonSmall variant="ghost" className="h-6 w-6 px-0 flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100">
<RiMore2Line className="h-3.5 w-3.5" />
</ButtonSmall>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit min-w-20">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setDeleteTarget(server);
}}
className="text-destructive focus:text-destructive"
>
<RiDeleteBinLine className="h-4 w-4 mr-px" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
})}
</>
)}
</>
)} )}
</ScrollableOverlay> </ScrollableOverlay>
@@ -226,14 +308,13 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button <ButtonLarge
variant="ghost" variant="ghost"
onClick={() => setDeleteTarget(null)} onClick={() => setDeleteTarget(null)}
disabled={isDeleting} disabled={isDeleting}
className="text-foreground hover:bg-interactive-hover hover:text-foreground"
> >
Cancel Cancel
</Button> </ButtonLarge>
<ButtonLarge onClick={handleDelete} disabled={isDeleting}> <ButtonLarge onClick={handleDelete} disabled={isDeleting}>
{isDeleting ? 'Deleting…' : 'Delete'} {isDeleting ? 'Deleting…' : 'Delete'}
</ButtonLarge> </ButtonLarge>
@@ -245,4 +326,4 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
}; };
// Re-export for easy sidebar icon usage // Re-export for easy sidebar icon usage
export { RiServerLine as McpIcon }; export { McpIcon } from '@/components/icons/McpIcon';
@@ -5,6 +5,7 @@ import { UpdateDialog } from '@/components/ui/UpdateDialog';
import { useDeviceInfo } from '@/lib/device'; import { useDeviceInfo } from '@/lib/device';
import { toast } from '@/components/ui'; import { toast } from '@/components/ui';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { ButtonSmall } from '@/components/ui/button-small';
const GITHUB_URL = 'https://github.com/btriapitsyn/openchamber'; const GITHUB_URL = 'https://github.com/btriapitsyn/openchamber';
@@ -67,7 +68,7 @@ export const AboutSettings: React.FC = () => {
{!isChecking && updateStore.available && ( {!isChecking && updateStore.available && (
<button <button
onClick={() => setUpdateDialogOpen(true)} onClick={() => setUpdateDialogOpen(true)}
className="flex items-center gap-1 typography-meta text-primary hover:underline" className="flex items-center gap-1 typography-meta text-[var(--primary-base)] hover:underline"
> >
<RiDownloadLine className="h-3.5 w-3.5" /> <RiDownloadLine className="h-3.5 w-3.5" />
Update Update
@@ -76,7 +77,7 @@ export const AboutSettings: React.FC = () => {
</div> </div>
{updateStore.error && ( {updateStore.error && (
<p className="typography-micro text-destructive truncate">{updateStore.error}</p> <p className="typography-micro text-[var(--status-error)] truncate">{updateStore.error}</p>
)} )}
{/* Links row */} {/* Links row */}
@@ -129,23 +130,23 @@ export const AboutSettings: React.FC = () => {
} }
// Desktop layout (unchanged) // Desktop layout (redesigned)
return ( return (
<div className="w-full space-y-6"> <div className="mb-8">
<div className="space-y-1"> <div className="mb-3 px-1">
<h3 className="typography-ui-header font-semibold text-foreground"> <h3 className="typography-ui-header font-semibold text-foreground">
About OpenChamber About OpenChamber
</h3> </h3>
</div> </div>
{/* Version and Update */} <div className="rounded-lg bg-[var(--surface-elevated)]/70 overflow-hidden flex flex-col">
<div className="space-y-3"> <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 items-center justify-between"> <div className="flex min-w-0 flex-col">
<div className="space-y-0.5"> <span className="typography-ui-label text-foreground">Version</span>
<div className="typography-ui-label text-muted-foreground">Version</div> <span className="typography-meta text-muted-foreground font-mono">{currentVersion}</span>
<div className="typography-ui-header font-mono">{currentVersion}</div>
</div> </div>
<div className="flex items-center gap-3">
{updateStore.checking && ( {updateStore.checking && (
<div className="flex items-center gap-2 text-muted-foreground"> <div className="flex items-center gap-2 text-muted-foreground">
<RiLoaderLine className="h-4 w-4 animate-spin" /> <RiLoaderLine className="h-4 w-4 animate-spin" />
@@ -154,54 +155,41 @@ export const AboutSettings: React.FC = () => {
)} )}
{!updateStore.checking && updateStore.available && ( {!updateStore.checking && updateStore.available && (
<button <ButtonSmall
variant="default"
onClick={() => setUpdateDialogOpen(true)} onClick={() => setUpdateDialogOpen(true)}
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-md',
'text-sm font-medium',
'bg-primary text-primary-foreground',
'hover:bg-primary/90',
'transition-colors'
)}
> >
<RiDownloadLine className="h-4 w-4" /> <RiDownloadLine className="h-4 w-4 mr-1" />
Update to {updateStore.info?.version} Update to {updateStore.info?.version}
</button> </ButtonSmall>
)} )}
{!updateStore.checking && !updateStore.available && !updateStore.error && ( {!updateStore.checking && !updateStore.available && !updateStore.error && (
<span className="typography-meta text-muted-foreground">Up to date</span> <span className="typography-meta text-muted-foreground">Up to date</span>
)} )}
<ButtonSmall
variant="outline"
onClick={() => updateStore.checkForUpdates()}
disabled={updateStore.checking}
>
Check for updates
</ButtonSmall>
</div>
</div> </div>
{updateStore.error && ( {updateStore.error && (
<p className="typography-meta text-destructive">{updateStore.error}</p> <div className="px-3 py-2 border-b border-[var(--surface-subtle)]">
)} <p className="typography-meta text-[var(--status-error)]">{updateStore.error}</p>
<button
onClick={() => updateStore.checkForUpdates()}
disabled={updateStore.checking}
className={cn(
'typography-meta text-muted-foreground hover:text-foreground',
'underline-offset-2 hover:underline',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
Check for updates
</button>
</div> </div>
)}
{/* Links */} <div className="flex items-center gap-4 px-4 py-4">
{/* Links */}
<div className="flex items-center gap-4">
<a <a
href={GITHUB_URL} href={GITHUB_URL}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className={cn( className="flex items-center gap-1.5 text-muted-foreground hover:text-foreground typography-meta transition-colors"
'flex items-center gap-1.5 text-muted-foreground hover:text-foreground',
'typography-meta transition-colors'
)}
> >
<RiGithubFill className="h-4 w-4" /> <RiGithubFill className="h-4 w-4" />
<span>GitHub</span> <span>GitHub</span>
@@ -211,17 +199,14 @@ export const AboutSettings: React.FC = () => {
href="https://x.com/btriapitsyn" href="https://x.com/btriapitsyn"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className={cn( className="flex items-center gap-1.5 text-muted-foreground hover:text-foreground typography-meta transition-colors"
'flex items-center gap-1.5 text-muted-foreground hover:text-foreground',
'typography-meta transition-colors'
)}
> >
<RiTwitterXFill className="h-4 w-4" /> <RiTwitterXFill className="h-4 w-4" />
<span>@btriapitsyn</span> <span>@btriapitsyn</span>
</a> </a>
</div> </div>
</div>
{/* Update Dialog */}
<UpdateDialog <UpdateDialog
open={updateDialogOpen} open={updateDialogOpen}
onOpenChange={setUpdateDialogOpen} onOpenChange={setUpdateDialogOpen}
@@ -8,20 +8,17 @@ import { Checkbox } from '@/components/ui/checkbox';
import { updateDesktopSettings } from '@/lib/persistence'; import { updateDesktopSettings } from '@/lib/persistence';
import { isVSCodeRuntime } from '@/lib/desktop'; import { isVSCodeRuntime } from '@/lib/desktop';
import { useConfigStore } from '@/stores/useConfigStore'; import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { getModifierLabel } from '@/lib/utils'; import { getModifierLabel, cn } from '@/lib/utils';
interface ZenModel { interface ZenModel {
id: string; id: string;
owned_by?: string; owned_by?: string;
} }
const FALLBACK_PROVIDER_ID = 'opencode';
const FALLBACK_MODEL_ID = 'big-pickle';
const getDisplayModel = ( const getDisplayModel = (
storedModel: string | undefined, storedModel: string | undefined
providers: Array<{ id: string; models: Array<{ id: string }> }>
): { providerId: string; modelId: string } => { ): { providerId: string; modelId: string } => {
if (storedModel) { if (storedModel) {
const parts = storedModel.split('/'); const parts = storedModel.split('/');
@@ -30,16 +27,8 @@ const getDisplayModel = (
} }
} }
const fallbackProvider = providers.find(p => p.id === FALLBACK_PROVIDER_ID); // Return empty values when no model is explicitly set
if (fallbackProvider?.models.some(m => m.id === FALLBACK_MODEL_ID)) { // This allows showing "Not selected" instead of a fallback
return { providerId: FALLBACK_PROVIDER_ID, modelId: FALLBACK_MODEL_ID };
}
const firstProvider = providers[0];
if (firstProvider?.models[0]) {
return { providerId: firstProvider.id, modelId: firstProvider.models[0].id };
}
return { providerId: '', modelId: '' }; return { providerId: '', modelId: '' };
}; };
@@ -55,6 +44,8 @@ export const DefaultsSettings: React.FC = () => {
const setSettingsAutoCreateWorktree = useConfigStore((state) => state.setSettingsAutoCreateWorktree); const setSettingsAutoCreateWorktree = useConfigStore((state) => state.setSettingsAutoCreateWorktree);
const settingsZenModel = useConfigStore((state) => state.settingsZenModel); const settingsZenModel = useConfigStore((state) => state.settingsZenModel);
const setSettingsZenModel = useConfigStore((state) => state.setSettingsZenModel); const setSettingsZenModel = useConfigStore((state) => state.setSettingsZenModel);
const showDeletionDialog = useUIStore((state) => state.showDeletionDialog);
const setShowDeletionDialog = useUIStore((state) => state.setShowDeletionDialog);
const providers = useConfigStore((state) => state.providers); const providers = useConfigStore((state) => state.providers);
const [defaultModel, setDefaultModel] = React.useState<string | undefined>(); const [defaultModel, setDefaultModel] = React.useState<string | undefined>();
@@ -65,8 +56,8 @@ export const DefaultsSettings: React.FC = () => {
const [zenModelsLoading, setZenModelsLoading] = React.useState(true); const [zenModelsLoading, setZenModelsLoading] = React.useState(true);
const parsedModel = React.useMemo(() => { const parsedModel = React.useMemo(() => {
return getDisplayModel(defaultModel, providers); return getDisplayModel(defaultModel);
}, [defaultModel, providers]); }, [defaultModel]);
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []); const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
@@ -275,8 +266,7 @@ export const DefaultsSettings: React.FC = () => {
} }
}, [defaultVariant, setCurrentVariant, setSettingsDefaultVariant, supportsVariants]); }, [defaultVariant, setCurrentVariant, setSettingsDefaultVariant, supportsVariants]);
const handleAutoWorktreeChange = React.useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => { const handleAutoWorktreeChange = React.useCallback(async (enabled: boolean) => {
const enabled = e.target.checked;
setSettingsAutoCreateWorktree(enabled); setSettingsAutoCreateWorktree(enabled);
try { try {
await updateDesktopSettings({ await updateDesktopSettings({
@@ -303,52 +293,71 @@ export const DefaultsSettings: React.FC = () => {
} }
return ( return (
<div className="space-y-4"> <div className="mb-6">
<div className="space-y-1"> <div className="mb-0.5 px-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h3 className="typography-ui-header font-semibold text-foreground">Session Defaults</h3> <h3 className="typography-ui-header font-medium text-foreground">Session Defaults</h3>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
Configure default behaviors for new sessions.
</TooltipContent>
</Tooltip>
</div> </div>
</div> </div>
<div className="space-y-3"> <section className="px-2 pb-2 pt-0 space-y-0">
<div className="flex flex-col gap-1.5"> <div className="mt-0 mb-1 typography-meta text-muted-foreground">
<label className="typography-ui-label text-muted-foreground">Default model</label> New sessions will start with:{' '}
{parsedModel.providerId ? (
<span className="text-foreground">
{parsedModel.providerId}/{parsedModel.modelId}
{supportsVariants ? ` (${defaultVariant ?? 'default'})` : ''}
</span>
) : (
<span className="text-foreground">opencode agent default</span>
)}
{defaultAgent && (
<>
{' / '}
<span className="text-foreground">{defaultAgent}</span>
</>
)}
</div>
<div 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">Default Model</span>
</div>
<div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial">
<ModelSelector <ModelSelector
providerId={parsedModel.providerId} providerId={parsedModel.providerId}
modelId={parsedModel.modelId} modelId={parsedModel.modelId}
onChange={handleModelChange} onChange={handleModelChange}
/> />
</div> </div>
</div>
{supportsVariants && ( <div className="flex flex-col gap-2 py-1 sm:flex-row sm:items-center sm:gap-8">
<div className="flex flex-col gap-1.5"> <div className="flex min-w-0 flex-col sm:w-56 shrink-0">
<label className="typography-ui-label text-muted-foreground">Default thinking</label> <span className="typography-ui-label text-foreground">Default Thinking</span>
<Select value={defaultVariant ?? DEFAULT_VARIANT_VALUE} onValueChange={handleVariantChange}> </div>
<SelectTrigger className="w-auto max-w-xs typography-meta text-foreground"> <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="Thinking" /> <SelectValue placeholder="Thinking" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value={DEFAULT_VARIANT_VALUE} className="pr-2 [&>span:first-child]:hidden">Default</SelectItem> <SelectItem value={DEFAULT_VARIANT_VALUE}>Default</SelectItem>
{availableVariants.map((variant) => ( {availableVariants.map((variant) => (
<SelectItem key={variant} value={variant} className="pr-2 [&>span:first-child]:hidden"> <SelectItem key={variant} value={variant}>
{variant} {variant}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
)} </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-2 py-1 sm:flex-row sm:items-center sm:gap-8">
<label className="typography-ui-label text-muted-foreground">Default agent</label> <div className="flex min-w-0 flex-col sm:w-56 shrink-0">
<span className="typography-ui-label text-foreground">Default Agent</span>
</div>
<div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial">
<AgentSelector <AgentSelector
agentName={defaultAgent || ''} agentName={defaultAgent || ''}
onChange={handleAgentChange} onChange={handleAgentChange}
@@ -356,44 +365,10 @@ export const DefaultsSettings: React.FC = () => {
</div> </div>
</div> </div>
{(parsedModel.providerId || defaultAgent) && ( <div className="flex flex-col gap-2 py-1 sm:flex-row sm:items-center sm:gap-8">
<div className="typography-meta text-muted-foreground"> <div className="flex min-w-0 flex-col sm:w-56 shrink-0">
New sessions will start with:{' '}
{parsedModel.providerId && (
<span className="text-foreground">
{parsedModel.providerId}/{parsedModel.modelId}
{supportsVariants ? ` (${defaultVariant ?? 'default'})` : ''}
</span>
)}
{parsedModel.providerId && defaultAgent && ' / '}
{defaultAgent && <span className="text-foreground">{defaultAgent}</span>}
</div>
)}
{!isVSCode && (
<div className="pt-2">
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={settingsAutoCreateWorktree}
onChange={(checked) => handleAutoWorktreeChange({ target: { checked } } as React.ChangeEvent<HTMLInputElement>)}
/>
<span className="typography-ui-label text-foreground">
Always create worktree for new sessions
</span>
</label>
<p className="typography-meta text-muted-foreground pl-5 mt-1">
{settingsAutoCreateWorktree
? `New session (Worktree): ${getModifierLabel()} + N • New session (Standard): Shift + ${getModifierLabel()} + N`
: `New session (Standard): ${getModifierLabel()} + N • New session (Worktree): Shift + ${getModifierLabel()} + N`}
</p>
</div>
)}
<div className="border-t border-border/40 pt-4 mt-4 space-y-3">
<div className="space-y-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h3 className="typography-ui-header font-semibold text-foreground">Zen Model</h3> <span className="typography-ui-label text-foreground">Zen Model</span>
<Tooltip delayDuration={1000}> <Tooltip delayDuration={1000}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" /> <RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
@@ -403,23 +378,18 @@ export const DefaultsSettings: React.FC = () => {
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
<p className="typography-meta text-muted-foreground">
Used for commit messages, PR descriptions, and text summarization.
</p>
</div> </div>
<div className="flex items-center gap-2 sm:w-fit">
<div className="flex flex-col gap-1.5">
<label className="typography-ui-label text-muted-foreground">Model</label>
{zenModelsLoading ? ( {zenModelsLoading ? (
<span className="typography-meta text-muted-foreground">Loading models...</span> <span className="typography-meta text-muted-foreground">Loading models...</span>
) : zenModels.length > 0 ? ( ) : zenModels.length > 0 ? (
<Select value={selectedZenModel} onValueChange={handleZenModelChange}> <Select value={selectedZenModel} onValueChange={handleZenModelChange}>
<SelectTrigger className="w-auto max-w-xs typography-meta text-foreground"> <SelectTrigger className="w-fit min-w-[120px]">
<SelectValue placeholder="Select model" /> <SelectValue placeholder="Select model" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{zenModels.map((model) => ( {zenModels.map((model) => (
<SelectItem key={model.id} value={model.id} className="pr-2 [&>span:first-child]:hidden"> <SelectItem key={model.id} value={model.id}>
{model.id} {model.id}
</SelectItem> </SelectItem>
))} ))}
@@ -430,6 +400,71 @@ export const DefaultsSettings: React.FC = () => {
)} )}
</div> </div>
</div> </div>
<div
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="Show deletion dialog"
/>
<span className="typography-ui-label text-foreground">Show Deletion Dialog</span>
</div>
{!isVSCode && (
<div
className="group flex cursor-pointer items-center gap-2 py-1"
role="button"
tabIndex={0}
aria-pressed={settingsAutoCreateWorktree}
onClick={() => {
void handleAutoWorktreeChange(!settingsAutoCreateWorktree);
}}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
void handleAutoWorktreeChange(!settingsAutoCreateWorktree);
}
}}
>
<Checkbox
checked={settingsAutoCreateWorktree}
onChange={(checked) => {
void handleAutoWorktreeChange(checked);
}}
ariaLabel="Always create worktree"
/>
<div className="flex min-w-0 flex-col">
<div className="flex items-center gap-1.5">
<span className="typography-ui-label text-foreground">Always Create Worktree</span>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
{settingsAutoCreateWorktree
? `New session (Worktree): ${getModifierLabel()}+N\nStandard: Shift+${getModifierLabel()}+N`
: `New session (Standard): ${getModifierLabel()}+N\nWorktree: Shift+${getModifierLabel()}+N`}
</TooltipContent>
</Tooltip>
</div>
</div>
</div>
)}
</section>
</div> </div>
); );
}; };
@@ -1,10 +1,14 @@
import React from 'react'; import React from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { ButtonSmall } from '@/components/ui/button-small';
import { toast } from '@/components/ui'; import { toast } from '@/components/ui';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import type { GitHubAuthStatus } from '@/lib/api/types'; import type { GitHubAuthStatus } from '@/lib/api/types';
import { RiGithubFill } from '@remixicon/react'; import { useDeviceInfo } from '@/lib/device';
import { cn } from '@/lib/utils';
import { RiGithubFill, RiInformationLine } from '@remixicon/react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
type GitHubUser = { type GitHubUser = {
login: string; login: string;
@@ -29,6 +33,7 @@ type DeviceFlowCompleteResponse =
| { connected: false; status?: string; error?: string }; | { connected: false; status?: string; error?: string };
export const GitHubSettings: React.FC = () => { export const GitHubSettings: React.FC = () => {
const { isMobile } = useDeviceInfo();
const runtimeGitHub = getRegisteredRuntimeAPIs()?.github; const runtimeGitHub = getRegisteredRuntimeAPIs()?.github;
const status = useGitHubAuthStore((state) => state.status); const status = useGitHubAuthStore((state) => state.status);
const isLoading = useGitHubAuthStore((state) => state.isLoading); const isLoading = useGitHubAuthStore((state) => state.isLoading);
@@ -252,134 +257,149 @@ export const GitHubSettings: React.FC = () => {
const accounts = status?.accounts ?? []; const accounts = status?.accounts ?? [];
return ( return (
<div className="space-y-6"> <div className="mb-8">
<div className="space-y-1"> <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">GitHub</h3> <h3 className="typography-ui-header font-semibold text-foreground">GitHub</h3>
<p className="typography-meta text-muted-foreground"> <Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
Connect a GitHub account for in-app PR and issue workflows. Connect a GitHub account for in-app PR and issue workflows.
</p> </TooltipContent>
</Tooltip>
</div>
</div> </div>
<div className="rounded-lg bg-[var(--surface-elevated)]/70 overflow-hidden flex flex-col">
{connected ? ( {connected ? (
<div className="flex items-center justify-between gap-4 rounded-lg border bg-background/50 px-4 py-3"> <div className={cn("px-4 py-3", isMobile ? "flex flex-col gap-3" : "flex items-center justify-between gap-4")}>
<div className="flex min-w-0 items-center gap-4"> <div className={cn("flex min-w-0 items-center gap-4", isMobile ? "w-full" : undefined)}>
{user?.avatarUrl ? ( {user?.avatarUrl ? (
<img <img
src={user.avatarUrl} src={user.avatarUrl}
alt={user.login ? `${user.login} avatar` : 'GitHub avatar'} alt={user.login ? `${user.login} avatar` : 'GitHub avatar'}
className="h-14 w-14 shrink-0 rounded-full border border-border/60 bg-muted object-cover" className="h-10 w-10 shrink-0 rounded-full border border-[var(--interactive-border)] bg-[var(--surface-muted)] object-cover"
loading="lazy" loading="lazy"
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
/> />
) : ( ) : (
<div className="h-14 w-14 shrink-0 rounded-full border border-border/60 bg-muted" /> <div className="h-10 w-10 shrink-0 rounded-full border border-[var(--interactive-border)] bg-[var(--surface-muted)]" />
)} )}
<div className="min-w-0"> <div className="min-w-0 flex-1">
<div className="typography-ui-header font-semibold text-foreground truncate"> <div className="typography-ui-label text-foreground">
{user?.name?.trim() || user?.login || 'GitHub'} {user?.name?.trim() || user?.login || 'GitHub'}
</div> </div>
{user?.email ? ( <div className={cn("flex items-center gap-2 typography-meta text-muted-foreground mt-0.5", isMobile ? "flex-wrap" : "truncate")}>
<div className="typography-body text-muted-foreground truncate">{user.email}</div> <RiGithubFill className="h-3.5 w-3.5 shrink-0" />
) : null}
<div className="mt-1 flex items-center gap-2 typography-meta text-muted-foreground truncate">
<RiGithubFill className="h-4 w-4" />
<span className="font-mono">{user?.login || 'unknown'}</span> <span className="font-mono">{user?.login || 'unknown'}</span>
{user?.email && <span className="opacity-50">•</span>}
{user?.email && <span>{user.email}</span>}
</div> </div>
{status?.scope ? ( {status?.scope && (
<div className="typography-micro text-muted-foreground truncate">Scopes: {status.scope}</div> <div className="typography-micro text-muted-foreground/70 mt-0.5">Scopes: {status.scope}</div>
) : null} )}
</div> </div>
</div> </div>
<Button variant="outline" onClick={disconnect} disabled={isBusy}> <ButtonSmall variant="outline" onClick={disconnect} disabled={isBusy} className={cn("text-[var(--status-error)] hover:text-[var(--status-error)]", isMobile ? "w-full" : undefined)}>
Disconnect Disconnect
</Button> </ButtonSmall>
</div> </div>
) : ( ) : (
<div className="flex items-center justify-between gap-3 rounded-lg border bg-background/50 px-3 py-2"> <div className="flex items-center justify-between gap-4 px-4 py-4">
<div className="typography-ui-label text-foreground">Not connected</div> <div className="flex min-w-0 flex-col">
<Button onClick={startConnect} disabled={isBusy}> <span className="typography-ui-label text-foreground">Not Connected</span>
Connect </div>
</Button> <ButtonSmall variant="default" onClick={startConnect} disabled={isBusy}>
Connect GitHub
</ButtonSmall>
</div> </div>
)} )}
{connected ? ( {accounts.length > 1 && (
<div className="flex justify-end"> <div className="mt-2 border-t border-[var(--surface-subtle)] pt-2 px-2 pb-1">
<Button variant="ghost" onClick={startConnect} disabled={isBusy}> <div className="typography-micro text-muted-foreground mb-2 px-1">Other Accounts</div>
Add account <div className="space-y-1">
</Button>
</div>
) : null}
{accounts.length > 1 ? (
<div className="space-y-2 rounded-lg border bg-background/50 p-3">
<div className="typography-ui-label text-foreground">Accounts</div>
<div className="space-y-2">
{accounts.map((account) => { {accounts.map((account) => {
const accountUser = account.user; const accountUser = account.user;
const isCurrent = Boolean(account.current); const isCurrent = Boolean(account.current);
return ( return (
<div <div
key={account.id} key={account.id}
className="flex items-center justify-between gap-3 rounded-md border border-border/40 bg-background/70 px-3 py-2" className="flex items-center justify-between gap-3 rounded-md border border-[var(--surface-subtle)] bg-[var(--surface-muted)] px-3 py-2"
> >
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
{accountUser?.avatarUrl ? ( {accountUser?.avatarUrl ? (
<img <img
src={accountUser.avatarUrl} src={accountUser.avatarUrl}
alt={accountUser.login ? `${accountUser.login} avatar` : 'GitHub avatar'} alt={accountUser.login ? `${accountUser.login} avatar` : 'GitHub avatar'}
className="h-8 w-8 shrink-0 rounded-full border border-border/60 bg-muted object-cover" className="h-6 w-6 shrink-0 rounded-full border border-[var(--interactive-border)] bg-[var(--surface-muted)] object-cover"
loading="lazy" loading="lazy"
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
/> />
) : ( ) : (
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-border/60 bg-muted"> <div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-[var(--interactive-border)] bg-[var(--surface-muted)]">
<RiGithubFill className="h-4 w-4 text-muted-foreground" /> <RiGithubFill className="h-3 w-3 text-muted-foreground" />
</div> </div>
)} )}
<div className="min-w-0"> <div className="min-w-0 flex flex-col">
<div className="typography-ui-label text-foreground truncate"> <span className="typography-ui-label text-foreground truncate">
{accountUser?.name?.trim() || accountUser?.login || 'GitHub'} {accountUser?.name?.trim() || accountUser?.login || 'GitHub'}
</div> </span>
{accountUser?.login ? ( {accountUser?.login && (
<div className="typography-micro text-muted-foreground truncate font-mono"> <span className="typography-micro text-muted-foreground truncate font-mono">
{accountUser.login} {accountUser.login}
</div> </span>
) : null} )}
</div> </div>
</div> </div>
{isCurrent ? ( {isCurrent ? (
<span className="typography-micro text-primary">Active</span> <span className="typography-micro text-[var(--primary-base)] bg-[var(--primary-base)]/10 px-1.5 py-0.5 rounded">Active</span>
) : ( ) : (
<Button <ButtonSmall
size="sm"
variant="ghost" variant="ghost"
onClick={() => activateAccount(account.id)} onClick={() => activateAccount(account.id)}
disabled={isBusy} disabled={isBusy}
> >
Use Switch to
</Button> </ButtonSmall>
)} )}
</div> </div>
); );
})} })}
</div> </div>
</div> </div>
) : null} )}
{flow ? ( </div>
<div className="space-y-3 rounded-lg border bg-background/50 p-3">
{connected && (
<div className="mt-2 px-2 pb-2">
<ButtonSmall
variant="outline"
onClick={startConnect}
disabled={isBusy}
className={cn(isMobile ? 'w-full' : undefined)}
>
Add Account
</ButtonSmall>
</div>
)}
{flow && (
<div className="mt-4 rounded-lg bg-[var(--surface-elevated)]/70 p-4 border border-[var(--interactive-border)]">
<div className="space-y-1"> <div className="space-y-1">
<div className="typography-ui-label text-foreground">Authorize OpenChamber</div> <h4 className="typography-ui-label text-foreground">Authorize OpenChamber</h4>
<div className="typography-meta text-muted-foreground"> <p className="typography-meta text-muted-foreground">
In GitHub, enter this code: In GitHub, enter the following code to authorize this device:
</p>
</div> </div>
</div> <div className="flex items-center justify-between gap-3 mt-4">
<div className="flex items-center justify-between gap-3"> <div className="font-mono text-xl tracking-widest text-foreground bg-[var(--surface-muted)] px-3 py-1.5 rounded-md border border-[var(--interactive-border)]">{flow.userCode}</div>
<div className="font-mono text-lg tracking-widest text-foreground">{flow.userCode}</div> <Button size="sm" asChild>
<Button variant="outline" asChild>
<a <a
href={flow.verificationUriComplete || flow.verificationUri} href={flow.verificationUriComplete || flow.verificationUri}
target="_blank" target="_blank"
@@ -389,19 +409,19 @@ export const GitHubSettings: React.FC = () => {
</a> </a>
</Button> </Button>
</div> </div>
<div className="typography-micro text-muted-foreground"> <div className="mt-4 flex items-center justify-between">
<span className="typography-micro text-muted-foreground animate-pulse">
Waiting for approval… (auto-refresh) Waiting for approval… (auto-refresh)
</div> </span>
<div className="flex justify-end"> <ButtonSmall variant="ghost" disabled={isBusy} onClick={() => {
<Button variant="ghost" disabled={isBusy} onClick={() => {
stopPolling(); stopPolling();
setFlow(null); setFlow(null);
}}> }}>
Cancel Cancel
</Button> </ButtonSmall>
</div> </div>
</div> </div>
) : null} )}
</div> </div>
); );
}; };
@@ -1,6 +1,4 @@
import React from 'react'; import React from 'react';
import { RiInformationLine } from '@remixicon/react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { updateDesktopSettings } from '@/lib/persistence'; import { updateDesktopSettings } from '@/lib/persistence';
import { useConfigStore } from '@/stores/useConfigStore'; import { useConfigStore } from '@/stores/useConfigStore';
@@ -14,7 +12,6 @@ export const GitSettings: React.FC = () => {
const [isLoading, setIsLoading] = React.useState(true); const [isLoading, setIsLoading] = React.useState(true);
// Load current settings // Load current settings
React.useEffect(() => { React.useEffect(() => {
const loadSettings = async () => { const loadSettings = async () => {
@@ -67,8 +64,7 @@ export const GitSettings: React.FC = () => {
loadSettings(); loadSettings();
}, [setSettingsGitmojiEnabled]); }, [setSettingsGitmojiEnabled]);
const handleGitmojiChange = React.useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => { const handleGitmojiChange = React.useCallback(async (enabled: boolean) => {
const enabled = event.target.checked;
setSettingsGitmojiEnabled(enabled); setSettingsGitmojiEnabled(enabled);
try { try {
await updateDesktopSettings({ await updateDesktopSettings({
@@ -84,56 +80,58 @@ export const GitSettings: React.FC = () => {
} }
return ( return (
<div className="space-y-6"> <div className="mb-8">
<div className="space-y-1"> <div className="mb-1 px-1">
<div className="flex items-center gap-2"> <h3 className="typography-ui-header font-medium text-foreground">Git Preferences</h3>
<h3 className="typography-ui-header font-semibold text-foreground">Commit Messages</h3>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
Configure how commit messages are generated.
</TooltipContent>
</Tooltip>
</div>
</div> </div>
<div className="space-y-3"> <section className="px-2 pb-2 pt-0 space-y-0.5">
<div className="flex flex-col gap-2"> <div
<label className="flex items-center gap-2 cursor-pointer"> 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 <Checkbox
checked={settingsGitmojiEnabled} checked={settingsGitmojiEnabled}
onChange={(checked) => handleGitmojiChange({ target: { checked } } as React.ChangeEvent<HTMLInputElement>)} onChange={(checked) => {
void handleGitmojiChange(checked);
}}
ariaLabel="Enable Gitmoji picker"
/> />
<span className="typography-ui-label text-foreground">Enable gitmoji picker</span> <span className="typography-ui-label text-foreground">Enable Gitmoji Picker</span>
</label>
<p className="typography-meta text-muted-foreground pl-5.5">
Adds a gitmoji selector to the Git commit message input.
</p>
</div>
</div> </div>
<div className="space-y-1"> <div
<h3 className="typography-ui-header font-semibold text-foreground">Files Overview</h3> className="group flex cursor-pointer items-center gap-2 py-1.5"
<p className="typography-meta text-muted-foreground"> role="button"
Show gitignored files in the Files browser pane only. tabIndex={0}
</p> aria-pressed={showGitignored}
</div> onClick={() => setFilesViewShowGitignored(!showGitignored)}
<div className="space-y-3"> onKeyDown={(event) => {
<div className="flex flex-col gap-2"> if (event.key === ' ' || event.key === 'Enter') {
<label className="flex items-center gap-2 cursor-pointer"> event.preventDefault();
setFilesViewShowGitignored(!showGitignored);
}
}}
>
<Checkbox <Checkbox
checked={showGitignored} checked={showGitignored}
onChange={setFilesViewShowGitignored} onChange={setFilesViewShowGitignored}
ariaLabel="Display gitignored files"
/> />
<span className="typography-ui-label text-foreground">Display gitignored files</span> <span className="typography-ui-label text-foreground">Display Gitignored Files</span>
</label>
<p className="typography-meta text-muted-foreground pl-5.5">
Toggles gitignored files in the Files tree and search results.
</p>
</div>
</div> </div>
</section>
</div> </div>
); );
}; };
@@ -1,7 +1,10 @@
import React from 'react'; import React from 'react';
import { Button } from '@/components/ui/button'; import { ButtonSmall } from '@/components/ui/button-small';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { RiInformationLine } from '@remixicon/react';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { cn } from '@/lib/utils';
import { import {
formatShortcutForDisplay, formatShortcutForDisplay,
getCustomizableShortcutActions, getCustomizableShortcutActions,
@@ -125,31 +128,75 @@ export const KeyboardShortcutsSettings: React.FC = () => {
}, [clearShortcutOverride]); }, [clearShortcutOverride]);
return ( return (
<div className="space-y-4"> <div className="mb-8">
<div className="space-y-1"> <div className="mb-1 px-1">
<h3 className="typography-ui-header font-semibold text-foreground">Keyboard Shortcuts</h3> <div className="flex items-center gap-2">
<p className="typography-meta text-muted-foreground"> <h3 className="typography-ui-header font-medium text-foreground">Keyboard Shortcuts</h3>
Capture a new key combo, save it, and the runtime/help/palette bindings update together. <ButtonSmall
</p> type="button"
variant="outline"
size="xs"
className="!font-normal"
onClick={() => {
resetAllShortcutOverrides();
setDraftByAction({});
setPendingOverwrite(null);
setErrorText('');
setWarningText('');
}}
>
Reset All
</ButtonSmall>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
Capture a new key combo, save it, and bindings will update immediately.
</TooltipContent>
</Tooltip>
</div>
</div> </div>
<div className="space-y-2"> {(errorText || warningText || pendingOverwrite) && (
{actions.map((action) => { <div className="mb-2 space-y-2 px-1">
{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">
<span className="typography-meta text-foreground">
This combo is already used by another shortcut. Overwrite and clear that other mapping?
</span>
<div className="flex gap-2 shrink-0">
<ButtonSmall type="button" size="xs" className="!font-normal" onClick={confirmOverwrite}>Overwrite</ButtonSmall>
<ButtonSmall type="button" size="xs" className="!font-normal" variant="ghost" onClick={() => setPendingOverwrite(null)}>Cancel</ButtonSmall>
</div>
</div>
)}
{errorText && (
<div className="rounded-lg border border-[var(--status-error-border)] bg-[var(--status-error-background)] p-3 typography-meta text-foreground">
{errorText}
</div>
)}
{warningText && (
<div className="rounded-lg border border-[var(--status-warning-border)] bg-[var(--status-warning-background)] p-3 typography-meta text-foreground">
{warningText}
</div>
)}
</div>
)}
<section className="px-2 pb-2 pt-0 space-y-0.5">
{actions.map((action, index) => {
const effective = getEffectiveShortcutCombo(action.id, shortcutOverrides); const effective = getEffectiveShortcutCombo(action.id, shortcutOverrides);
const draft = draftByAction[action.id]; const draft = draftByAction[action.id];
const displayCombo = draft ?? effective; const displayCombo = draft ?? effective;
const hasDraft = typeof draft === 'string' && normalizeCombo(draft) !== normalizeCombo(effective); const hasDraft = typeof draft === 'string' && normalizeCombo(draft) !== normalizeCombo(effective);
return ( return (
<div key={action.id} className="rounded-md border border-border/60 p-3"> <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 items-start justify-between gap-3"> <div className="flex min-w-0 flex-col sm:w-56 shrink-0">
<div className="space-y-0.5"> <span className="typography-ui-label text-foreground">{action.label}</span>
<p className="typography-ui-label text-foreground">{action.label}</p>
{action.description && (
<p className="typography-meta text-muted-foreground">{action.description}</p>
)}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial">
<Input <Input
readOnly readOnly
value={capturingActionId === action.id ? 'Press keys...' : formatShortcutForDisplay(displayCombo)} value={capturingActionId === action.id ? 'Press keys...' : formatShortcutForDisplay(displayCombo)}
@@ -184,11 +231,13 @@ export const KeyboardShortcutsSettings: React.FC = () => {
setPendingOverwrite(null); setPendingOverwrite(null);
setErrorText(''); setErrorText('');
}} }}
className="w-52" className="h-7 w-40 min-w-0 typography-ui-label text-center"
/> />
<Button <ButtonSmall
type="button" type="button"
variant="secondary" variant="secondary"
size="xs"
className="!font-normal"
onClick={() => { onClick={() => {
const next = draftByAction[action.id]; const next = draftByAction[action.id];
if (!next) { if (!next) {
@@ -200,62 +249,15 @@ export const KeyboardShortcutsSettings: React.FC = () => {
disabled={!hasDraft} disabled={!hasDraft}
> >
Save Save
</Button> </ButtonSmall>
<Button type="button" variant="ghost" onClick={() => resetOne(action.id)}> <ButtonSmall type="button" size="xs" className="!font-normal" variant="ghost" onClick={() => resetOne(action.id)}>
Reset Reset
</Button> </ButtonSmall>
</div>
</div> </div>
</div> </div>
); );
})} })}
</div> </section>
{pendingOverwrite && (
<div className="rounded-md border border-[var(--status-warning-border)] bg-[var(--status-warning-background)] p-3">
<p className="typography-meta" style={{ color: 'var(--surface-foreground)' }}>
This combo is already used by another shortcut. Overwrite and clear that other mapping?
</p>
<div className="mt-2 flex gap-2">
<Button type="button" size="sm" onClick={confirmOverwrite}>Overwrite</Button>
<Button type="button" size="sm" variant="ghost" onClick={() => setPendingOverwrite(null)}>Cancel</Button>
</div>
</div>
)}
{errorText && (
<div
className="rounded-md border border-[var(--status-error-border)] bg-[var(--status-error-background)] p-2 typography-meta"
style={{ color: 'var(--surface-foreground)' }}
>
{errorText}
</div>
)}
{warningText && (
<div
className="rounded-md border border-[var(--status-warning-border)] bg-[var(--status-warning-background)] p-2 typography-meta"
style={{ color: 'var(--surface-foreground)' }}
>
{warningText}
</div>
)}
<div className="flex justify-end">
<Button
type="button"
variant="outline"
onClick={() => {
resetAllShortcutOverrides();
setDraftByAction({});
setPendingOverwrite(null);
setErrorText('');
setWarningText('');
}}
>
Reset all shortcuts
</Button>
</div>
</div> </div>
); );
}; };
@@ -1,8 +1,8 @@
import React from 'react'; import React from 'react';
import { RiInformationLine } from '@remixicon/react'; import { RiInformationLine, RiRestartLine } from '@remixicon/react';
import { NumberInput } from '@/components/ui/number-input'; import { NumberInput } from '@/components/ui/number-input';
import { ButtonSmall } from '@/components/ui/button-small';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useDeviceInfo } from '@/lib/device';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { updateDesktopSettings } from '@/lib/persistence'; import { updateDesktopSettings } from '@/lib/persistence';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
@@ -12,8 +12,6 @@ const MIN_LIMIT = 10;
const MAX_LIMIT = 500; const MAX_LIMIT = 500;
export const MemoryLimitsSettings: React.FC = () => { export const MemoryLimitsSettings: React.FC = () => {
const { isMobile } = useDeviceInfo();
const messageLimit = useUIStore((state) => state.messageLimit); const messageLimit = useUIStore((state) => state.messageLimit);
const setMessageLimit = useUIStore((state) => state.setMessageLimit); const setMessageLimit = useUIStore((state) => state.setMessageLimit);
@@ -80,36 +78,28 @@ export const MemoryLimitsSettings: React.FC = () => {
const isDefault = messageLimit === DEFAULT_MESSAGE_LIMIT; const isDefault = messageLimit === DEFAULT_MESSAGE_LIMIT;
return ( return (
<div className="space-y-4"> <div className="mb-8">
<div className="space-y-1"> <div className="mb-1 px-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h3 className="typography-ui-header font-semibold text-foreground">Message Memory</h3> <h3 className="typography-ui-header font-medium text-foreground">Message Memory</h3>
<Tooltip delayDuration={1000}> <Tooltip delayDuration={1000}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" /> <RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs"> <TooltipContent sideOffset={8} className="max-w-xs">
How many messages to keep in view per session.<br /> Limit how many messages are loaded per session in memory.<br />
Older messages are available via "Load more". Background sessions are trimmed automatically. Older messages are available via "Load more". Background sessions are trimmed automatically.
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
</div> </div>
<div className="space-y-3"> <section className="px-2 pb-2 pt-0">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
<div className="flex items-center justify-between gap-4"> <div className="flex min-w-0 flex-col sm:w-56 shrink-0">
<div className="flex flex-col"> <span className="typography-ui-label text-foreground">Message Limit</span>
<span className="typography-ui-label text-foreground">Message limit</span>
<span className="typography-meta text-muted-foreground">Messages loaded per session</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 sm:w-fit">
{!isDefault && (
<span className="typography-meta text-muted-foreground/60">(default: {DEFAULT_MESSAGE_LIMIT})</span>
)}
{isMobile ? (
<MobileInput value={messageLimit} min={MIN_LIMIT} max={MAX_LIMIT} onChange={handleChange} />
) : (
<NumberInput <NumberInput
value={messageLimit} value={messageLimit}
onValueChange={handleChange} onValueChange={handleChange}
@@ -117,61 +107,22 @@ export const MemoryLimitsSettings: React.FC = () => {
max={MAX_LIMIT} max={MAX_LIMIT}
step={10} step={10}
aria-label="Message limit" aria-label="Message limit"
className="w-20 tabular-nums"
/> />
)} <ButtonSmall
</div> type="button"
</div> variant="ghost"
onClick={() => handleChange(DEFAULT_MESSAGE_LIMIT)}
disabled={isDefault}
className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
aria-label="Reset message limit"
title="Reset"
>
<RiRestartLine className="h-3.5 w-3.5" />
</ButtonSmall>
</div> </div>
</div> </div>
</section>
</div> </div>
); );
}; };
const MobileInput: React.FC<{ value: number; min: number; max: number; onChange: (v: number) => void }> = ({
value,
min,
max,
onChange,
}) => {
const [draft, setDraft] = React.useState(String(value));
React.useEffect(() => {
setDraft(String(value));
}, [value]);
const handleChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const nextValue = e.target.value;
setDraft(nextValue);
if (nextValue.trim() === '') return;
const parsed = Number(nextValue);
if (!Number.isFinite(parsed)) return;
onChange(Math.min(max, Math.max(min, Math.round(parsed))));
}, [min, max, onChange]);
const handleBlur = React.useCallback(() => {
if (draft.trim() === '') {
setDraft(String(value));
return;
}
const parsed = Number(draft);
if (!Number.isFinite(parsed)) {
setDraft(String(value));
return;
}
const clamped = Math.min(max, Math.max(min, Math.round(parsed)));
onChange(clamped);
setDraft(String(clamped));
}, [draft, value, min, max, onChange]);
return (
<input
type="number"
inputMode="numeric"
value={draft}
onChange={handleChange}
onBlur={handleBlur}
aria-label="Message limit"
className="h-8 w-20 rounded-lg border border-border bg-background px-2 text-center typography-ui-label text-foreground focus:border-ring focus:outline-none focus:ring-2 focus:ring-ring/50"
/>
);
};
@@ -1,11 +1,16 @@
import React from 'react'; import React from 'react';
import { RiRestartLine } from '@remixicon/react';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { isDesktopShell, isVSCodeRuntime } from '@/lib/desktop'; import { isDesktopShell, isVSCodeRuntime } from '@/lib/desktop';
import { Switch } from '@/components/ui/switch'; import { useDeviceInfo } from '@/lib/device';
import { Checkbox } from '@/components/ui/checkbox';
import { toast } from '@/components/ui'; import { toast } from '@/components/ui';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { GridLoader } from '@/components/ui/grid-loader'; import { GridLoader } from '@/components/ui/grid-loader';
import { Input } from '@/components/ui/input';
import { NumberInput } from '@/components/ui/number-input';
import { ButtonSmall } from '@/components/ui/button-small';
import { cn } from '@/lib/utils';
const DEFAULT_NOTIFICATION_TEMPLATES = { const DEFAULT_NOTIFICATION_TEMPLATES = {
completion: { title: '{agent_name} is ready', message: '{model_name} completed the task' }, completion: { title: '{agent_name} is ready', message: '{model_name} completed the task' },
@@ -14,7 +19,12 @@ const DEFAULT_NOTIFICATION_TEMPLATES = {
subtask: { title: '{agent_name} is ready', message: '{model_name} completed the task' }, subtask: { title: '{agent_name} is ready', message: '{model_name} completed the task' },
} as const; } as const;
const DEFAULT_SUMMARY_THRESHOLD = 200;
const DEFAULT_SUMMARY_LENGTH = 100;
const DEFAULT_MAX_LAST_MESSAGE_LENGTH = 250;
export const NotificationSettings: React.FC = () => { export const NotificationSettings: React.FC = () => {
const { isMobile } = useDeviceInfo();
const isDesktop = React.useMemo(() => isDesktopShell(), []); const isDesktop = React.useMemo(() => isDesktopShell(), []);
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []); const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
const isBrowser = !isDesktop && !isVSCode; const isBrowser = !isDesktop && !isVSCode;
@@ -205,8 +215,6 @@ export const NotificationSettings: React.FC = () => {
throw new Error('navigator.serviceWorker.register unavailable'); throw new Error('navigator.serviceWorker.register unavailable');
} }
// iOS Safari can throw non-sensical internal errors when unsupported options
// are passed. Try no-options first, then add options progressively.
const attempts: Array<{ label: string; opts: RegistrationOptions | null }> = [ const attempts: Array<{ label: string; opts: RegistrationOptions | null }> = [
{ label: 'no-options', opts: null }, { label: 'no-options', opts: null },
{ label: 'scope-root', opts: { scope: '/' } }, { label: 'scope-root', opts: { scope: '/' } },
@@ -225,7 +233,6 @@ export const NotificationSettings: React.FC = () => {
return await withTimeout(promise, 10000, `Service worker registration timed out (${attempt.label})`); return await withTimeout(promise, 10000, `Service worker registration timed out (${attempt.label})`);
} catch (error) { } catch (error) {
lastError = error; lastError = error;
// ignore
} }
} }
@@ -254,7 +261,6 @@ export const NotificationSettings: React.FC = () => {
return registered; return registered;
}; };
const formatUnknownError = (error: unknown) => { const formatUnknownError = (error: unknown) => {
const anyError = error as { name?: unknown; message?: unknown; stack?: unknown } | null; const anyError = error as { name?: unknown; message?: unknown; stack?: unknown } | null;
const parts = [ const parts = [
@@ -325,25 +331,21 @@ export const NotificationSettings: React.FC = () => {
throw new Error('PushManager unavailable (requires installed PWA + iOS 16.4+)'); throw new Error('PushManager unavailable (requires installed PWA + iOS 16.4+)');
} }
const subscription = existing ?? await withTimeout( const subscription = existing ?? await withTimeout(
registration.pushManager.subscribe({ registration.pushManager.subscribe({
userVisibleOnly: true, userVisibleOnly: true,
// iOS Safari is picky here; pass Uint8Array (not ArrayBuffer).
applicationServerKey: base64UrlToUint8Array(key.publicKey), applicationServerKey: base64UrlToUint8Array(key.publicKey),
}), }),
15000, 15000,
'Push subscription timed out' 'Push subscription timed out'
); );
const json = subscription.toJSON(); const json = subscription.toJSON();
const keys = json.keys; const keys = json.keys;
if (!json.endpoint || !keys?.p256dh || !keys.auth) { if (!json.endpoint || !keys?.p256dh || !keys.auth) {
throw new Error('Push subscription missing keys'); throw new Error('Push subscription missing keys');
} }
const ok = await withTimeout( const ok = await withTimeout(
apis.push.subscribe({ apis.push.subscribe({
endpoint: json.endpoint, endpoint: json.endpoint,
@@ -357,7 +359,6 @@ export const NotificationSettings: React.FC = () => {
'Push subscribe request timed out' 'Push subscribe request timed out'
); );
if (!ok?.ok) { if (!ok?.ok) {
toast.error('Failed to enable background notifications'); toast.error('Failed to enable background notifications');
return; return;
@@ -371,7 +372,6 @@ export const NotificationSettings: React.FC = () => {
toast.error('Failed to enable background notifications', { toast.error('Failed to enable background notifications', {
description: formatted.summary, description: formatted.summary,
}); });
} finally { } finally {
setPushBusy(false); setPushBusy(false);
} }
@@ -409,338 +409,377 @@ export const NotificationSettings: React.FC = () => {
}; };
return ( return (
<div className="space-y-6"> <div className="space-y-8">
<div className="space-y-1 pt-2">
<h3 className="typography-ui-header font-semibold text-foreground"> {/* --- Global Delivery Settings --- */}
When to notify <div className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">
Notification Delivery
</h3> </h3>
<p className="typography-ui text-muted-foreground">
Customize when notifications show up.
</p>
</div> </div>
<div className="flex items-center justify-between"> <section className="px-2 pb-2 pt-0 space-y-0.5">
<div className="space-y-0.5"> <div
<span className="typography-ui text-foreground"> className="group flex cursor-pointer items-center gap-2 py-1.5"
Enable notifications role="button"
</span> tabIndex={0}
<p className="typography-micro text-muted-foreground"> aria-pressed={nativeNotificationsEnabled && canShowNotifications}
Turns notifications on or off. onClick={() => {
</p> void handleToggleChange(!(nativeNotificationsEnabled && canShowNotifications));
</div> }}
<Switch onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
void handleToggleChange(!(nativeNotificationsEnabled && canShowNotifications));
}
}}
>
<Checkbox
checked={nativeNotificationsEnabled && canShowNotifications} checked={nativeNotificationsEnabled && canShowNotifications}
onCheckedChange={handleToggleChange} onChange={(checked) => {
className="data-[state=checked]:bg-status-info" void handleToggleChange(checked);
}}
ariaLabel="Enable notifications"
/> />
<span className="typography-ui-label text-foreground">Enable Notifications</span>
</div> </div>
{nativeNotificationsEnabled && canShowNotifications && (
<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="Notify while app is focused"
/>
<span className="typography-ui-label text-foreground">Notify While App is Focused</span>
</div>
)}
</section>
{isBrowser && ( {isBrowser && (
<p className="typography-micro text-muted-foreground"> <div className="mt-1 px-2">
<p className="typography-meta text-muted-foreground/70">
Your browser may ask for permission the first time. Your browser may ask for permission the first time.
</p> </p>
{notificationPermission === 'denied' && (
<p className="typography-meta text-[var(--status-error)] mt-1">
Notification permission denied. Enable it in your browser settings.
</p>
)} )}
{notificationPermission === 'granted' && !nativeNotificationsEnabled && (
{nativeNotificationsEnabled && canShowNotifications && ( <p className="typography-meta text-muted-foreground/70 mt-1">
<div className="flex items-center justify-between"> Permission granted, but notifications are disabled.
<div className="space-y-0.5"> </p>
<span className="typography-ui text-foreground"> )}
Notify while app is focused </div>
</span> )}
<p className="typography-micro text-muted-foreground"> {isVSCode && (
When off, only notify when you are not looking at OpenChamber. <div className="mt-1 px-2">
<p className="typography-meta text-muted-foreground/70">
VS Code runtime handles notifications separately natively.
</p> </p>
</div> </div>
<Switch
checked={notificationMode === 'always'}
onCheckedChange={(checked: boolean) => setNotificationMode(checked ? 'always' : 'hidden-only')}
className="data-[state=checked]:bg-status-info"
/>
</div>
)} )}
</div>
{nativeNotificationsEnabled && canShowNotifications && ( {nativeNotificationsEnabled && canShowNotifications && (
<div className="space-y-3 pt-2"> <>
<div className="space-y-0.5"> {/* --- Events --- */}
<span className="typography-ui text-foreground font-medium"> <div className="mb-8">
Events <div className="mb-1 px-1">
</span> <h3 className="typography-ui-header font-medium text-foreground">
<p className="typography-micro text-muted-foreground"> Notification Events
Choose which events trigger notifications.
</p>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<span className="typography-ui text-foreground">Completion</span>
<p className="typography-micro text-muted-foreground">Agent finished its task.</p>
</div>
<Switch
checked={notifyOnCompletion}
onCheckedChange={setNotifyOnCompletion}
className="data-[state=checked]:bg-status-info"
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<span className="typography-ui text-foreground">Errors</span>
<p className="typography-micro text-muted-foreground">A tool call failed.</p>
</div>
<Switch
checked={notifyOnError}
onCheckedChange={setNotifyOnError}
className="data-[state=checked]:bg-status-info"
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<span className="typography-ui text-foreground">Questions</span>
<p className="typography-micro text-muted-foreground">Agent is asking for input or permission.</p>
</div>
<Switch
checked={notifyOnQuestion}
onCheckedChange={setNotifyOnQuestion}
className="data-[state=checked]:bg-status-info"
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<span className="typography-ui text-foreground">Subagents</span>
<p className="typography-micro text-muted-foreground">Also notify for child sessions started by the main one.</p>
</div>
<Switch
checked={notifyOnSubtasks}
onCheckedChange={(checked: boolean) => setNotifyOnSubtasks(checked)}
className="data-[state=checked]:bg-status-info"
/>
</div>
</div>
)}
{nativeNotificationsEnabled && canShowNotifications && (
<div className="space-y-4 pt-4">
<div className="space-y-1">
<h3 className="typography-ui-header font-semibold text-foreground">
Customize content
</h3> </h3>
<p className="typography-micro text-muted-foreground"> </div>
Use template variables: <code className="text-accent-foreground">{'{project_name}'}</code>{' '}
<code className="text-accent-foreground">{'{worktree}'}</code>{' '} <section className="px-2 pb-2 pt-0 space-y-0.5">
<code className="text-accent-foreground">{'{branch}'}</code>{' '} <div
<code className="text-accent-foreground">{'{session_name}'}</code>{' '} className="group flex cursor-pointer items-center gap-2 py-1.5"
<code className="text-accent-foreground">{'{agent_name}'}</code>{' '} role="button"
<code className="text-accent-foreground">{'{last_message}'}</code> 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="Agent completion" />
<span className="typography-ui-label text-foreground">Agent Completion</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="Subagent completion" />
<span className="typography-ui-label text-foreground">Subagent Completion</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="Agent errors" />
<span className="typography-ui-label text-foreground">Agent Errors</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="Agent questions" />
<span className="typography-ui-label text-foreground">Agent Questions</span>
</div>
</section>
</div>
{/* --- Template Customization --- */}
<div className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">
Notification Templates
</h3>
<p className="typography-meta text-muted-foreground mt-0.5">
Variables: <code className="text-[var(--primary-base)]">{'{project_name}'}</code> <code className="text-[var(--primary-base)]">{'{worktree}'}</code> <code className="text-[var(--primary-base)]">{'{branch}'}</code> <code className="text-[var(--primary-base)]">{'{session_name}'}</code> <code className="text-[var(--primary-base)]">{'{agent_name}'}</code> <code className="text-[var(--primary-base)]">{'{model_name}'}</code> <code className="text-[var(--primary-base)]">{'{last_message}'}</code>
</p> </p>
</div> </div>
{(['completion', 'error', 'question', 'subtask'] as const).map((event) => ( <div className="grid grid-cols-1 gap-2 md:grid-cols-2 md:gap-3">
<div key={event} className="space-y-2"> {(['completion', 'subtask', 'error', 'question'] as const).map((event) => (
<span className="typography-ui text-foreground font-medium capitalize">{event}</span> <section key={event} className="p-2">
<div className="space-y-1.5"> <span className="typography-ui-label text-foreground font-normal capitalize block">
{event === 'subtask' ? 'Subagent Completion' : event}
</span>
<div className="mt-1.5 space-y-2">
<div> <div>
<label className="typography-micro text-muted-foreground block mb-1">Title</label> <label className="typography-micro text-muted-foreground block mb-1">Title</label>
<input <Input
type="text"
value={notificationTemplates[event].title} value={notificationTemplates[event].title}
onChange={(e) => updateTemplate(event, 'title', e.target.value)} onChange={(e) => updateTemplate(event, 'title', e.target.value)}
className="w-full rounded-md border border-border bg-background px-3 py-1.5 typography-ui text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-accent" className="h-7"
placeholder={DEFAULT_NOTIFICATION_TEMPLATES[event].title} placeholder={DEFAULT_NOTIFICATION_TEMPLATES[event].title}
/> />
</div> </div>
<div> <div>
<label className="typography-micro text-muted-foreground block mb-1">Message</label> <label className="typography-micro text-muted-foreground block mb-1">Message</label>
<input <Input
type="text"
value={notificationTemplates[event].message} value={notificationTemplates[event].message}
onChange={(e) => updateTemplate(event, 'message', e.target.value)} onChange={(e) => updateTemplate(event, 'message', e.target.value)}
className="w-full rounded-md border border-border bg-background px-3 py-1.5 typography-ui text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-accent" className="h-7"
placeholder={DEFAULT_NOTIFICATION_TEMPLATES[event].message} placeholder={DEFAULT_NOTIFICATION_TEMPLATES[event].message}
/> />
</div> </div>
</div> </div>
</div> </section>
))} ))}
</div> </div>
)} </div>
{nativeNotificationsEnabled && canShowNotifications && ( {/* --- Summarization --- */}
<div className="space-y-3 pt-4"> <div className="mb-8">
<div className="space-y-1"> <div className="mb-1 px-1">
<h3 className="typography-ui-header font-semibold text-foreground"> <h3 className="typography-ui-header font-medium text-foreground">
Summarization AI Summarization
</h3> </h3>
<p className="typography-micro text-muted-foreground">
Summarize long messages in notifications using AI.
</p>
</div> </div>
<div className="flex items-center justify-between"> <section className="px-2 pb-2 pt-0 space-y-0.5">
<div className="space-y-0.5"> <div
<span className="typography-ui text-foreground"> className="group flex cursor-pointer items-center gap-2 py-1.5"
Summarize last message role="button"
</span> tabIndex={0}
<p className="typography-micro text-muted-foreground"> aria-pressed={summarizeLastMessage}
Uses AI to shorten the {'{last_message}'} variable. onClick={() => setSummarizeLastMessage(!summarizeLastMessage)}
</p> onKeyDown={(event) => {
</div> if (event.key === ' ' || event.key === 'Enter') {
<Switch event.preventDefault();
setSummarizeLastMessage(!summarizeLastMessage);
}
}}
>
<Checkbox
checked={summarizeLastMessage} checked={summarizeLastMessage}
onCheckedChange={setSummarizeLastMessage} onChange={setSummarizeLastMessage}
className="data-[state=checked]:bg-status-info" ariaLabel="Summarize last message"
/> />
<span className="typography-ui-label text-foreground">Summarize Last Message</span>
</div> </div>
{summarizeLastMessage ? ( {summarizeLastMessage ? (
<> <>
<div className="space-y-1.5"> <div className="flex items-center gap-8 py-1.5 mt-1 border-t border-[var(--surface-subtle)]">
<div className="flex items-center justify-between"> <div className="flex min-w-0 flex-col w-56 shrink-0">
<label className="typography-ui text-foreground"> <span className="typography-ui-label text-foreground">Threshold</span>
Summary threshold <span className="typography-meta text-muted-foreground">Messages longer than this will be summarized</span>
</label>
<span className="typography-micro text-muted-foreground tabular-nums">{summaryThreshold} chars</span>
</div> </div>
<p className="typography-micro text-muted-foreground"> <div className="flex items-center gap-2 w-fit">
Messages longer than this will be summarized. <NumberInput
</p> value={summaryThreshold}
<input onValueChange={setSummaryThreshold}
type="range"
min={50} min={50}
max={2000} max={2000}
step={50} step={50}
value={summaryThreshold} className="w-20 tabular-nums"
onChange={(e) => setSummaryThreshold(Number(e.target.value))}
className="w-full h-2 bg-muted rounded-lg 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-primary [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0"
/> />
<ButtonSmall
type="button"
variant="ghost"
onClick={() => setSummaryThreshold(DEFAULT_SUMMARY_THRESHOLD)}
disabled={summaryThreshold === DEFAULT_SUMMARY_THRESHOLD}
className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
aria-label="Reset threshold"
title="Reset"
>
<RiRestartLine className="h-3.5 w-3.5" />
</ButtonSmall>
</div> </div>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<label className="typography-ui text-foreground">
Summary length
</label>
<span className="typography-micro text-muted-foreground tabular-nums">{summaryLength} chars</span>
</div> </div>
<p className="typography-micro text-muted-foreground"> <div className="flex items-center gap-8 py-1.5">
Target length of the summary. <div className="flex min-w-0 flex-col w-56 shrink-0">
</p> <span className="typography-ui-label text-foreground">Length</span>
<input <span className="typography-meta text-muted-foreground">Target character length of the summary</span>
type="range" </div>
<div className="flex items-center gap-2 w-fit">
<NumberInput
value={summaryLength}
onValueChange={setSummaryLength}
min={20} min={20}
max={500} max={500}
step={10} step={10}
value={summaryLength} className="w-20 tabular-nums"
onChange={(e) => setSummaryLength(Number(e.target.value))}
className="w-full h-2 bg-muted rounded-lg 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-primary [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0"
/> />
<ButtonSmall
type="button"
variant="ghost"
onClick={() => setSummaryLength(DEFAULT_SUMMARY_LENGTH)}
disabled={summaryLength === DEFAULT_SUMMARY_LENGTH}
className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
aria-label="Reset summary length"
title="Reset"
>
<RiRestartLine className="h-3.5 w-3.5" />
</ButtonSmall>
</div>
</div> </div>
</> </>
) : ( ) : (
<div className="space-y-1.5"> <div className={cn("py-1.5 mt-1 border-t border-[var(--surface-subtle)]", isMobile ? "flex flex-col gap-3" : "flex items-center gap-8")}>
<div className="flex items-center justify-between"> <div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "w-56 shrink-0")}>
<label className="typography-ui text-foreground"> <span className="typography-ui-label text-foreground">Max Length</span>
Max last message length <span className="typography-meta text-muted-foreground">Truncate {'{last_message}'} to this length</span>
</label>
<span className="typography-micro text-muted-foreground tabular-nums">{maxLastMessageLength} chars</span>
</div> </div>
<p className="typography-micro text-muted-foreground"> <div className={cn("flex items-center gap-2", isMobile ? "w-full" : "w-fit")}>
Truncate {'{last_message}'} to this many characters. <NumberInput
</p> value={maxLastMessageLength}
<input onValueChange={setMaxLastMessageLength}
type="range"
min={50} min={50}
max={1000} max={1000}
step={10} step={10}
value={maxLastMessageLength} className="w-20 tabular-nums"
onChange={(e) => setMaxLastMessageLength(Number(e.target.value))}
className="w-full h-2 bg-muted rounded-lg 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-primary [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0"
/> />
<ButtonSmall
type="button"
variant="ghost"
onClick={() => setMaxLastMessageLength(DEFAULT_MAX_LAST_MESSAGE_LENGTH)}
disabled={maxLastMessageLength === DEFAULT_MAX_LAST_MESSAGE_LENGTH}
className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
aria-label="Reset max message length"
title="Reset"
>
<RiRestartLine className="h-3.5 w-3.5" />
</ButtonSmall>
</div>
</div> </div>
)} )}
</section>
</div> </div>
</>
)} )}
{/* --- Background Push Notifications --- */}
{isBrowser && ( {isBrowser && (
<> <div className="mb-8">
{notificationPermission === 'denied' && ( <div className="mb-1 px-1">
<p className="typography-micro text-destructive"> <h3 className="typography-ui-header font-medium text-foreground">
Notification permission denied. Enable it in your browser settings. Background Push Notifications
</p>
)}
{notificationPermission === 'granted' && !nativeNotificationsEnabled && (
<p className="typography-micro text-muted-foreground">
Permission granted, but notifications are disabled.
</p>
)}
<div className="space-y-1 pt-4">
<h3 className="typography-ui-header font-semibold text-foreground">
Background (Push)
</h3> </h3>
<p className="typography-ui text-muted-foreground">
Get notified even if this page is closed.
</p>
</div> </div>
{!pushSupported ? ( <section className="px-2 pb-2 pt-0">
<p className="typography-micro text-muted-foreground"> <div className="flex items-start gap-2 py-1.5">
Push not supported in this browser. <Checkbox
</p> checked={pushSupported ? pushSubscribed : false}
) : ( disabled={!pushSupported || pushBusy}
<p className="typography-micro text-muted-foreground"> onChange={(checked: boolean) => {
Desktop Chrome/Edge and Android support push. iOS requires an installed PWA.
</p>
)}
{pushSupported && (
<div className="flex items-center justify-between gap-3">
<div className="space-y-0.5">
<span className="typography-ui text-foreground">
Enable push notifications
</span>
<p className="typography-micro text-muted-foreground">
Clicking a notification opens the relevant session.
</p>
</div>
<div className="flex items-center gap-2">
{pushBusy && (
<div className="text-muted-foreground">
<GridLoader size="sm" />
</div>
)}
<Switch
checked={pushSubscribed}
disabled={pushBusy}
onCheckedChange={(checked: boolean) => {
if (checked) { if (checked) {
void handleEnableBackgroundNotifications(); void handleEnableBackgroundNotifications();
} else { } else {
void handleDisableBackgroundNotifications(); void handleDisableBackgroundNotifications();
} }
}} }}
className="data-[state=checked]:bg-status-info" ariaLabel="Enable push notifications"
/> />
<div className="flex min-w-0 flex-col">
<span className={cn("typography-ui-label", !pushSupported ? "text-muted-foreground" : "text-foreground")}>Enable push notifications</span>
<span className="typography-meta text-muted-foreground">
{!pushSupported
? "Push not supported. Desktop Chrome/Edge and Android support push. iOS requires an installed PWA."
: "Receive alerts via your operating system background service"}
</span>
</div> </div>
{pushBusy && (
<div className="pt-0.5 text-muted-foreground">
<GridLoader size="sm" />
</div> </div>
)} )}
</> </div>
</section>
</div>
)} )}
{isVSCode && (
<div className="space-y-1 pt-4">
<h3 className="typography-ui-header font-semibold text-foreground">
Delivery
</h3>
<p className="typography-ui text-muted-foreground">
VS Code runtime handles notifications separately.
</p>
</div>
)}
</div> </div>
); );
}; };
@@ -5,7 +5,6 @@ import { SessionRetentionSettings } from './SessionRetentionSettings';
import { MemoryLimitsSettings } from './MemoryLimitsSettings'; import { MemoryLimitsSettings } from './MemoryLimitsSettings';
import { DefaultsSettings } from './DefaultsSettings'; import { DefaultsSettings } from './DefaultsSettings';
import { GitSettings } from './GitSettings'; import { GitSettings } from './GitSettings';
import { WorktreeSectionContent } from './WorktreeSectionContent';
import { NotificationSettings } from './NotificationSettings'; import { NotificationSettings } from './NotificationSettings';
import { GitHubSettings } from './GitHubSettings'; import { GitHubSettings } from './GitHubSettings';
import { VoiceSettings } from './VoiceSettings'; import { VoiceSettings } from './VoiceSettings';
@@ -14,7 +13,7 @@ import { KeyboardShortcutsSettings } from './KeyboardShortcutsSettings';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { useDeviceInfo } from '@/lib/device'; import { useDeviceInfo } from '@/lib/device';
import { isVSCodeRuntime, isWebRuntime } from '@/lib/desktop'; import { isVSCodeRuntime, isWebRuntime } from '@/lib/desktop';
import type { OpenChamberSection } from './OpenChamberSidebar'; import type { OpenChamberSection } from './types';
interface OpenChamberPageProps { interface OpenChamberPageProps {
/** Which section to display. If undefined, shows all sections (mobile/legacy behavior) */ /** Which section to display. If undefined, shows all sections (mobile/legacy behavior) */
@@ -34,7 +33,7 @@ export const OpenChamberPage: React.FC<OpenChamberPageProps> = ({ section }) =>
outerClassName="h-full" outerClassName="h-full"
className="w-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"> <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 /> <OpenChamberVisualSettings />
<div className="border-t border-border/40 pt-6"> <div className="border-t border-border/40 pt-6">
<DefaultsSettings /> <DefaultsSettings />
@@ -87,7 +86,7 @@ export const OpenChamberPage: React.FC<OpenChamberPageProps> = ({ section }) =>
outerClassName="h-full" outerClassName="h-full"
className="w-full" className="w-full"
> >
<div className="openchamber-page-body mx-auto max-w-3xl space-y-6 p-3 sm:p-6"> <div className="openchamber-page-body mx-auto max-w-3xl space-y-6 p-3 sm:p-6 sm:pt-8">
{renderSectionContent()} {renderSectionContent()}
</div> </div>
</ScrollableOverlay> </ScrollableOverlay>
@@ -134,15 +133,6 @@ const GitSectionContent: React.FC = () => {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<GitSettings /> <GitSettings />
<div className="border-t border-border/40 pt-6">
<div className="space-y-1 mb-4">
<h3 className="typography-ui-header font-semibold text-foreground">Worktree</h3>
<p className="typography-meta text-muted-foreground">
Configure worktree branch defaults and manage existing worktrees.
</p>
</div>
<WorktreeSectionContent />
</div>
</div> </div>
); );
}; };
@@ -1,213 +0,0 @@
import React from 'react';
import { RiRestartLine } from '@remixicon/react';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useDeviceInfo } from '@/lib/device';
import { isVSCodeRuntime, isWebRuntime } from '@/lib/desktop';
import { AboutSettings } from './AboutSettings';
import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
import { cn } from '@/lib/utils';
export type OpenChamberSection = 'visual' | 'chat' | 'shortcuts' | 'sessions' | 'git' | 'github' | 'notifications' | 'voice';
interface OpenChamberSidebarProps {
selectedSection: OpenChamberSection;
onSelectSection: (section: OpenChamberSection) => void;
}
interface SectionGroup {
id: OpenChamberSection;
label: string;
items: string[];
badge?: string;
webOnly?: boolean;
hideInVSCode?: boolean;
}
const OPENCHAMBER_SECTION_GROUPS: SectionGroup[] = [
{
id: 'visual',
label: 'Visual',
items: ['Theme', 'Font', 'Spacing'],
},
{
id: 'chat',
label: 'Chat',
items: ['Tools', 'Diff', 'Reasoning'],
},
{
id: 'shortcuts',
label: 'Shortcuts',
items: ['Keyboard', 'Overrides'],
},
{
id: 'sessions',
label: 'Sessions',
items: ['Defaults', 'Zen Model', 'Retention'],
},
{
id: 'git',
label: 'Git',
items: ['Commit Messages', 'Worktree'],
hideInVSCode: true,
},
{
id: 'github',
label: 'GitHub',
items: ['Connect', 'PRs', 'Issues'],
hideInVSCode: true,
},
{
id: 'notifications',
label: 'Notifications',
items: ['Native'],
},
{
id: 'voice',
label: 'Voice',
items: ['Language', 'Continuous Mode'],
badge: 'experimental',
hideInVSCode: true,
},
];
export const OpenChamberSidebar: React.FC<OpenChamberSidebarProps> = ({
selectedSection,
onSelectSection,
}) => {
const { isMobile } = useDeviceInfo();
const showAbout = isMobile && isWebRuntime();
const [isReloadingConfig, setIsReloadingConfig] = React.useState(false);
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
const isWeb = React.useMemo(() => isWebRuntime(), []);
const showReload = !isVSCode;
const handleReloadConfiguration = React.useCallback(async () => {
setIsReloadingConfig(true);
try {
await reloadOpenCodeConfiguration({ message: 'Restarting OpenCode…', mode: 'projects', scopes: ['all'] });
} finally {
setIsReloadingConfig(false);
}
}, []);
const visibleSections = React.useMemo(() => {
return OPENCHAMBER_SECTION_GROUPS.filter((group) => {
if (group.webOnly && !isWeb) return false;
if (group.hideInVSCode && isVSCode) return false;
return true;
});
}, [isWeb, isVSCode]);
// Desktop app: transparent for blur effect
// VS Code: bg-background (same as page content)
// Web/mobile: bg-sidebar
const bgClass = isVSCode ? 'bg-background' : 'bg-sidebar';
return (
<div className={cn('grid h-full min-h-0 grid-rows-[minmax(0,1fr)_auto]', bgClass)}>
<div className="min-h-0">
<ScrollableOverlay outerClassName="h-full" className="space-y-1 px-3 py-2 overflow-x-hidden">
{visibleSections.map((group) => {
const isSelected = selectedSection === group.id;
return (
<div
key={group.id}
className={cn(
'group relative rounded-md px-1.5 py-1 transition-all duration-200',
isSelected ? 'bg-interactive-selection' : 'hover:bg-interactive-hover'
)}
>
<button
onClick={() => onSelectSection(group.id)}
className="w-full text-left flex flex-col gap-0 rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
>
<div className="flex items-center gap-2">
<span className="typography-ui-label font-normal text-foreground">
{group.label}
</span>
{group.badge && (
<span className="text-[10px] leading-none uppercase font-bold tracking-tight bg-[var(--status-warning-background)] text-[var(--status-warning)] border border-[var(--status-warning-border)] px-1.5 py-0.5 rounded">
{group.badge}
</span>
)}
</div>
<div className="typography-micro text-muted-foreground/60 leading-tight">
{group.items.join(' · ')}
</div>
</button>
</div>
);
})}
</ScrollableOverlay>
</div>
{(showReload || showAbout) && (
<div className={cn(
'border-t border-border bg-sidebar',
showAbout ? 'px-3 py-3 space-y-3' : 'flex-shrink-0 h-12 px-2'
)}>
{showAbout ? (
<>
{showReload && (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
'flex h-8 w-full items-center gap-2 rounded-md px-2',
'text-sm font-semibold text-sidebar-foreground/90',
'hover:text-sidebar-foreground hover:bg-interactive-hover',
'transition-all duration-200',
'disabled:pointer-events-none disabled:opacity-50'
)}
onClick={() => void handleReloadConfiguration()}
disabled={isReloadingConfig}
>
<RiRestartLine className="h-4 w-4" />
{isReloadingConfig ? 'Reloading OpenCode…' : 'Reload OpenCode'}
</button>
</TooltipTrigger>
<TooltipContent>
Restart OpenCode and reload its configuration (agents, commands, skills, providers).
</TooltipContent>
</Tooltip>
)}
<AboutSettings />
</>
) : (
<div className="flex h-full items-center justify-between gap-2">
{showReload && (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
'flex h-8 items-center gap-2 rounded-md px-2',
'text-sm font-semibold text-sidebar-foreground/90',
'hover:text-sidebar-foreground hover:bg-interactive-hover',
'transition-all duration-200',
'disabled:pointer-events-none disabled:opacity-50'
)}
onClick={() => void handleReloadConfiguration()}
disabled={isReloadingConfig}
>
<RiRestartLine className="h-4 w-4" />
{isReloadingConfig ? 'Reloading OpenCode…' : 'Reload OpenCode'}
</button>
</TooltipTrigger>
<TooltipContent>
Restart OpenCode and reload its configuration (agents, commands, skills, providers).
</TooltipContent>
</Tooltip>
)}
<div />
</div>
)}
</div>
)}
</div>
);
};
@@ -1,15 +1,16 @@
import React from 'react'; import React from 'react';
import { RiRestartLine } from '@remixicon/react'; import { RiRestartLine, RiInformationLine } from '@remixicon/react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useThemeSystem } from '@/contexts/useThemeSystem'; import { useThemeSystem } from '@/contexts/useThemeSystem';
import type { ThemeMode } from '@/types/theme'; import type { ThemeMode } from '@/types/theme';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { useMessageQueueStore } from '@/stores/messageQueueStore'; import { useMessageQueueStore } from '@/stores/messageQueueStore';
import { cn, getModifierLabel } from '@/lib/utils'; import { cn, getModifierLabel } from '@/lib/utils';
import { ButtonSmall } from '@/components/ui/button-small'; import { ButtonSmall } from '@/components/ui/button-small';
import { NumberInput } from '@/components/ui/number-input';
import { Switch } from '@/components/ui/switch';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { NumberInput } from '@/components/ui/number-input';
import { Radio } from '@/components/ui/radio';
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -55,17 +56,17 @@ const DIFF_LAYOUT_OPTIONS: Option<'dynamic' | 'inline' | 'side-by-side'>[] = [
{ {
id: 'dynamic', id: 'dynamic',
label: 'Dynamic', label: 'Dynamic',
description: 'New files inline, modified files side-by-side. Responsive inline fallback only in Dynamic mode.', description: 'New inline, modified side-by-side.',
}, },
{ {
id: 'inline', id: 'inline',
label: 'Always inline', label: 'Always inline',
description: 'Show all file diffs as a single unified view.', description: 'Show as a single unified view.',
}, },
{ {
id: 'side-by-side', id: 'side-by-side',
label: 'Always side-by-side', label: 'Always side-by-side',
description: 'Compare original and modified files next to each other.', description: 'Compare original and modified files.',
}, },
]; ];
@@ -73,12 +74,12 @@ const DIFF_VIEW_MODE_OPTIONS: Option<'single' | 'stacked'>[] = [
{ {
id: 'single', id: 'single',
label: 'Single file', label: 'Single file',
description: 'Show one file at a time in the Diff tab.', description: 'Show one file at a time.',
}, },
{ {
id: 'stacked', id: 'stacked',
label: 'All files', label: 'All files',
description: 'Stack all changed files together in the Diff tab.', description: 'Stack all changed files together.',
}, },
]; ];
@@ -166,37 +167,56 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
return visibleSettings.includes(setting); return visibleSettings.includes(setting);
}; };
const hasAppearanceSettings = shouldShow('theme') && !isVSCodeRuntime();
const hasLayoutSettings = shouldShow('fontSize') || shouldShow('terminalFontSize') || shouldShow('spacing') || shouldShow('cornerRadius') || shouldShow('inputBarOffset');
const hasBehaviorSettings = shouldShow('toolOutput')
|| shouldShow('diffLayout')
|| shouldShow('dotfiles')
|| shouldShow('reasoning')
|| shouldShow('queueMode')
|| shouldShow('textJustificationActivity')
|| shouldShow('persistDraft')
|| (shouldShow('terminalQuickKeys') && !isMobile);
return ( return (
<div className="w-full space-y-8"> <div className="space-y-8">
{shouldShow('theme') && !isVSCodeRuntime() && (
<div className="space-y-4">
<div className="space-y-1">
<h3 className="typography-ui-header font-semibold text-foreground">
Theme Mode
</h3>
</div>
<div className="flex gap-1 w-fit"> {/* --- Appearance & Themes --- */}
{hasAppearanceSettings && (
<div className="mb-8 space-y-3">
<section className="px-2 pb-2 pt-0 space-y-0.5">
<div className="pb-1.5">
<div className="flex min-w-0 flex-col gap-1.5">
<span className="typography-ui-header font-medium text-foreground">Color Mode</span>
<div className="flex flex-wrap items-center gap-1">
{THEME_MODE_OPTIONS.map((option) => ( {THEME_MODE_OPTIONS.map((option) => (
<ButtonSmall <ButtonSmall
key={option.value} key={option.value}
variant={themeMode === option.value ? 'default' : 'outline'} variant="outline"
className={cn(themeMode === option.value ? undefined : 'text-foreground')} size="xs"
className={cn(
'!font-normal',
themeMode === option.value
? 'border-[var(--primary-base)] text-[var(--primary-base)] bg-[var(--primary-base)]/10 hover:text-[var(--primary-base)]'
: 'text-foreground'
)}
onClick={() => setThemeMode(option.value)} onClick={() => setThemeMode(option.value)}
> >
{option.label} {option.label}
</ButtonSmall> </ButtonSmall>
))} ))}
</div> </div>
</div>
</div>
<div className="flex gap-10"> <div 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 flex-col gap-1.5"> <div className="flex min-w-0 items-center gap-2">
<h4 className="typography-ui-label font-medium text-foreground">Light Theme</h4> <span className="typography-ui-label text-foreground shrink-0">Light Theme</span>
<Select value={selectedLightTheme?.metadata.id ?? ''} onValueChange={setLightThemePreference}> <Select value={selectedLightTheme?.metadata.id ?? ''} onValueChange={setLightThemePreference}>
<SelectTrigger aria-label="Select light theme" className="min-w-32"> <SelectTrigger aria-label="Select light theme" className="w-fit">
<SelectValue placeholder="Select theme" /> <SelectValue placeholder="Select theme" />
</SelectTrigger> </SelectTrigger>
<SelectContent className="max-h-64 min-w-40"> <SelectContent>
{lightThemes.map((theme) => ( {lightThemes.map((theme) => (
<SelectItem key={theme.metadata.id} value={theme.metadata.id}> <SelectItem key={theme.metadata.id} value={theme.metadata.id}>
{formatThemeLabel(theme.metadata.name, 'light')} {formatThemeLabel(theme.metadata.name, 'light')}
@@ -205,14 +225,13 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="flex min-w-0 items-center gap-2">
<div className="flex flex-col gap-1.5"> <span className="typography-ui-label text-foreground shrink-0">Dark Theme</span>
<h4 className="typography-ui-label font-medium text-foreground">Dark Theme</h4>
<Select value={selectedDarkTheme?.metadata.id ?? ''} onValueChange={setDarkThemePreference}> <Select value={selectedDarkTheme?.metadata.id ?? ''} onValueChange={setDarkThemePreference}>
<SelectTrigger aria-label="Select dark theme" className="min-w-32"> <SelectTrigger aria-label="Select dark theme" className="w-fit">
<SelectValue placeholder="Select theme" /> <SelectValue placeholder="Select theme" />
</SelectTrigger> </SelectTrigger>
<SelectContent className="max-h-64 min-w-40"> <SelectContent>
{darkThemes.map((theme) => ( {darkThemes.map((theme) => (
<SelectItem key={theme.metadata.id} value={theme.metadata.id}> <SelectItem key={theme.metadata.id} value={theme.metadata.id}>
{formatThemeLabel(theme.metadata.name, 'dark')} {formatThemeLabel(theme.metadata.name, 'dark')}
@@ -222,10 +241,11 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
</Select> </Select>
</div> </div>
</div> </div>
<div className="flex items-center gap-2 py-1.5">
<div className="space-y-1.5"> <ButtonSmall
<button
type="button" type="button"
variant="outline"
size="xs"
disabled={customThemesLoading || themesReloading} disabled={customThemesLoading || themesReloading}
onClick={async () => { onClick={async () => {
setThemesReloading(true); setThemesReloading(true);
@@ -235,36 +255,41 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
setThemesReloading(false); setThemesReloading(false);
} }
}} }}
className="typography-ui-label text-muted-foreground hover:text-foreground hover:underline underline-offset-2 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5" className="!font-normal"
> >
<RiRestartLine className={cn('h-3.5 w-3.5', themesReloading && 'animate-spin')} /> <RiRestartLine className={cn('h-3.5 w-3.5', themesReloading && 'animate-spin')} />
Reload custom themes Reload themes
</ButtonSmall>
<Tooltip delayDuration={700}>
<TooltipTrigger asChild>
<button
type="button"
className="flex items-center justify-center rounded-md p-1 text-muted-foreground/70 hover:text-foreground"
aria-label="Theme import info"
>
<RiInformationLine className="h-3.5 w-3.5" />
</button> </button>
<p className="typography-meta text-muted-foreground/70"> </TooltipTrigger>
Import themes from ~/.config/openchamber/themes/ <TooltipContent sideOffset={8}>
</p> Import custom themes from ~/.config/openchamber/themes/
</TooltipContent>
</Tooltip>
</div> </div>
</section>
</div> </div>
)} )}
{shouldShow('fontSize') && !isMobile && ( {/* --- UI Scaling & Layout --- */}
<div className="space-y-4"> {hasLayoutSettings && (
<div className="space-y-1"> <div className="mb-8 space-y-3">
<h3 className="typography-ui-header font-semibold text-foreground"> <section className="p-2 space-y-0.5">
Font Size
</h3>
{shouldShow('fontSize') && !isMobile && (
<div className="flex items-center gap-8 py-1.5">
<div className="flex min-w-0 flex-col w-56 shrink-0">
<span className="typography-ui-label text-foreground">Interface Font Size</span>
</div> </div>
<div className="flex items-center gap-3 w-full max-w-md"> <div className="flex items-center gap-2 w-fit">
<input
type="range"
min="50"
max="200"
step="5"
value={fontSize}
onChange={(e) => setFontSize(Number(e.target.value))}
className="flex-1 min-w-0 h-2 bg-muted rounded-lg 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-primary [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0"
/>
<NumberInput <NumberInput
value={fontSize} value={fontSize}
onValueChange={setFontSize} onValueChange={setFontSize}
@@ -272,13 +297,14 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
max={200} max={200}
step={5} step={5}
aria-label="Font size percentage" aria-label="Font size percentage"
className="w-16"
/> />
<ButtonSmall <ButtonSmall
type="button" type="button"
variant="ghost" variant="ghost"
onClick={() => setFontSize(100)} onClick={() => setFontSize(100)}
disabled={fontSize === 100} disabled={fontSize === 100}
className="h-8 w-8 px-0 border border-border bg-background hover:bg-interactive-hover disabled:opacity-100 disabled:bg-background" className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
aria-label="Reset font size" aria-label="Reset font size"
title="Reset" title="Reset"
> >
@@ -289,492 +315,402 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
)} )}
{shouldShow('terminalFontSize') && ( {shouldShow('terminalFontSize') && (
<div className="space-y-4"> <div className={cn("py-1.5", isMobile ? "flex flex-col gap-3" : "flex items-center gap-8")}>
<div className="space-y-1"> <div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "w-56 shrink-0")}>
<h3 className="typography-ui-header font-semibold text-foreground"> <span className="typography-ui-label text-foreground">Terminal Font Size</span>
Terminal Font Size
</h3>
</div> </div>
{isMobile ? ( <div className={cn("flex items-center gap-2", isMobile ? "w-full" : "w-fit")}>
<div className="flex items-center gap-2 w-full">
<input
type="range"
min="9"
max="52"
step="1"
value={terminalFontSize}
onChange={(e) => setTerminalFontSize(Number(e.target.value))}
className="flex-1 min-w-0 h-3 bg-muted rounded-full appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-moz-range-thumb]:w-5 [&::-moz-range-thumb]:h-5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0"
aria-label="Terminal font size"
/>
<span className="typography-ui-label font-medium text-foreground tabular-nums rounded-md border border-border bg-background px-2 py-1.5 min-w-[3.75rem] text-center">
{terminalFontSize}px
</span>
<ButtonSmall
type="button"
variant="ghost"
onClick={() => setTerminalFontSize(13)}
disabled={terminalFontSize === 13}
className="h-8 w-8 px-0 border border-border bg-background hover:bg-interactive-hover disabled:opacity-100 disabled:bg-background"
aria-label="Reset terminal font size"
title="Reset"
>
<RiRestartLine className="h-3.5 w-3.5" />
</ButtonSmall>
</div>
) : (
<div className="flex items-center gap-3 w-full max-w-md">
<input
type="range"
min="9"
max="52"
step="1"
value={terminalFontSize}
onChange={(e) => setTerminalFontSize(Number(e.target.value))}
className="flex-1 min-w-0 h-2 bg-muted rounded-lg 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-primary [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0"
/>
<NumberInput <NumberInput
value={terminalFontSize} value={terminalFontSize}
onValueChange={setTerminalFontSize} onValueChange={setTerminalFontSize}
min={9} min={9}
max={52} max={52}
step={1} step={1}
aria-label="Terminal font size" className="w-16"
/> />
<ButtonSmall <ButtonSmall
type="button" type="button"
variant="ghost" variant="ghost"
onClick={() => setTerminalFontSize(13)} onClick={() => setTerminalFontSize(13)}
disabled={terminalFontSize === 13} disabled={terminalFontSize === 13}
className="h-8 w-8 px-0 border border-border bg-background hover:bg-interactive-hover disabled:opacity-100 disabled:bg-background" className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
aria-label="Reset terminal font size" aria-label="Reset terminal font size"
title="Reset" title="Reset"
> >
<RiRestartLine className="h-3.5 w-3.5" /> <RiRestartLine className="h-3.5 w-3.5" />
</ButtonSmall> </ButtonSmall>
</div> </div>
)}
</div> </div>
)} )}
{shouldShow('spacing') && ( {shouldShow('spacing') && (
<div className="space-y-4"> <div className={cn("py-1.5", isMobile ? "flex flex-col gap-3" : "flex items-center gap-8")}>
<div className="space-y-1"> <div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "w-56 shrink-0")}>
<h3 className="typography-ui-header font-semibold text-foreground"> <span className="typography-ui-label text-foreground">Spacing Density</span>
Spacing
</h3>
</div> </div>
<div className={cn("flex items-center gap-2", isMobile ? "w-full" : "w-fit")}>
{isMobile ? (
<div className="flex items-center gap-2 w-full">
<input
type="range"
min="50"
max="200"
step="5"
value={padding}
onChange={(e) => setPadding(Number(e.target.value))}
className="flex-1 min-w-0 h-3 bg-muted rounded-full appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-moz-range-thumb]:w-5 [&::-moz-range-thumb]:h-5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0"
aria-label="Spacing percentage"
/>
<span className="typography-ui-label font-medium text-foreground tabular-nums rounded-md border border-border bg-background px-2 py-1.5 min-w-[3.75rem] text-center">
{padding}
</span>
<ButtonSmall
type="button"
variant="ghost"
onClick={() => setPadding(100)}
disabled={padding === 100}
className="h-8 w-8 px-0 border border-border bg-background hover:bg-interactive-hover disabled:opacity-100 disabled:bg-background"
aria-label="Reset spacing"
title="Reset"
>
<RiRestartLine className="h-3.5 w-3.5" />
</ButtonSmall>
</div>
) : (
<div className="flex items-center gap-3 w-full max-w-md">
<input
type="range"
min="50"
max="200"
step="5"
value={padding}
onChange={(e) => setPadding(Number(e.target.value))}
className="flex-1 min-w-0 h-2 bg-muted rounded-lg 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-primary [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0"
/>
<NumberInput <NumberInput
value={padding} value={padding}
onValueChange={setPadding} onValueChange={setPadding}
min={50} min={50}
max={200} max={200}
step={5} step={5}
aria-label="Spacing percentage" className="w-16"
/> />
<ButtonSmall <ButtonSmall
type="button" type="button"
variant="ghost" variant="ghost"
onClick={() => setPadding(100)} onClick={() => setPadding(100)}
disabled={padding === 100} disabled={padding === 100}
className="h-8 w-8 px-0 border border-border bg-background hover:bg-interactive-hover disabled:opacity-100 disabled:bg-background" className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
aria-label="Reset spacing" aria-label="Reset spacing"
title="Reset" title="Reset"
> >
<RiRestartLine className="h-3.5 w-3.5" /> <RiRestartLine className="h-3.5 w-3.5" />
</ButtonSmall> </ButtonSmall>
</div> </div>
)}
</div>
)}
{shouldShow('terminalQuickKeys') && !isMobile && (
<div className="space-y-4">
<div className="space-y-1">
<h3 className="typography-ui-header font-semibold text-foreground">
Show terminal optional key bar
</h3>
<p className="typography-ui text-muted-foreground">
Esc, Ctrl, arrows, Enter.
</p>
</div>
<Switch
checked={showTerminalQuickKeysOnDesktop}
onCheckedChange={setShowTerminalQuickKeysOnDesktop}
className="data-[state=checked]:bg-status-info"
/>
</div> </div>
)} )}
{shouldShow('cornerRadius') && ( {shouldShow('cornerRadius') && (
<div className="space-y-4"> <div className={cn("py-1.5", isMobile ? "flex flex-col gap-3" : "flex items-center gap-8")}>
<div className="space-y-1"> <div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "w-56 shrink-0")}>
<h3 className="typography-ui-header font-semibold text-foreground"> <span className="typography-ui-label text-foreground">Corner Radius</span>
Input Field Corner Radius
</h3>
</div> </div>
<div className={cn("flex items-center gap-2", isMobile ? "w-full" : "w-fit")}>
{isMobile ? (
<div className="flex items-center gap-2 w-full">
<input
type="range"
min="0"
max="32"
step="1"
value={cornerRadius}
onChange={(e) => setCornerRadius(Number(e.target.value))}
className="flex-1 min-w-0 h-3 bg-muted rounded-full appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-moz-range-thumb]:w-5 [&::-moz-range-thumb]:h-5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0"
aria-label="Corner radius in pixels"
/>
<span className="typography-ui-label font-medium text-foreground tabular-nums rounded-md border border-border bg-background px-2 py-1.5 min-w-[3.75rem] text-center">
{cornerRadius}px
</span>
<ButtonSmall
type="button"
variant="ghost"
onClick={() => setCornerRadius(12)}
disabled={cornerRadius === 12}
className="h-8 w-8 px-0 border border-border bg-background hover:bg-interactive-hover disabled:opacity-100 disabled:bg-background"
aria-label="Reset corner radius"
title="Reset"
>
<RiRestartLine className="h-3.5 w-3.5" />
</ButtonSmall>
</div>
) : (
<div className="flex items-center gap-3 w-full max-w-md">
<input
type="range"
min="0"
max="32"
step="1"
value={cornerRadius}
onChange={(e) => setCornerRadius(Number(e.target.value))}
className="flex-1 min-w-0 h-2 bg-muted rounded-lg 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-primary [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0"
aria-label="Corner radius in pixels"
/>
<NumberInput <NumberInput
value={cornerRadius} value={cornerRadius}
onValueChange={setCornerRadius} onValueChange={setCornerRadius}
min={0} min={0}
max={32} max={32}
step={1} step={1}
aria-label="Corner radius in pixels" className="w-16"
/> />
<ButtonSmall <ButtonSmall
type="button" type="button"
variant="ghost" variant="ghost"
onClick={() => setCornerRadius(12)} onClick={() => setCornerRadius(12)}
disabled={cornerRadius === 12} disabled={cornerRadius === 12}
className="h-8 w-8 px-0 border border-border bg-background hover:bg-interactive-hover disabled:opacity-100 disabled:bg-background" className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
aria-label="Reset corner radius" aria-label="Reset corner radius"
title="Reset" title="Reset"
> >
<RiRestartLine className="h-3.5 w-3.5" /> <RiRestartLine className="h-3.5 w-3.5" />
</ButtonSmall> </ButtonSmall>
</div> </div>
)}
</div> </div>
)} )}
{shouldShow('inputBarOffset') && ( {shouldShow('inputBarOffset') && (
<div className="space-y-4"> <div className={cn("py-1.5", isMobile ? "flex flex-col gap-3" : "flex items-center gap-8")}>
<div className="space-y-1"> <div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "w-56 shrink-0")}>
<h3 className="typography-ui-header font-semibold text-foreground"> <div className="flex items-center gap-1.5">
Input Bar Offset <span className="typography-ui-label text-foreground">Input Bar Offset</span>
</h3> <Tooltip delayDuration={1000}>
<p className="typography-meta text-muted-foreground"> <TooltipTrigger asChild>
Raise the input bar to avoid screen obstructions. <RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</p> </TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
Raise input bar to avoid OS-level screen obstructions like home bars.
</TooltipContent>
</Tooltip>
</div> </div>
{isMobile ? (
<div className="flex items-center gap-2 w-full">
<input
type="range"
min="0"
max="100"
step="5"
value={inputBarOffset}
onChange={(e) => setInputBarOffset(Number(e.target.value))}
className="flex-1 min-w-0 h-3 bg-muted rounded-full appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-moz-range-thumb]:w-5 [&::-moz-range-thumb]:h-5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0"
aria-label="Input bar offset in pixels"
/>
<span className="typography-ui-label font-medium text-foreground tabular-nums rounded-md border border-border bg-background px-2 py-1.5 min-w-[3.75rem] text-center">
{inputBarOffset}px
</span>
<ButtonSmall
type="button"
variant="ghost"
onClick={() => setInputBarOffset(0)}
disabled={inputBarOffset === 0}
className="h-8 w-8 px-0 border border-border bg-background hover:bg-interactive-hover disabled:opacity-100 disabled:bg-background"
aria-label="Reset input bar offset"
title="Reset"
>
<RiRestartLine className="h-3.5 w-3.5" />
</ButtonSmall>
</div> </div>
) : ( <div className={cn("flex items-center gap-2", isMobile ? "w-full" : "w-fit")}>
<div className="flex items-center gap-3 w-full max-w-md">
<input
type="range"
min="0"
max="100"
step="5"
value={inputBarOffset}
onChange={(e) => setInputBarOffset(Number(e.target.value))}
className="flex-1 min-w-0 h-2 bg-muted rounded-lg 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-primary [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0"
aria-label="Input bar offset in pixels"
/>
<NumberInput <NumberInput
value={inputBarOffset} value={inputBarOffset}
onValueChange={setInputBarOffset} onValueChange={setInputBarOffset}
min={0} min={0}
max={100} max={100}
step={5} step={5}
aria-label="Input bar offset in pixels" className="w-16"
/> />
<ButtonSmall <ButtonSmall
type="button" type="button"
variant="ghost" variant="ghost"
onClick={() => setInputBarOffset(0)} onClick={() => setInputBarOffset(0)}
disabled={inputBarOffset === 0} disabled={inputBarOffset === 0}
className="h-8 w-8 px-0 border border-border bg-background hover:bg-interactive-hover disabled:opacity-100 disabled:bg-background" className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
aria-label="Reset input bar offset" aria-label="Reset input bar offset"
title="Reset" title="Reset"
> >
<RiRestartLine className="h-3.5 w-3.5" /> <RiRestartLine className="h-3.5 w-3.5" />
</ButtonSmall> </ButtonSmall>
</div> </div>
)}
</div> </div>
)} )}
{shouldShow('toolOutput') && ( </section>
<div className="space-y-4">
<div className="space-y-1">
<h3 className="typography-ui-header font-semibold text-foreground">
Default Tool Output
</h3>
<p className="typography-meta text-muted-foreground">
{TOOL_EXPANSION_OPTIONS.find(o => o.value === toolCallExpansion)?.description}
</p>
</div> </div>
<div className="flex gap-1 w-fit"> )}
{TOOL_EXPANSION_OPTIONS.map((option) => (
{hasBehaviorSettings && (
<div className="space-y-3">
{shouldShow('toolOutput') && (
<section className="px-2 pb-2 pt-0">
<h4 className="typography-ui-header font-medium text-foreground">Default Tool Output</h4>
<div className="mt-1.5 flex flex-wrap items-center gap-1">
{TOOL_EXPANSION_OPTIONS.map((option) => {
return (
<ButtonSmall <ButtonSmall
key={option.value} key={option.value}
variant={toolCallExpansion === option.value ? 'default' : 'outline'} variant="outline"
className={cn(toolCallExpansion === option.value ? undefined : 'text-foreground')} size="xs"
className={cn(
'!font-normal',
toolCallExpansion === option.value
? 'border-[var(--primary-base)] text-[var(--primary-base)] bg-[var(--primary-base)]/10 hover:text-[var(--primary-base)]'
: 'text-foreground'
)}
onClick={() => setToolCallExpansion(option.value)} onClick={() => setToolCallExpansion(option.value)}
> >
{option.label} {option.label}
</ButtonSmall> </ButtonSmall>
))} );
</div> })}
</div> </div>
</section>
)} )}
{shouldShow('diffLayout') && !isMobile && !isVSCodeRuntime() && ( {shouldShow('diffLayout') && !isVSCodeRuntime() && (
<div className="space-y-6"> <section className="p-2">
<div className="space-y-1"> <h4 className="typography-ui-header font-medium text-foreground">Diff Layout</h4>
<h3 className="typography-ui-header font-semibold text-foreground"> <div role="radiogroup" aria-label="Diff layout" className="mt-1 space-y-0">
Diff layout (Diff tab) {DIFF_LAYOUT_OPTIONS.map((option) => {
</h3> const selected = diffLayoutPreference === option.id;
<p className="typography-meta text-muted-foreground/80"> return (
Choose the default layout for file diffs. You can still override layout per file from the Diff tab. <div
</p>
</div>
<div className="flex flex-col gap-2">
<div className="flex gap-1 w-fit">
{DIFF_LAYOUT_OPTIONS.map((option) => (
<ButtonSmall
key={option.id} key={option.id}
variant={diffLayoutPreference === option.id ? 'default' : 'outline'} role="button"
className={cn(diffLayoutPreference === option.id ? undefined : 'text-foreground')} tabIndex={0}
aria-pressed={selected}
onClick={() => setDiffLayoutPreference(option.id)} onClick={() => setDiffLayoutPreference(option.id)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
setDiffLayoutPreference(option.id);
}
}}
className="flex w-full items-center gap-2 py-0.5 text-left"
> >
<Radio
checked={selected}
onChange={() => setDiffLayoutPreference(option.id)}
ariaLabel={`Diff layout: ${option.label}`}
/>
<span className={cn('typography-ui-label font-normal', selected ? 'text-foreground' : 'text-foreground/50')}>
{option.label} {option.label}
</ButtonSmall> </span>
))}
</div> </div>
<p className="typography-meta text-muted-foreground/80 max-w-xl"> );
{DIFF_LAYOUT_OPTIONS.find((option) => option.id === diffLayoutPreference)?.description} })}
</p>
</div> </div>
</section>
)}
<div className="space-y-1"> {shouldShow('diffLayout') && !isVSCodeRuntime() && (
<h3 className="typography-ui-header font-semibold text-foreground"> <section className="p-2">
Diff view (Diff tab) <h4 className="typography-ui-header font-medium text-foreground">Diff View Mode</h4>
</h3> <div role="radiogroup" aria-label="Diff view mode" className="mt-1 space-y-0">
<p className="typography-meta text-muted-foreground/80"> {DIFF_VIEW_MODE_OPTIONS.map((option) => {
Choose whether the Diff tab defaults to a single file or all files. const selected = diffViewMode === option.id;
</p> return (
</div> <div
<div className="flex flex-col gap-2">
<div className="flex gap-1 w-fit">
{DIFF_VIEW_MODE_OPTIONS.map((option) => (
<ButtonSmall
key={option.id} key={option.id}
variant={diffViewMode === option.id ? 'default' : 'outline'} role="button"
className={cn(diffViewMode === option.id ? undefined : 'text-foreground')} tabIndex={0}
aria-pressed={selected}
onClick={() => setDiffViewMode(option.id)} onClick={() => setDiffViewMode(option.id)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
setDiffViewMode(option.id);
}
}}
className="flex w-full items-center gap-2 py-0.5 text-left"
> >
<Radio
checked={selected}
onChange={() => setDiffViewMode(option.id)}
ariaLabel={`Diff view mode: ${option.label}`}
/>
<span className={cn('typography-ui-label font-normal', selected ? 'text-foreground' : 'text-foreground/50')}>
{option.label} {option.label}
</ButtonSmall> </span>
))}
</div> </div>
<p className="typography-meta text-muted-foreground/80 max-w-xl"> );
{DIFF_VIEW_MODE_OPTIONS.find((option) => option.id === diffViewMode)?.description} })}
</p>
</div>
</div> </div>
</section>
)} )}
{(shouldShow('dotfiles') || shouldShow('queueMode') || shouldShow('persistDraft') || shouldShow('reasoning') || shouldShow('textJustificationActivity')) && (
<section className="p-2 space-y-0.5">
{shouldShow('dotfiles') && !isVSCodeRuntime() && ( {shouldShow('dotfiles') && !isVSCodeRuntime() && (
<div className="space-y-3"> <div
<div className="space-y-1"> className="group flex cursor-pointer items-center gap-2 py-1.5"
<h3 className="typography-ui-header font-semibold text-foreground"> role="button"
Hidden files (Chat) tabIndex={0}
</h3> aria-pressed={directoryShowHidden}
<p className="typography-meta text-muted-foreground/80"> onClick={() => setDirectoryShowHidden(!directoryShowHidden)}
Show or hide dotfiles in file lists and directory pickers. onKeyDown={(event) => {
</p> if (event.key === ' ' || event.key === 'Enter') {
</div> event.preventDefault();
setDirectoryShowHidden(!directoryShowHidden);
<div className="flex flex-col gap-2"> }
<div className="flex gap-1 w-fit"> }}
{[
{ id: 'hide', label: 'Hide', value: false },
{ id: 'show', label: 'Show', value: true },
].map((option) => (
<ButtonSmall
key={option.id}
variant={directoryShowHidden === option.value ? 'default' : 'outline'}
className={cn(directoryShowHidden === option.value ? undefined : 'text-foreground')}
onClick={() => setDirectoryShowHidden(option.value)}
> >
{option.label}
</ButtonSmall>
))}
</div>
</div>
</div>
)}
{shouldShow('reasoning') && (
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox <Checkbox
checked={showReasoningTraces} checked={directoryShowHidden}
onChange={setShowReasoningTraces} onChange={setDirectoryShowHidden}
ariaLabel="Show dotfiles"
/> />
<span className="typography-ui-header font-semibold text-foreground"> <span className="typography-ui-label text-foreground">Show Dotfiles</span>
Show thinking / reasoning traces </div>
</span>
</label>
)}
{shouldShow('textJustificationActivity') && (
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={showTextJustificationActivity}
onChange={setShowTextJustificationActivity}
/>
<span className="typography-ui-header font-semibold text-foreground">
Show text justification in activity
</span>
</label>
)} )}
{shouldShow('queueMode') && ( {shouldShow('queueMode') && (
<div className="space-y-2"> <div
<label className="flex items-center gap-2 cursor-pointer"> className="group flex cursor-pointer items-center gap-2 py-1.5"
role="button"
tabIndex={0}
aria-pressed={queueModeEnabled}
onClick={() => setQueueMode(!queueModeEnabled)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
setQueueMode(!queueModeEnabled);
}
}}
>
<Checkbox <Checkbox
checked={queueModeEnabled} checked={queueModeEnabled}
onChange={setQueueMode} onChange={setQueueMode}
ariaLabel="Queue messages by default"
/> />
<span className="typography-ui-header font-semibold text-foreground"> <div className="flex min-w-0 items-center gap-1.5">
Queue messages by default <span className="typography-ui-label text-foreground">Queue Messages by Default</span>
</span> <Tooltip delayDuration={1000}>
</label> <TooltipTrigger asChild>
<p className="typography-meta text-muted-foreground pl-5"> <RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
{queueModeEnabled </TooltipTrigger>
? `Enter queues messages, ${getModifierLabel()}+Enter sends immediately.` <TooltipContent sideOffset={8} className="max-w-xs">
: `Enter sends immediately, ${getModifierLabel()}+Enter queues messages.`} When enabled, Enter queues messages. Use {getModifierLabel()}+Enter to send.
</p> </TooltipContent>
</Tooltip>
</div>
</div> </div>
)} )}
{shouldShow('persistDraft') && ( {shouldShow('persistDraft') && (
<div className="space-y-2"> <div
<label className="flex items-center gap-2 cursor-pointer"> className="group flex cursor-pointer items-center gap-2 py-1.5"
role="button"
tabIndex={0}
aria-pressed={persistChatDraft}
onClick={() => setPersistChatDraft(!persistChatDraft)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
setPersistChatDraft(!persistChatDraft);
}
}}
>
<Checkbox <Checkbox
checked={persistChatDraft} checked={persistChatDraft}
onChange={setPersistChatDraft} onChange={setPersistChatDraft}
ariaLabel="Persist draft messages"
/> />
<span className="typography-ui-header font-semibold text-foreground"> <span className="typography-ui-label text-foreground">Persist Draft Messages</span>
Persist chat input draft
</span>
</label>
<p className="typography-meta text-muted-foreground pl-5">
Save your typed message across page reloads and session switches.
</p>
</div> </div>
)} )}
{shouldShow('reasoning') && (
<div
className="group flex cursor-pointer items-center gap-2 py-1.5"
role="button"
tabIndex={0}
aria-pressed={showReasoningTraces}
onClick={() => setShowReasoningTraces(!showReasoningTraces)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
setShowReasoningTraces(!showReasoningTraces);
}
}}
>
<Checkbox
checked={showReasoningTraces}
onChange={setShowReasoningTraces}
ariaLabel="Show reasoning traces"
/>
<span className="typography-ui-label text-foreground">Show Reasoning Traces</span>
</div>
)}
{shouldShow('textJustificationActivity') && (
<div
className="group flex cursor-pointer items-center gap-2 py-1.5"
role="button"
tabIndex={0}
aria-pressed={showTextJustificationActivity}
onClick={() => setShowTextJustificationActivity(!showTextJustificationActivity)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
setShowTextJustificationActivity(!showTextJustificationActivity);
}
}}
>
<Checkbox
checked={showTextJustificationActivity}
onChange={setShowTextJustificationActivity}
ariaLabel="Show justification activity"
/>
<span className="typography-ui-label text-foreground">Show Justification Activity</span>
</div>
)}
</section>
)}
{shouldShow('terminalQuickKeys') && !isMobile && (
<section className="p-2">
<div
className="group flex cursor-pointer items-center gap-2 rounded-md py-1.5 transition-colors hover:bg-[var(--interactive-hover)]/30"
role="button"
tabIndex={0}
aria-pressed={showTerminalQuickKeysOnDesktop}
onClick={() => setShowTerminalQuickKeysOnDesktop(!showTerminalQuickKeysOnDesktop)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
setShowTerminalQuickKeysOnDesktop(!showTerminalQuickKeysOnDesktop);
}
}}
>
<Checkbox
checked={showTerminalQuickKeysOnDesktop}
onChange={setShowTerminalQuickKeysOnDesktop}
ariaLabel="Terminal quick keys"
/>
<div className="flex min-w-0 items-center gap-1.5">
<span className="typography-ui-label text-foreground">Terminal Quick Keys</span>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
Show Esc, Ctrl, Arrows in terminal view
</TooltipContent>
</Tooltip>
</div>
</div>
</section>
)}
</div>
)}
</div> </div>
); );
}; };
@@ -1,6 +1,8 @@
import * as React from 'react'; import * as React from 'react';
import { Button } from '@/components/ui/button'; import { ButtonSmall } from '@/components/ui/button-small';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { RiFolderLine, RiInformationLine } from '@remixicon/react';
import { isDesktopShell, isTauriShell } from '@/lib/desktop'; import { isDesktopShell, isTauriShell } from '@/lib/desktop';
import { updateDesktopSettings } from '@/lib/persistence'; import { updateDesktopSettings } from '@/lib/persistence';
import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore'; import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
@@ -79,45 +81,69 @@ export const OpenCodeCliSettings: React.FC = () => {
}, [value]); }, [value]);
return ( return (
<div className="space-y-3"> <div className="mb-8">
<div className="space-y-1"> <div className="mb-1 px-1">
<h3 className="typography-ui-header font-semibold text-foreground">OpenCode CLI</h3> <div className="flex items-center gap-2">
<p className="typography-meta text-muted-foreground"> <h3 className="typography-ui-header font-medium text-foreground">
OpenCode CLI
</h3>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
Optional absolute path to the <code className="font-mono text-xs">opencode</code> binary. Optional absolute path to the <code className="font-mono text-xs">opencode</code> binary.
Useful when your desktop app launch environment has a stale PATH. </TooltipContent>
If your <code className="font-mono text-xs">opencode</code> shim requires Node/Bun (e.g. <code className="font-mono text-xs">env node</code> or <code className="font-mono text-xs">env bun</code>), make sure that runtime is installed. </Tooltip>
</p> </div>
</div> </div>
<div className="flex gap-2"> <section className="px-2 pb-2 pt-0 space-y-0.5">
<div 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">OpenCode Binary Path</span>
</div>
<div className="flex min-w-0 items-center gap-2 sm:w-[20rem]">
<Input <Input
value={value} value={value}
onChange={(e) => setValue(e.target.value)} onChange={(e) => setValue(e.target.value)}
placeholder="/Users/you/.bun/bin/opencode" placeholder="/Users/you/.bun/bin/opencode"
disabled={isLoading || isSaving} disabled={isLoading || isSaving}
className="flex-1 font-mono text-xs" className="h-7 min-w-0 flex-1 font-mono text-xs"
/> />
<Button <ButtonSmall
type="button" type="button"
variant="secondary" variant="outline"
size="xs"
onClick={handleBrowse} onClick={handleBrowse}
disabled={isLoading || isSaving || !isDesktopShell() || !isTauriShell()} disabled={isLoading || isSaving || !isDesktopShell() || !isTauriShell()}
className="h-7 w-7 p-0"
aria-label="Browse for OpenCode binary path"
title="Browse"
> >
Browse <RiFolderLine className="h-4 w-4" />
</Button> </ButtonSmall>
<Button </div>
type="button"
onClick={handleSaveAndReload}
disabled={isLoading || isSaving}
>
{isSaving ? 'Saving…' : 'Save + Reload'}
</Button>
</div> </div>
<div className="typography-micro text-muted-foreground"> <div className="py-1.5">
Tip: you can also use <span className="font-mono">OPENCODE_BINARY</span> env var, but this setting persists in <div className="typography-micro text-muted-foreground/70">
<span className="font-mono"> ~/.config/openchamber/settings.json</span>. Tip: you can also use <span className="font-mono">OPENCODE_BINARY</span> env var, but this setting persists in <span className="font-mono">~/.config/openchamber/settings.json</span>.
</div> </div>
</div> </div>
<div className="flex justify-start py-1.5">
<ButtonSmall
type="button"
size="xs"
onClick={handleSaveAndReload}
disabled={isLoading || isSaving}
className="shrink-0 !font-normal"
>
{isSaving ? 'Saving…' : 'Save + Reload'}
</ButtonSmall>
</div>
</section>
</div>
); );
}; };
@@ -1,30 +1,23 @@
import React from 'react'; import React from 'react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { RiInformationLine, RiRestartLine } from '@remixicon/react';
import { toast } from '@/components/ui'; import { toast } from '@/components/ui';
import { RiInformationLine } from '@remixicon/react';
import { NumberInput } from '@/components/ui/number-input'; import { NumberInput } from '@/components/ui/number-input';
import { ButtonSmall } from '@/components/ui/button-small'; import { ButtonSmall } from '@/components/ui/button-small';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { useDeviceInfo } from '@/lib/device';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { useSessionAutoCleanup } from '@/hooks/useSessionAutoCleanup'; import { useSessionAutoCleanup } from '@/hooks/useSessionAutoCleanup';
const MIN_DAYS = 1; const MIN_DAYS = 1;
const MAX_DAYS = 365; const MAX_DAYS = 365;
const DEFAULT_RETENTION_DAYS = 30;
export const SessionRetentionSettings: React.FC = () => { export const SessionRetentionSettings: React.FC = () => {
const { isMobile } = useDeviceInfo();
const autoDeleteEnabled = useUIStore((state) => state.autoDeleteEnabled); const autoDeleteEnabled = useUIStore((state) => state.autoDeleteEnabled);
const autoDeleteAfterDays = useUIStore((state) => state.autoDeleteAfterDays); const autoDeleteAfterDays = useUIStore((state) => state.autoDeleteAfterDays);
const setAutoDeleteEnabled = useUIStore((state) => state.setAutoDeleteEnabled); const setAutoDeleteEnabled = useUIStore((state) => state.setAutoDeleteEnabled);
const setAutoDeleteAfterDays = useUIStore((state) => state.setAutoDeleteAfterDays); const setAutoDeleteAfterDays = useUIStore((state) => state.setAutoDeleteAfterDays);
const [mobileDraftDays, setMobileDraftDays] = React.useState(String(autoDeleteAfterDays));
React.useEffect(() => {
setMobileDraftDays(String(autoDeleteAfterDays));
}, [autoDeleteAfterDays]);
const { candidates, isRunning, runCleanup } = useSessionAutoCleanup({ autoRun: false }); const { candidates, isRunning, runCleanup } = useSessionAutoCleanup({ autoRun: false });
const pendingCount = candidates.length; const pendingCount = candidates.length;
@@ -43,69 +36,50 @@ export const SessionRetentionSettings: React.FC = () => {
}, [runCleanup]); }, [runCleanup]);
return ( return (
<div className="space-y-4"> <div className="mb-8">
<div className="space-y-1"> <div className="mb-1 px-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h3 className="typography-ui-header font-semibold text-foreground">Session retention</h3> <h3 className="typography-ui-header font-medium text-foreground">
Session Retention
</h3>
<Tooltip delayDuration={1000}> <Tooltip delayDuration={1000}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" /> <RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs"> <TooltipContent sideOffset={8} className="max-w-xs">
Automatically delete inactive sessions based on their last activity.<br /> Automatically delete inactive sessions based on their last activity. Keeps recent 5 sessions.
You can also run a one-time cleanup without enabling auto-cleanup.<br />
Keeps the most recent 5 sessions, and never deletes shared sessions.
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
</div> </div>
<label className="flex items-center gap-2 cursor-pointer"> <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={autoDeleteEnabled}
onClick={() => setAutoDeleteEnabled(!autoDeleteEnabled)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
setAutoDeleteEnabled(!autoDeleteEnabled);
}
}}
>
<Checkbox <Checkbox
checked={autoDeleteEnabled} checked={autoDeleteEnabled}
onChange={setAutoDeleteEnabled} onChange={setAutoDeleteEnabled}
ariaLabel="Enable auto-cleanup"
/> />
<span className="typography-ui-header font-semibold text-foreground">Enable auto-cleanup</span> <span className="typography-ui-label text-foreground">Enable Auto-Cleanup</span>
</label> </div>
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
<div className="flex items-center gap-3"> <div className="flex min-w-0 flex-col sm:w-56 shrink-0">
{isMobile ? ( <span className="typography-ui-label text-foreground">Retention Period</span>
<input </div>
type="number" <div className="flex items-center gap-2 sm:w-fit">
inputMode="numeric"
value={mobileDraftDays}
onChange={(event) => {
const nextValue = event.target.value;
setMobileDraftDays(nextValue);
if (nextValue.trim() === '') {
return;
}
const parsed = Number(nextValue);
if (!Number.isFinite(parsed)) {
return;
}
const clamped = Math.min(MAX_DAYS, Math.max(MIN_DAYS, Math.round(parsed)));
setAutoDeleteAfterDays(clamped);
}}
onBlur={() => {
if (mobileDraftDays.trim() === '') {
setMobileDraftDays(String(autoDeleteAfterDays));
return;
}
const parsed = Number(mobileDraftDays);
if (!Number.isFinite(parsed)) {
setMobileDraftDays(String(autoDeleteAfterDays));
return;
}
const clamped = Math.min(MAX_DAYS, Math.max(MIN_DAYS, Math.round(parsed)));
setAutoDeleteAfterDays(clamped);
setMobileDraftDays(String(clamped));
}}
aria-label="Retention period in days"
className="h-8 w-16 rounded-lg border border-border bg-background px-2 text-center typography-ui-label text-foreground focus:border-ring focus:outline-none focus:ring-2 focus:ring-ring/50"
/>
) : (
<NumberInput <NumberInput
value={autoDeleteAfterDays} value={autoDeleteAfterDays}
onValueChange={setAutoDeleteAfterDays} onValueChange={setAutoDeleteAfterDays}
@@ -113,22 +87,45 @@ export const SessionRetentionSettings: React.FC = () => {
max={MAX_DAYS} max={MAX_DAYS}
step={1} step={1}
aria-label="Retention period in days" aria-label="Retention period in days"
className="w-20 tabular-nums"
/> />
)} <span className="typography-ui-label text-muted-foreground">days</span>
<span className="typography-ui-label text-muted-foreground">days since last activity</span> <ButtonSmall
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="Reset retention period"
title="Reset"
>
<RiRestartLine className="h-3.5 w-3.5" />
</ButtonSmall>
</div> </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">Manual Cleanup</p>
</div>
<div className="flex items-center gap-2 sm:w-fit">
<ButtonSmall <ButtonSmall
type="button" type="button"
variant="outline" variant="outline"
size="xs"
onClick={handleRunCleanup} onClick={handleRunCleanup}
disabled={isRunning} disabled={isRunning}
className="!font-normal"
> >
{isRunning ? 'Cleaning up...' : 'Run cleanup now'} {isRunning ? 'Cleaning up...' : 'Run cleanup now'}
</ButtonSmall> </ButtonSmall>
</div> </div>
</div>
<div className="typography-meta text-muted-foreground"> <p className="typography-meta text-muted-foreground">
Eligible for deletion right now: {pendingCount} Eligible for deletion right now: <span className="tabular-nums">{pendingCount}</span>
</p>
</div> </div>
</div> </div>
); );
@@ -1,7 +1,7 @@
import React, { useEffect, useState, useCallback, useMemo } from 'react'; import React, { useEffect, useState, useCallback, useMemo } from 'react';
import { useBrowserVoice } from '@/hooks/useBrowserVoice'; import { useBrowserVoice } from '@/hooks/useBrowserVoice';
import { useConfigStore } from '@/stores/useConfigStore'; import { useConfigStore } from '@/stores/useConfigStore';
import { SettingsSection } from '@/components/sections/shared/SettingsSection'; import { useDeviceInfo } from '@/lib/device';
import { import {
Select, Select,
@@ -10,14 +10,14 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch'; import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button'; import { ButtonSmall } from '@/components/ui/button-small';
import { Slider } from '@/components/ui/slider'; import { NumberInput } from '@/components/ui/number-input';
import { RiMicLine, RiAlertLine, RiVolumeUpLine, RiSpeedLine, RiMusicLine, RiSoundModuleLine, RiAppleLine, RiPlayLine, RiStopLine, RiChromeLine, RiFileTextLine, RiKeyLine, RiCloseLine } from '@remixicon/react'; import { RiPlayLine, RiStopLine, RiCloseLine, RiAppleLine, RiInformationLine } from '@remixicon/react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { browserVoiceService } from '@/lib/voice/browserVoiceService'; import { browserVoiceService } from '@/lib/voice/browserVoiceService';
import { cn } from '@/lib/utils';
// Common language options with display names
// Shared with BrowserVoiceButton.tsx
const LANGUAGE_OPTIONS = [ const LANGUAGE_OPTIONS = [
{ value: 'en-US', label: 'English' }, { value: 'en-US', label: 'English' },
{ value: 'es-ES', label: 'Español' }, { value: 'es-ES', label: 'Español' },
@@ -31,11 +31,24 @@ const LANGUAGE_OPTIONS = [
{ value: 'uk-UA', label: 'Українська' }, { value: 'uk-UA', label: 'Українська' },
]; ];
/** const OPENAI_VOICE_OPTIONS = [
* Voice settings section for OpenChamber settings { value: 'alloy', label: 'Alloy' },
* Allows users to configure voice conversation preferences { value: 'ash', label: 'Ash' },
*/ { value: 'ballad', label: 'Ballad' },
{ value: 'coral', label: 'Coral' },
{ value: 'echo', label: 'Echo' },
{ value: 'fable', label: 'Fable' },
{ value: 'nova', label: 'Nova' },
{ value: 'onyx', label: 'Onyx' },
{ value: 'sage', label: 'Sage' },
{ value: 'shimmer', label: 'Shimmer' },
{ value: 'verse', label: 'Verse' },
{ value: 'marin', label: 'Marin' },
{ value: 'cedar', label: 'Cedar' },
];
export const VoiceSettings: React.FC = () => { export const VoiceSettings: React.FC = () => {
const { isMobile } = useDeviceInfo();
const { const {
isSupported, isSupported,
language, language,
@@ -72,22 +85,18 @@ export const VoiceSettings: React.FC = () => {
setSummarizeMaxLength, setSummarizeMaxLength,
} = useConfigStore(); } = useConfigStore();
// Check if macOS 'say' is available and get voices
const [isSayAvailable, setIsSayAvailable] = useState(false); const [isSayAvailable, setIsSayAvailable] = useState(false);
const [sayVoices, setSayVoices] = useState<Array<{ name: string; locale: string }>>([]); const [sayVoices, setSayVoices] = useState<Array<{ name: string; locale: string }>>([]);
const [isPreviewPlaying, setIsPreviewPlaying] = useState(false); const [isPreviewPlaying, setIsPreviewPlaying] = useState(false);
const [previewAudio, setPreviewAudio] = useState<HTMLAudioElement | null>(null); const [previewAudio, setPreviewAudio] = useState<HTMLAudioElement | null>(null);
// Check if OpenAI TTS is available
const [isOpenAIAvailable, setIsOpenAIAvailable] = useState(false); const [isOpenAIAvailable, setIsOpenAIAvailable] = useState(false);
const [isOpenAIPreviewPlaying, setIsOpenAIPreviewPlaying] = useState(false); const [isOpenAIPreviewPlaying, setIsOpenAIPreviewPlaying] = useState(false);
const [openaiPreviewAudio, setOpenaiPreviewAudio] = useState<HTMLAudioElement | null>(null); const [openaiPreviewAudio, setOpenaiPreviewAudio] = useState<HTMLAudioElement | null>(null);
// Browser voices
const [browserVoices, setBrowserVoices] = useState<SpeechSynthesisVoice[]>([]); const [browserVoices, setBrowserVoices] = useState<SpeechSynthesisVoice[]>([]);
const [isBrowserPreviewPlaying, setIsBrowserPreviewPlaying] = useState(false); const [isBrowserPreviewPlaying, setIsBrowserPreviewPlaying] = useState(false);
// Load browser voices
useEffect(() => { useEffect(() => {
const loadVoices = async () => { const loadVoices = async () => {
const voices = await browserVoiceService.waitForVoices(); const voices = await browserVoiceService.waitForVoices();
@@ -95,7 +104,6 @@ export const VoiceSettings: React.FC = () => {
}; };
loadVoices(); loadVoices();
// Also listen for voice changes (Chrome loads voices asynchronously)
if ('speechSynthesis' in window) { if ('speechSynthesis' in window) {
window.speechSynthesis.onvoiceschanged = () => { window.speechSynthesis.onvoiceschanged = () => {
setBrowserVoices(window.speechSynthesis.getVoices()); setBrowserVoices(window.speechSynthesis.getVoices());
@@ -109,25 +117,20 @@ export const VoiceSettings: React.FC = () => {
}; };
}, []); }, []);
// Filter and sort browser voices by language
const filteredBrowserVoices = useMemo(() => { const filteredBrowserVoices = useMemo(() => {
// Group voices by language, prioritize English voices at top
return browserVoices return browserVoices
.filter(v => v.lang) // Only voices with a language .filter(v => v.lang)
.sort((a, b) => { .sort((a, b) => {
// Prioritize English voices
const aIsEnglish = a.lang.startsWith('en'); const aIsEnglish = a.lang.startsWith('en');
const bIsEnglish = b.lang.startsWith('en'); const bIsEnglish = b.lang.startsWith('en');
if (aIsEnglish && !bIsEnglish) return -1; if (aIsEnglish && !bIsEnglish) return -1;
if (!aIsEnglish && bIsEnglish) return 1; if (!aIsEnglish && bIsEnglish) return 1;
// Then sort by language, then by name
const langCompare = a.lang.localeCompare(b.lang); const langCompare = a.lang.localeCompare(b.lang);
if (langCompare !== 0) return langCompare; if (langCompare !== 0) return langCompare;
return a.name.localeCompare(b.name); return a.name.localeCompare(b.name);
}); });
}, [browserVoices]); }, [browserVoices]);
// Preview browser voice
const previewBrowserVoice = useCallback(() => { const previewBrowserVoice = useCallback(() => {
if (isBrowserPreviewPlaying) { if (isBrowserPreviewPlaying) {
browserVoiceService.cancelSpeech(); browserVoiceService.cancelSpeech();
@@ -158,7 +161,6 @@ export const VoiceSettings: React.FC = () => {
window.speechSynthesis.speak(utterance); window.speechSynthesis.speak(utterance);
}, [browserVoice, browserVoices, speechRate, speechPitch, speechVolume, isBrowserPreviewPlaying]); }, [browserVoice, browserVoices, speechRate, speechPitch, speechVolume, isBrowserPreviewPlaying]);
// Cleanup browser preview on unmount
useEffect(() => { useEffect(() => {
return () => { return () => {
if (isBrowserPreviewPlaying) { if (isBrowserPreviewPlaying) {
@@ -167,39 +169,15 @@ export const VoiceSettings: React.FC = () => {
}; };
}, [isBrowserPreviewPlaying]); }, [isBrowserPreviewPlaying]);
// OpenAI voice options
const OPENAI_VOICE_OPTIONS = [
{ value: 'alloy', label: 'Alloy' },
{ value: 'ash', label: 'Ash' },
{ value: 'ballad', label: 'Ballad' },
{ value: 'coral', label: 'Coral' },
{ value: 'echo', label: 'Echo' },
{ value: 'fable', label: 'Fable' },
{ value: 'nova', label: 'Nova' },
{ value: 'onyx', label: 'Onyx' },
{ value: 'sage', label: 'Sage' },
{ value: 'shimmer', label: 'Shimmer' },
{ value: 'verse', label: 'Verse' },
{ value: 'marin', label: 'Marin' },
{ value: 'cedar', label: 'Cedar' },
];
// Check OpenAI TTS availability (including API key from settings)
useEffect(() => { useEffect(() => {
const checkOpenAIAvailability = async () => { const checkOpenAIAvailability = async () => {
try { try {
// First check if server has API key configured
const response = await fetch('/api/tts/status'); const response = await fetch('/api/tts/status');
const data = await response.json(); const data = await response.json();
console.log('[VoiceSettings] OpenAI TTS status:', data);
// Available if server has key OR user has set API key in settings
const hasServerKey = data.available; const hasServerKey = data.available;
const hasSettingsKey = openaiApiKey.trim().length > 0; const hasSettingsKey = openaiApiKey.trim().length > 0;
setIsOpenAIAvailable(hasServerKey || hasSettingsKey); setIsOpenAIAvailable(hasServerKey || hasSettingsKey);
} catch (err) { } catch {
console.error('[VoiceSettings] Failed to check OpenAI TTS status:', err);
// Still available if user has set API key in settings
setIsOpenAIAvailable(openaiApiKey.trim().length > 0); setIsOpenAIAvailable(openaiApiKey.trim().length > 0);
} }
}; };
@@ -211,10 +189,8 @@ export const VoiceSettings: React.FC = () => {
fetch('/api/tts/say/status') fetch('/api/tts/say/status')
.then(res => res.json()) .then(res => res.json())
.then(data => { .then(data => {
console.log('[VoiceSettings] Say TTS status:', data);
setIsSayAvailable(data.available); setIsSayAvailable(data.available);
if (data.voices) { if (data.voices) {
// Filter to unique voice names and sort alphabetically
const uniqueVoices = data.voices const uniqueVoices = data.voices
.filter((v: { name: string; locale: string }, i: number, arr: Array<{ name: string; locale: string }>) => .filter((v: { name: string; locale: string }, i: number, arr: Array<{ name: string; locale: string }>) =>
arr.findIndex((x: { name: string }) => x.name === v.name) === i arr.findIndex((x: { name: string }) => x.name === v.name) === i
@@ -223,15 +199,12 @@ export const VoiceSettings: React.FC = () => {
setSayVoices(uniqueVoices); setSayVoices(uniqueVoices);
} }
}) })
.catch((err) => { .catch(() => {
console.error('[VoiceSettings] Failed to check Say TTS status:', err);
setIsSayAvailable(false); setIsSayAvailable(false);
}); });
}, []); }, []);
// Preview voice function
const previewVoice = useCallback(async () => { const previewVoice = useCallback(async () => {
// Stop any existing preview
if (previewAudio) { if (previewAudio) {
previewAudio.pause(); previewAudio.pause();
previewAudio.currentTime = 0; previewAudio.currentTime = 0;
@@ -272,13 +245,11 @@ export const VoiceSettings: React.FC = () => {
setPreviewAudio(audio); setPreviewAudio(audio);
await audio.play(); await audio.play();
} catch (err) { } catch {
console.error('Voice preview failed:', err);
setIsPreviewPlaying(false); setIsPreviewPlaying(false);
} }
}, [sayVoice, speechRate, previewAudio]); }, [sayVoice, speechRate, previewAudio]);
// Cleanup preview audio on unmount
useEffect(() => { useEffect(() => {
return () => { return () => {
if (previewAudio) { if (previewAudio) {
@@ -287,9 +258,7 @@ export const VoiceSettings: React.FC = () => {
}; };
}, [previewAudio]); }, [previewAudio]);
// Preview OpenAI voice
const previewOpenAIVoice = useCallback(async () => { const previewOpenAIVoice = useCallback(async () => {
// Stop any existing preview
if (openaiPreviewAudio) { if (openaiPreviewAudio) {
openaiPreviewAudio.pause(); openaiPreviewAudio.pause();
openaiPreviewAudio.currentTime = 0; openaiPreviewAudio.currentTime = 0;
@@ -334,13 +303,11 @@ export const VoiceSettings: React.FC = () => {
setOpenaiPreviewAudio(audio); setOpenaiPreviewAudio(audio);
await audio.play(); await audio.play();
} catch (err) { } catch {
console.error('[VoiceSettings] OpenAI voice preview failed:', err);
setIsOpenAIPreviewPlaying(false); setIsOpenAIPreviewPlaying(false);
} }
}, [openaiVoice, speechRate, openaiPreviewAudio, openaiApiKey]); }, [openaiVoice, speechRate, openaiPreviewAudio, openaiApiKey]);
// Cleanup OpenAI preview audio on unmount
useEffect(() => { useEffect(() => {
return () => { return () => {
if (openaiPreviewAudio) { if (openaiPreviewAudio) {
@@ -349,526 +316,321 @@ export const VoiceSettings: React.FC = () => {
}; };
}, [openaiPreviewAudio]); }, [openaiPreviewAudio]);
const sliderClass = "flex-1 min-w-0 h-1.5 bg-[var(--interactive-border)] rounded-full appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-[var(--primary-base)] [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-[var(--primary-base)] [&::-moz-range-thumb]:border-0 disabled:opacity-50";
return ( return (
<SettingsSection <div className="space-y-8">
title="Voice"
description="Configure voice conversation settings" {/* Voice Setup */}
> <div className="mb-8">
<div className="space-y-6"> <div className="mb-1 px-1">
{/* Voice Mode Enable/Disable */} <h3 className="typography-ui-header font-medium text-foreground">
<div className="flex items-start justify-between gap-4"> Voice Setup
<div className="space-y-1 flex-1"> </h3>
<div className="flex items-center gap-2"> </div>
<RiMicLine className="w-4 h-4 text-muted-foreground" />
<span className="typography-ui-label text-foreground"> <section className="px-2 pb-2 pt-0 space-y-0">
Voice Mode
</span> <div
</div> className="group flex cursor-pointer items-center gap-2 py-1.5"
<p className="typography-meta text-muted-foreground"> role="button"
Enable voice conversations with microphone input tabIndex={0}
</p> aria-pressed={voiceModeEnabled}
</div> onClick={() => setVoiceModeEnabled(!voiceModeEnabled)}
<Switch onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); setVoiceModeEnabled(!voiceModeEnabled); } }}
checked={voiceModeEnabled} >
onCheckedChange={setVoiceModeEnabled} <Checkbox checked={voiceModeEnabled} onChange={setVoiceModeEnabled} ariaLabel="Enable voice mode" />
aria-label="Toggle voice mode" <span className="typography-ui-label text-foreground">Enable Voice Mode</span>
/>
</div> </div>
{/* Voice provider selection - only show when voice mode is enabled */}
{voiceModeEnabled && ( {voiceModeEnabled && (
<div className="flex items-start justify-between gap-4"> <>
<div className="space-y-1 flex-1"> <div className="pb-1.5 pt-0.5">
<div className="flex items-center gap-2"> <div className="flex min-w-0 flex-col gap-1.5">
<RiVolumeUpLine className="w-4 h-4 text-muted-foreground" /> <div className="flex items-center gap-1.5">
<span className="typography-ui-label text-foreground"> <span className="typography-ui-label text-foreground">Provider</span>
Voice Provider <Tooltip delayDuration={1000}>
</span> <TooltipTrigger asChild>
<RiInformationLine 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>Browser:</strong> Free, offline, limited mobile support.</li>
<li><strong>OpenAI:</strong> High quality, mobile ready, needs API key.</li>
<li><strong>Say:</strong> macOS native. Fast, free, offline.</li>
</ul>
</TooltipContent>
</Tooltip>
</div> </div>
<p className="typography-meta text-muted-foreground"> <div className="flex flex-wrap items-center gap-1">
Choose your preferred text-to-speech provider <ButtonSmall
</p> variant="outline"
</div> size="xs"
<div className="flex gap-2 flex-wrap justify-end">
<Button
variant={voiceProvider === 'browser' ? 'default' : 'outline'}
size="sm"
onClick={() => setVoiceProvider('browser')} onClick={() => setVoiceProvider('browser')}
className="min-w-[80px]" className={cn(
'!font-normal',
voiceProvider === 'browser'
? 'border-[var(--primary-base)] text-[var(--primary-base)] bg-[var(--primary-base)]/10 hover:text-[var(--primary-base)]'
: 'text-foreground'
)}
> >
Browser Browser
</Button> </ButtonSmall>
<Button <ButtonSmall
variant={voiceProvider === 'openai' ? 'default' : 'outline'} variant="outline"
size="sm" size="xs"
onClick={() => setVoiceProvider('openai')} onClick={() => setVoiceProvider('openai')}
className="min-w-[80px]" className={cn(
title={isOpenAIAvailable ? 'OpenAI voice' : 'OpenAI voice unavailable - API key not configured'} '!font-normal',
voiceProvider === 'openai'
? 'border-[var(--primary-base)] text-[var(--primary-base)] bg-[var(--primary-base)]/10 hover:text-[var(--primary-base)]'
: 'text-foreground'
)}
> >
OpenAI OpenAI
</Button> </ButtonSmall>
{isSayAvailable && ( {isSayAvailable && (
<Button <ButtonSmall
variant={voiceProvider === 'say' ? 'default' : 'outline'} variant="outline"
size="sm" size="xs"
onClick={() => setVoiceProvider('say')} onClick={() => setVoiceProvider('say')}
className="min-w-[80px]" className={cn(
'!font-normal',
voiceProvider === 'say'
? 'border-[var(--primary-base)] text-[var(--primary-base)] bg-[var(--primary-base)]/10 hover:text-[var(--primary-base)]'
: 'text-foreground'
)}
> >
<RiAppleLine className="w-4 h-4 mr-1" /> <RiAppleLine className="w-3.5 h-3.5 mr-0.5" />
Say Say
</Button> </ButtonSmall>
)} )}
</div> </div>
</div> </div>
)} </div>
{/* Provider description */} {/* OpenAI API Key */}
{voiceModeEnabled && ( {voiceProvider === 'openai' && (
<div className="p-3 rounded-lg bg-muted/50 border border-border/50"> <div className="py-1.5">
<p className="typography-micro text-muted-foreground"> <span className={cn("typography-ui-label text-foreground", !isOpenAIAvailable && "text-[var(--status-error)]")}>
<span className="font-medium text-foreground"> API Key
{voiceProvider === 'browser' ? 'Browser Voice:' : voiceProvider === 'openai' ? 'OpenAI:' : 'macOS Say:'}
</span>{' '}
{voiceProvider === 'browser'
? 'Free, works offline, but has limited mobile support. Best for desktop use.'
: voiceProvider === 'openai'
? 'Higher quality voice synthesis that works reliably on mobile. Requires OpenAI API key.'
: 'Native macOS speech synthesis. Free, fast, and works offline. Desktop only.'}
</p>
</div>
)}
{/* OpenAI unavailable warning */}
{voiceModeEnabled && voiceProvider === 'openai' && !isOpenAIAvailable && (
<div className="flex items-start gap-3 p-3 rounded-lg bg-destructive/10 border border-destructive/20">
<RiAlertLine className="w-5 h-5 text-destructive flex-shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="typography-ui text-destructive font-medium">
OpenAI voice unavailable
</p>
<p className="typography-micro text-destructive/80">
OpenAI voice requires an OpenAI API key to be configured. Please set the OpenAI API key or switch to Browser voice.
</p>
</div>
</div>
)}
{/* OpenAI API Key Input - show when OpenAI is selected or when no server key is configured */}
{voiceModeEnabled && voiceProvider === 'openai' && (
<div className="flex items-start justify-between gap-4">
<div className="space-y-1 flex-1">
<div className="flex items-center gap-2">
<RiKeyLine className="w-4 h-4 text-muted-foreground" />
<span className="typography-ui-label text-foreground">
OpenAI API Key
</span> </span>
</div> <span className={cn("typography-meta ml-2", !isOpenAIAvailable ? "text-[var(--status-error)]/80" : "text-muted-foreground")}>
<p className="typography-meta text-muted-foreground"> {isOpenAIAvailable && !openaiApiKey ? 'Using key from configuration' : !isOpenAIAvailable ? 'OpenAI TTS requires an API key' : 'Provide your OpenAI key'}
{isOpenAIAvailable && !openaiApiKey ? 'Using API key from OpenCode configuration' : 'Enter your OpenAI API key for voice synthesis'} </span>
</p> <div className="relative mt-1.5 max-w-xs">
</div>
<div className="flex items-center gap-2 w-[280px]">
<input <input
type="password" type="password"
value={openaiApiKey} value={openaiApiKey}
onChange={(e) => setOpenaiApiKey(e.target.value)} onChange={(e) => setOpenaiApiKey(e.target.value)}
placeholder="sk-..." placeholder="sk-..."
className="flex-1 px-3 py-2 text-sm bg-background border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-ring" className="w-full h-7 rounded-lg border border-input bg-transparent px-2 typography-ui-label text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/50 focus:border-primary/70"
/> />
{openaiApiKey && ( {openaiApiKey && (
<Button <button
size="icon" type="button"
variant="ghost"
onClick={() => setOpenaiApiKey('')} onClick={() => setOpenaiApiKey('')}
title="Clear API key" className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
> >
<RiCloseLine className="w-4 h-4" /> <RiCloseLine className="w-3.5 h-3.5" />
</Button> </button>
)} )}
</div> </div>
</div> </div>
)} )}
{/* OpenAI Voice Selection */} {/* Voice Selection */}
{voiceModeEnabled && voiceProvider === 'openai' && isOpenAIAvailable && ( <div className="flex items-center gap-8 py-1.5">
<div className="flex items-start justify-between gap-4"> <span className="typography-ui-label text-foreground sm:w-56 shrink-0">Voice</span>
<div className="space-y-1 flex-1"> <div className="flex items-center gap-2 w-fit">
<div className="flex items-center gap-2"> {voiceProvider === 'openai' && isOpenAIAvailable && (
<RiVolumeUpLine className="w-4 h-4 text-muted-foreground" /> <>
<span className="typography-ui-label text-foreground"> <Select value={openaiVoice} onValueChange={setOpenaiVoice}>
OpenAI Voice <SelectTrigger className="w-fit">
</span>
</div>
<p className="typography-meta text-muted-foreground">
Select an OpenAI voice for text-to-speech
</p>
</div>
<div className="flex items-center gap-2">
<Select
value={openaiVoice}
onValueChange={setOpenaiVoice}
>
<SelectTrigger className="w-[160px]" aria-label="Select OpenAI voice">
<SelectValue placeholder="Select voice" /> <SelectValue placeholder="Select voice" />
</SelectTrigger> </SelectTrigger>
<SelectContent className="max-h-[300px]">
{OPENAI_VOICE_OPTIONS.map((voice) => (
<SelectItem key={voice.value} value={voice.value}>
{voice.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="icon"
variant="outline"
onClick={previewOpenAIVoice}
disabled={!isOpenAIAvailable}
title={isOpenAIPreviewPlaying ? 'Stop preview' : 'Preview voice'}
>
{isOpenAIPreviewPlaying ? (
<RiStopLine className="w-4 h-4" />
) : (
<RiPlayLine className="w-4 h-4" />
)}
</Button>
</div>
</div>
)}
{/* macOS Say Voice Selection */}
{voiceModeEnabled && voiceProvider === 'say' && isSayAvailable && sayVoices.length > 0 && (
<div className="flex items-start justify-between gap-4">
<div className="space-y-1 flex-1">
<div className="flex items-center gap-2">
<RiAppleLine className="w-4 h-4 text-muted-foreground" />
<span className="typography-ui-label text-foreground">
macOS Voice
</span>
</div>
<p className="typography-meta text-muted-foreground">
Select a voice installed on your Mac
</p>
</div>
<div className="flex items-center gap-2">
<Select
value={sayVoice}
onValueChange={setSayVoice}
>
<SelectTrigger className="w-[160px]" aria-label="Select macOS voice">
<SelectValue placeholder="Select voice" />
</SelectTrigger>
<SelectContent className="max-h-[300px]">
{sayVoices.map((voice) => (
<SelectItem key={voice.name} value={voice.name}>
{voice.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="icon"
variant="outline"
onClick={previewVoice}
title={isPreviewPlaying ? 'Stop preview' : 'Preview voice'}
>
{isPreviewPlaying ? (
<RiStopLine className="w-4 h-4" />
) : (
<RiPlayLine className="w-4 h-4" />
)}
</Button>
</div>
</div>
)}
{/* Browser Voice Selection */}
{voiceModeEnabled && voiceProvider === 'browser' && filteredBrowserVoices.length > 0 && (
<div className="flex items-start justify-between gap-4">
<div className="space-y-1 flex-1">
<div className="flex items-center gap-2">
<RiChromeLine className="w-4 h-4 text-muted-foreground" />
<span className="typography-ui-label text-foreground">
Browser Voice
</span>
</div>
<p className="typography-meta text-muted-foreground">
Select a voice from your browser
</p>
</div>
<div className="flex items-center gap-2">
<Select
value={browserVoice || '__auto__'}
onValueChange={(value) => setBrowserVoice(value === '__auto__' ? '' : value)}
>
<SelectTrigger className="w-[200px]" aria-label="Select browser voice">
<SelectValue placeholder="Auto (default)" />
</SelectTrigger>
<SelectContent className="max-h-[300px]">
<SelectItem value="__auto__">Auto (default)</SelectItem>
{filteredBrowserVoices.map((voice) => (
<SelectItem key={voice.name} value={voice.name}>
{voice.name} ({voice.lang})
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="icon"
variant="outline"
onClick={previewBrowserVoice}
title={isBrowserPreviewPlaying ? 'Stop preview' : 'Preview voice'}
>
{isBrowserPreviewPlaying ? (
<RiStopLine className="w-4 h-4" />
) : (
<RiPlayLine className="w-4 h-4" />
)}
</Button>
</div>
</div>
)}
{/* Language selection */}
{voiceModeEnabled && (
<div className="flex items-start justify-between gap-4">
<div className="space-y-1 flex-1">
<div className="flex items-center gap-2">
<RiMicLine className="w-4 h-4 text-muted-foreground" />
<span className="typography-ui-label text-foreground">
Language
</span>
</div>
<p className="typography-meta text-muted-foreground">
Language for speech recognition and synthesis
</p>
</div>
<Select
value={language}
onValueChange={setLanguage}
disabled={!isSupported}
>
<SelectTrigger className="w-[180px]" aria-label="Select language">
<SelectValue placeholder="Select language" />
</SelectTrigger>
<SelectContent> <SelectContent>
{LANGUAGE_OPTIONS.map((lang) => ( {OPENAI_VOICE_OPTIONS.map((v) => (
<SelectItem key={lang.value} value={lang.value}> <SelectItem key={v.value} value={v.value}>{v.label}</SelectItem>
{lang.label}
</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> <ButtonSmall variant="ghost" className="h-7 w-7 px-0" onClick={previewOpenAIVoice} title="Preview">
{isOpenAIPreviewPlaying ? <RiStopLine className="w-3.5 h-3.5" /> : <RiPlayLine className="w-3.5 h-3.5" />}
</ButtonSmall>
</>
)} )}
{/* Show TTS buttons on messages */} {voiceProvider === 'say' && isSayAvailable && sayVoices.length > 0 && (
<div className="flex items-start justify-between gap-4">
<div className="space-y-1 flex-1">
<div className="flex items-center gap-2">
<RiVolumeUpLine className="w-4 h-4 text-muted-foreground" />
<span className="typography-ui-label text-foreground">
Message Read Aloud
</span>
</div>
<p className="typography-meta text-muted-foreground">
Show speaker button on AI responses to read them aloud
</p>
</div>
<Switch
checked={showMessageTTSButtons}
onCheckedChange={setShowMessageTTSButtons}
aria-label="Toggle message TTS buttons"
/>
</div>
{/* Summarization Section */}
<div className="pt-4 border-t border-border/40 space-y-4">
<div className="flex items-center gap-2 mb-2">
<RiFileTextLine className="w-4 h-4 text-muted-foreground" />
<span className="typography-ui-label text-foreground font-medium">
Summarization
</span>
</div>
{/* Summarize Message TTS */}
<div className="flex items-start justify-between gap-4">
<div className="space-y-1 flex-1">
<span className="typography-ui-label text-foreground">
Summarize Message Playback
</span>
<p className="typography-meta text-muted-foreground">
Summarize long messages before reading them aloud
</p>
</div>
<Switch
checked={summarizeMessageTTS}
onCheckedChange={setSummarizeMessageTTS}
aria-label="Toggle message TTS summarization"
/>
</div>
{/* Summarize Voice Conversation */}
{voiceModeEnabled && (
<div className="flex items-start justify-between gap-4">
<div className="space-y-1 flex-1">
<span className="typography-ui-label text-foreground">
Summarize Voice Responses
</span>
<p className="typography-meta text-muted-foreground">
Summarize long AI responses during voice conversations
</p>
</div>
<Switch
checked={summarizeVoiceConversation}
onCheckedChange={setSummarizeVoiceConversation}
aria-label="Toggle voice conversation summarization"
/>
</div>
)}
{/* Character Threshold - only show if either summarization is enabled */}
{(summarizeMessageTTS || summarizeVoiceConversation) && (
<> <>
<div className="flex items-start justify-between gap-4"> <Select value={sayVoice} onValueChange={setSayVoice}>
<div className="space-y-1 flex-1"> <SelectTrigger className="w-fit">
<span className="typography-ui-label text-foreground"> <SelectValue placeholder="Select voice" />
Character Threshold </SelectTrigger>
</span> <SelectContent>
<p className="typography-meta text-muted-foreground"> {sayVoices.map((v) => (
Summarize text longer than this ({summarizeCharacterThreshold} chars) <SelectItem key={v.name} value={v.name}>{v.name}</SelectItem>
</p> ))}
</div> </SelectContent>
<div className="w-[180px]"> </Select>
<Slider <ButtonSmall variant="ghost" className="h-7 w-7 px-0" onClick={previewVoice} title="Preview">
value={summarizeCharacterThreshold} {isPreviewPlaying ? <RiStopLine className="w-3.5 h-3.5" /> : <RiPlayLine className="w-3.5 h-3.5" />}
onChange={setSummarizeCharacterThreshold} </ButtonSmall>
min={50} </>
max={2000} )}
step={50}
label="Character threshold"
valueFormatter={(v: number) => `${v}`}
/>
</div>
</div>
<div className="flex items-start justify-between gap-4"> {voiceProvider === 'browser' && filteredBrowserVoices.length > 0 && (
<div className="space-y-1 flex-1"> <>
<span className="typography-ui-label text-foreground"> <Select value={browserVoice || '__auto__'} onValueChange={(value) => setBrowserVoice(value === '__auto__' ? '' : value)}>
Summary Length Limit <SelectTrigger className="w-fit max-w-[200px]">
</span> <SelectValue placeholder="Auto" />
<p className="typography-meta text-muted-foreground"> </SelectTrigger>
Max characters for the summary ({summarizeMaxLength} chars) <SelectContent>
</p> <SelectItem value="__auto__">Auto</SelectItem>
</div> {filteredBrowserVoices.map((v) => (
<div className="w-[180px]"> <SelectItem key={v.name} value={v.name}>{v.name} ({v.lang})</SelectItem>
<Slider ))}
value={summarizeMaxLength} </SelectContent>
onChange={setSummarizeMaxLength} </Select>
min={50} <ButtonSmall variant="ghost" className="h-7 w-7 px-0" onClick={previewBrowserVoice} title="Preview">
max={2000} {isBrowserPreviewPlaying ? <RiStopLine className="w-3.5 h-3.5" /> : <RiPlayLine className="w-3.5 h-3.5" />}
step={50} </ButtonSmall>
label="Summary length limit"
valueFormatter={(v: number) => `${v}`}
/>
</div>
</div>
</> </>
)} )}
</div> </div>
</div>
{/* Speech Rate */} {/* Speech Rate */}
<div className="flex items-start justify-between gap-4"> <div className="flex items-center gap-8 py-1.5">
<div className="space-y-1 flex-1"> <span className="typography-ui-label text-foreground sm:w-56 shrink-0">Speech Rate</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 w-fit">
<RiSpeedLine className="w-4 h-4 text-muted-foreground" /> {!isMobile && <input type="range" min={0.5} max={2} step={0.1} value={speechRate} onChange={(e) => setSpeechRate(Number(e.target.value))} disabled={!isSupported} className={sliderClass} />}
<span className="typography-ui-label text-foreground"> <NumberInput value={speechRate} onValueChange={setSpeechRate} min={0.5} max={2} step={0.1} className="w-16 tabular-nums" />
Speech Rate
</span>
</div>
<p className="typography-meta text-muted-foreground">
Speed of speech (0.5x - 2x)
</p>
</div>
<div className="w-[180px]">
<Slider
value={speechRate}
onChange={setSpeechRate}
min={0.5}
max={2}
step={0.1}
disabled={!isSupported}
label="Speech rate"
valueFormatter={(v: number) => `${v.toFixed(1)}x`}
/>
</div> </div>
</div> </div>
{/* Speech Pitch */} {/* Speech Pitch */}
<div className="flex items-start justify-between gap-4"> <div className="flex items-center gap-8 py-1.5">
<div className="space-y-1 flex-1"> <span className="typography-ui-label text-foreground sm:w-56 shrink-0">Speech Pitch</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 w-fit">
<RiMusicLine className="w-4 h-4 text-muted-foreground" /> {!isMobile && <input type="range" min={0.5} max={2} step={0.1} value={speechPitch} onChange={(e) => setSpeechPitch(Number(e.target.value))} disabled={!isSupported} className={sliderClass} />}
<span className="typography-ui-label text-foreground"> <NumberInput value={speechPitch} onValueChange={setSpeechPitch} min={0.5} max={2} step={0.1} className="w-16 tabular-nums" />
Speech Pitch
</span>
</div>
<p className="typography-meta text-muted-foreground">
Voice pitch (0.5 - 2)
</p>
</div>
<div className="w-[180px]">
<Slider
value={speechPitch}
onChange={setSpeechPitch}
min={0.5}
max={2}
step={0.1}
disabled={!isSupported}
label="Speech pitch"
/>
</div> </div>
</div> </div>
{/* Speech Volume */} {/* Speech Volume */}
<div className="flex items-start justify-between gap-4"> <div className="flex items-center gap-8 py-1.5">
<div className="space-y-1 flex-1"> <span className="typography-ui-label text-foreground sm:w-56 shrink-0">Speech Volume</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 w-fit">
<RiSoundModuleLine className="w-4 h-4 text-muted-foreground" /> {!isMobile && <input type="range" min={0} max={1} step={0.1} value={speechVolume} onChange={(e) => setSpeechVolume(Number(e.target.value))} disabled={!isSupported} className={sliderClass} />}
<span className="typography-ui-label text-foreground"> {isMobile ? (
Speech Volume <NumberInput value={Math.round(speechVolume * 100)} onValueChange={(v) => setSpeechVolume(v / 100)} min={0} max={100} step={10} className="w-16 tabular-nums" />
) : (
<span className="typography-ui-label text-foreground tabular-nums min-w-[3rem] text-right">
{Math.round(speechVolume * 100)}%
</span> </span>
</div> )}
<p className="typography-meta text-muted-foreground">
Voice volume (0 - 100%)
</p>
</div>
<div className="w-[180px]">
<Slider
value={speechVolume}
onChange={setSpeechVolume}
min={0}
max={1}
step={0.1}
disabled={!isSupported}
label="Speech volume"
valueFormatter={(v: number) => `${Math.round(v * 100)}%`}
/>
</div> </div>
</div> </div>
{/* Keyboard shortcut hint */} {/* Language */}
<div className="flex items-center gap-8 py-1.5">
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">Language</span>
<div className="flex items-center gap-2 w-fit">
<Select value={language} onValueChange={setLanguage} disabled={!isSupported}>
<SelectTrigger className="w-fit">
<SelectValue placeholder="Select language" />
</SelectTrigger>
<SelectContent>
{LANGUAGE_OPTIONS.map((lang) => (
<SelectItem key={lang.value} value={lang.value}>{lang.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</>
)}
</section>
</div>
{/* Playback & Summarization */}
<div className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">
Playback & Summarization
</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="Message read aloud button" />
<span className="typography-ui-label text-foreground">Message Read Aloud Button</span>
</div>
<div
className="group flex cursor-pointer items-center gap-2 py-1.5"
role="button"
tabIndex={0}
aria-pressed={summarizeMessageTTS}
onClick={() => setSummarizeMessageTTS(!summarizeMessageTTS)}
onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); setSummarizeMessageTTS(!summarizeMessageTTS); } }}
>
<Checkbox checked={summarizeMessageTTS} onChange={setSummarizeMessageTTS} ariaLabel="Summarize before playback" />
<span className="typography-ui-label text-foreground">Summarize Before Playback</span>
</div>
{voiceModeEnabled && (
<div
className="group flex cursor-pointer items-center gap-2 py-1.5"
role="button"
tabIndex={0}
aria-pressed={summarizeVoiceConversation}
onClick={() => setSummarizeVoiceConversation(!summarizeVoiceConversation)}
onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); setSummarizeVoiceConversation(!summarizeVoiceConversation); } }}
>
<Checkbox checked={summarizeVoiceConversation} onChange={setSummarizeVoiceConversation} ariaLabel="Summarize voice mode responses" />
<span className="typography-ui-label text-foreground">Summarize Voice Mode Responses</span>
</div>
)}
{(summarizeMessageTTS || summarizeVoiceConversation) && (
<>
<div className="flex items-center gap-8 py-1.5">
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">Summarization Threshold</span>
<div className="flex items-center gap-2 w-fit">
{!isMobile && <input type="range" min={50} max={2000} step={50} value={summarizeCharacterThreshold} onChange={(e) => setSummarizeCharacterThreshold(Number(e.target.value))} className={sliderClass} />}
<NumberInput value={summarizeCharacterThreshold} onValueChange={setSummarizeCharacterThreshold} min={50} max={2000} step={50} className="w-16 tabular-nums" />
</div>
</div>
<div className="flex items-center gap-8 py-1.5">
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">Summary Max Length</span>
<div className="flex items-center gap-2 w-fit">
{!isMobile && <input type="range" min={50} max={2000} step={50} value={summarizeMaxLength} onChange={(e) => setSummarizeMaxLength(Number(e.target.value))} className={sliderClass} />}
<NumberInput value={summarizeMaxLength} onValueChange={setSummarizeMaxLength} min={50} max={2000} step={50} className="w-16 tabular-nums" />
</div>
</div>
</>
)}
</section>
{voiceModeEnabled && isSupported && ( {voiceModeEnabled && isSupported && (
<div className="pt-4 border-t border-border/40"> <div className="mt-2 px-2">
<p className="typography-micro text-muted-foreground"> <p className="typography-meta text-muted-foreground">
<span className="font-medium text-foreground">Tip:</span>{' '} Press <kbd className="px-1 py-0.5 mx-0.5 rounded border border-[var(--interactive-border)] bg-background typography-mono text-[10px]">Shift</kbd> + <kbd className="px-1 py-0.5 mx-0.5 rounded border border-[var(--interactive-border)] bg-background typography-mono text-[10px]">Click</kbd> on the mic button to toggle continuous mode
Press <kbd className="px-1.5 py-0.5 rounded bg-muted typography-mono text-xs">Shift</kbd> +{' '}
<kbd className="px-1.5 py-0.5 rounded bg-muted typography-mono text-xs">Click</kbd>{' '}
on the voice button to quickly toggle continuous mode
</p> </p>
</div> </div>
)} )}
</div> </div>
</SettingsSection>
</div>
); );
}; };
@@ -1,21 +1,28 @@
import React from 'react'; import React from 'react';
import { RiAddLine, RiCloseLine, RiDeleteBinLine, RiInformationLine } from '@remixicon/react'; import { RiAddLine, RiCloseLine, RiDeleteBinLine, RiInformationLine } from '@remixicon/react';
import { ButtonSmall } from '@/components/ui/button-small';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useProjectsStore } from '@/stores/useProjectsStore'; import { useProjectsStore } from '@/stores/useProjectsStore';
import { useSessionStore } from '@/stores/useSessionStore'; import { useSessionStore } from '@/stores/useSessionStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useDeviceInfo } from '@/lib/device';
import { checkIsGitRepository } from '@/lib/gitApi'; import { checkIsGitRepository } from '@/lib/gitApi';
import { getWorktreeSetupCommands, saveWorktreeSetupCommands } from '@/lib/openchamberConfig'; import { getWorktreeSetupCommands, saveWorktreeSetupCommands } from '@/lib/openchamberConfig';
import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager'; import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager';
import { sessionEvents } from '@/lib/sessionEvents'; import { sessionEvents } from '@/lib/sessionEvents';
import type { WorktreeMetadata } from '@/types/worktree'; import type { WorktreeMetadata } from '@/types/worktree';
import { formatPathForDisplay } from '@/lib/utils'; import { formatPathForDisplay, cn } from '@/lib/utils';
export const WorktreeSectionContent: React.FC = () => { export interface WorktreeSectionContentProps {
projectRef?: { id: string; path: string } | null;
}
export const WorktreeSectionContent: React.FC<WorktreeSectionContentProps> = ({ projectRef: projectRefProp = null }) => {
const { isMobile } = useDeviceInfo();
const activeProject = useProjectsStore((state) => state.getActiveProject()); const activeProject = useProjectsStore((state) => state.getActiveProject());
const projectPath = activeProject?.path ?? null; const projectPath = projectRefProp?.path ?? activeProject?.path ?? null;
const { sessions, getWorktreeMetadata } = useSessionStore(); const { sessions, getWorktreeMetadata } = useSessionStore();
const homeDirectory = useDirectoryStore((state) => state.homeDirectory); const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
@@ -27,11 +34,14 @@ export const WorktreeSectionContent: React.FC = () => {
const [isLoadingWorktrees, setIsLoadingWorktrees] = React.useState(false); const [isLoadingWorktrees, setIsLoadingWorktrees] = React.useState(false);
const projectRef = React.useMemo(() => { const projectRef = React.useMemo(() => {
if (projectRefProp?.id && projectRefProp?.path) {
return { id: projectRefProp.id, path: projectRefProp.path };
}
if (!activeProject?.id || !projectPath) { if (!activeProject?.id || !projectPath) {
return null; return null;
} }
return { id: activeProject.id, path: projectPath }; return { id: activeProject.id, path: projectPath };
}, [activeProject?.id, projectPath]); }, [activeProject?.id, projectPath, projectRefProp?.id, projectRefProp?.path]);
const refreshWorktrees = React.useCallback(async () => { const refreshWorktrees = React.useCallback(async () => {
if (!projectRef || isGitRepoLocal === false) return; if (!projectRef || isGitRepoLocal === false) return;
@@ -253,60 +263,68 @@ export const WorktreeSectionContent: React.FC = () => {
} }
return ( return (
<div className="space-y-6"> <div className="max-w-[44rem] space-y-5">
{/* Setup commands */} {/* Setup commands */}
<div className="space-y-4"> <div className="space-y-2">
<div className="space-y-1"> <div className="mb-1 px-1">
<h3 className="typography-ui-header font-semibold text-foreground">Setup commands</h3> <div className="flex items-center gap-2">
<p className="typography-meta text-muted-foreground"> <h3 className="typography-ui-header font-normal text-foreground">Setup commands</h3>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
Run automatically inside the new worktree directory when a worktree is created. Run automatically inside the new worktree directory when a worktree is created.
<br />
Use <code className="font-mono text-xs bg-sidebar-accent/50 px-1 rounded">$ROOT_PROJECT_PATH</code> for the project root. Use <code className="font-mono text-xs bg-sidebar-accent/50 px-1 rounded">$ROOT_PROJECT_PATH</code> for the project root.
</p> </TooltipContent>
</Tooltip>
</div>
</div> </div>
{isLoadingCommands ? ( {isLoadingCommands ? (
<p className="typography-meta text-muted-foreground">Loading...</p> <p className="typography-meta text-muted-foreground px-1">Loading...</p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2 px-1">
{setupCommands.map((command, index) => ( {setupCommands.map((command, index) => (
<div key={index} className="flex gap-2"> <div key={index} className="flex w-full gap-2">
<Input <Input
value={command} value={command}
onChange={(e) => handleSetupCommandChange(index, e.target.value)} onChange={(e) => handleSetupCommandChange(index, e.target.value)}
onBlur={handleCommandBlur} onBlur={handleCommandBlur}
placeholder="e.g., bun install" placeholder="e.g., bun install"
className="flex-1 font-mono text-xs" className="h-7 w-[30rem] max-w-full font-mono text-xs"
/> />
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
handleRemoveCommand(index); handleRemoveCommand(index);
}} }}
className="flex-shrink-0 flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50" className="flex-shrink-0 flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label="Remove command" aria-label="Remove command"
> >
<RiCloseLine className="h-4 w-4" /> <RiCloseLine className="h-4 w-4" />
</button> </button>
</div> </div>
))} ))}
<button <ButtonSmall
type="button" type="button"
variant="ghost"
size="xs"
className="!font-normal"
onClick={handleAddCommand} onClick={handleAddCommand}
className="flex items-center gap-1.5 typography-meta text-muted-foreground hover:text-foreground transition-colors"
> >
<RiAddLine className="h-3.5 w-3.5" /> <RiAddLine className="h-3.5 w-3.5" />
Add command Add command
</button> </ButtonSmall>
</div> </div>
)} )}
</div> </div>
{/* Existing worktrees */} {/* Existing worktrees */}
<div className="space-y-4 border-t border-border/40 pt-6"> <div className="space-y-2 border-t border-border/40 pt-4">
<div className="space-y-1"> <div className="mb-1 px-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h3 className="typography-ui-header font-semibold text-foreground">Existing worktrees</h3> <h3 className="typography-ui-header font-normal text-foreground">Existing worktrees</h3>
<Tooltip delayDuration={1000}> <Tooltip delayDuration={1000}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" /> <RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
@@ -316,23 +334,20 @@ export const WorktreeSectionContent: React.FC = () => {
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
<p className="typography-meta text-muted-foreground">
Manage worktrees for this project
</p>
</div> </div>
{isLoadingWorktrees ? ( {isLoadingWorktrees ? (
<p className="typography-meta text-muted-foreground">Loading worktrees...</p> <p className="typography-meta text-muted-foreground px-1">Loading worktrees...</p>
) : availableWorktrees.length === 0 ? ( ) : availableWorktrees.length === 0 ? (
<p className="typography-meta text-muted-foreground/70"> <p className="typography-meta text-muted-foreground/70 px-1">
No worktrees found for this project No worktrees found for this project
</p> </p>
) : ( ) : (
<div className="space-y-1"> <div className="space-y-1 px-1 max-w-[32.5rem]">
{availableWorktrees.map((worktree) => ( {availableWorktrees.map((worktree) => (
<div <div
key={worktree.path} key={worktree.path}
className="flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-sidebar-accent/30 transition-colors group" className="group flex w-full items-center gap-2 py-1.5"
> >
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
@@ -350,7 +365,10 @@ export const WorktreeSectionContent: React.FC = () => {
<button <button
type="button" type="button"
onClick={() => handleDeleteWorktree(worktree)} onClick={() => handleDeleteWorktree(worktree)}
className="flex-shrink-0 flex h-7 w-7 items-center justify-center rounded text-muted-foreground/50 hover:text-destructive hover:bg-destructive/10 opacity-0 group-hover:opacity-100 transition-opacity focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50" className={cn(
"flex-shrink-0 flex h-7 w-7 items-center justify-center rounded text-muted-foreground/50 hover:text-destructive hover:bg-destructive/10 transition-opacity focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50",
isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100"
)}
aria-label={`Delete worktree ${worktree.branch || worktree.label}`} aria-label={`Delete worktree ${worktree.branch || worktree.label}`}
> >
<RiDeleteBinLine className="h-4 w-4" /> <RiDeleteBinLine className="h-4 w-4" />
@@ -0,0 +1,9 @@
export type OpenChamberSection =
| 'visual'
| 'chat'
| 'shortcuts'
| 'sessions'
| 'git'
| 'github'
| 'notifications'
| 'voice';
@@ -0,0 +1,215 @@
import React from 'react';
import { Input } from '@/components/ui/input';
import { ButtonSmall } from '@/components/ui/button-small';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { cn } from '@/lib/utils';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useUIStore } from '@/stores/useUIStore';
import { PROJECT_COLORS, PROJECT_ICONS, PROJECT_COLOR_MAP as COLOR_MAP } from '@/lib/projectMeta';
import { RiCloseLine } from '@remixicon/react';
import { WorktreeSectionContent } from '@/components/sections/openchamber/WorktreeSectionContent';
export const ProjectsPage: React.FC = () => {
const projects = useProjectsStore((state) => state.projects);
const updateProjectMeta = useProjectsStore((state) => state.updateProjectMeta);
const selectedId = useUIStore((state) => state.settingsProjectsSelectedId);
const setSelectedId = useUIStore((state) => state.setSettingsProjectsSelectedId);
const selectedProject = React.useMemo(() => {
if (!selectedId) return null;
return projects.find((p) => p.id === selectedId) ?? null;
}, [projects, selectedId]);
React.useEffect(() => {
if (projects.length === 0) {
setSelectedId(null);
return;
}
if (selectedId && projects.some((p) => p.id === selectedId)) {
return;
}
setSelectedId(projects[0].id);
}, [projects, selectedId, setSelectedId]);
const [name, setName] = React.useState('');
const [icon, setIcon] = React.useState<string | null>(null);
const [color, setColor] = React.useState<string | null>(null);
React.useEffect(() => {
if (!selectedProject) {
setName('');
setIcon(null);
setColor(null);
return;
}
setName(selectedProject.label ?? '');
setIcon(selectedProject.icon ?? null);
setColor(selectedProject.color ?? null);
}, [selectedProject]);
const hasChanges = Boolean(selectedProject) && (
name.trim() !== (selectedProject?.label ?? '').trim()
|| icon !== (selectedProject?.icon ?? null)
|| color !== (selectedProject?.color ?? null)
);
const handleSave = React.useCallback(() => {
if (!selectedProject) return;
updateProjectMeta(selectedProject.id, { label: name.trim(), icon, color });
}, [color, icon, name, selectedProject, updateProjectMeta]);
if (!selectedProject) {
return (
<ScrollableOverlay keyboardAvoid 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">No projects available.</p>
</div>
</ScrollableOverlay>
);
}
const currentColorVar = color ? (COLOR_MAP[color] ?? null) : null;
return (
<ScrollableOverlay keyboardAvoid 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">
{/* Top Header & Actions */}
<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">
{selectedProject.label ?? 'Project Settings'}
</h2>
<p className="typography-meta text-muted-foreground truncate" title={selectedProject.path}>
{selectedProject.path}
</p>
</div>
</div>
{/* Identity Controls */}
<div className="mb-8">
<section className="px-2 pb-2 pt-0 space-y-0.5">
{/* Name */}
<div className="py-1.5">
<div className="flex min-w-0 flex-col">
<span className="typography-ui-label text-foreground">Project Name</span>
</div>
<div className="mt-1.5 flex min-w-0 items-center gap-2">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Project name"
className="h-7 min-w-0 w-full sm:max-w-[19rem]"
/>
</div>
</div>
{/* Color */}
<div className="py-1.5">
<div className="flex min-w-0 flex-col">
<span className="typography-ui-label text-foreground">Accent Color</span>
</div>
<div className="mt-1.5 flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => setColor(null)}
className={cn(
'h-7 w-7 rounded-md border transition-colors flex items-center justify-center',
color === null
? 'border-2 border-foreground bg-[var(--primary-base)]/10'
: 'border-border/40 hover:border-border hover:bg-[var(--surface-muted)]'
)}
title="None"
>
<RiCloseLine className="h-4 w-4 text-muted-foreground" />
</button>
{PROJECT_COLORS.map((c) => (
<button
key={c.key}
type="button"
onClick={() => setColor(c.key)}
className={cn(
'h-7 w-7 rounded-md border transition-colors',
color === c.key
? 'border-2 border-foreground ring-1 ring-[var(--primary-base)]/40'
: 'border-transparent hover:border-border/70'
)}
style={{ backgroundColor: c.cssVar }}
title={c.label}
/>
))}
</div>
</div>
{/* Icon */}
<div className="py-1.5">
<div className="flex min-w-0 flex-col">
<span className="typography-ui-label text-foreground">Project Icon</span>
</div>
<div className="mt-1.5 flex max-w-[22rem] flex-wrap items-center gap-2">
<button
type="button"
onClick={() => setIcon(null)}
className={cn(
'h-7 w-7 rounded-md border transition-colors flex items-center justify-center',
icon === null
? 'border-2 border-foreground bg-[var(--primary-base)]/10'
: 'border-border/40 hover:border-border hover:bg-[var(--surface-muted)]'
)}
title="None"
>
<RiCloseLine className="h-4 w-4 text-muted-foreground" />
</button>
{PROJECT_ICONS.map((i) => {
const IconComponent = i.Icon;
return (
<button
key={i.key}
type="button"
onClick={() => setIcon(i.key)}
className={cn(
'h-7 w-7 rounded-md border transition-colors flex items-center justify-center',
icon === i.key
? 'border-2 border-foreground bg-[var(--primary-base)]/10'
: 'border-transparent hover:border-border hover:bg-[var(--surface-muted)]'
)}
title={i.label}
>
<IconComponent className="w-4 h-4" style={currentColorVar && icon === i.key ? { color: currentColorVar } : undefined} />
</button>
);
})}
</div>
</div>
</section>
<div className="mt-0.5 px-2 py-1">
<ButtonSmall
onClick={handleSave}
disabled={!hasChanges || name.trim().length === 0}
size="xs"
className="!font-normal"
>
Save Changes
</ButtonSmall>
</div>
</div>
{/* Worktree Group */}
<div className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">
Worktree
</h3>
</div>
<section className="px-2 pb-2 pt-0">
<WorktreeSectionContent projectRef={{ id: selectedProject.id, path: selectedProject.path }} />
</section>
</div>
</div>
</ScrollableOverlay>
);
};
@@ -0,0 +1,117 @@
import React from 'react';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useUIStore } from '@/stores/useUIStore';
import { Button } from '@/components/ui/button';
import { SettingsSidebarLayout } from '@/components/sections/shared/SettingsSidebarLayout';
import { SettingsSidebarItem } from '@/components/sections/shared/SettingsSidebarItem';
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP } from '@/lib/projectMeta';
import { cn } from '@/lib/utils';
import { RiAddLine, RiFolderLine } from '@remixicon/react';
import { isDesktopLocalOriginActive, isTauriShell, isVSCodeRuntime } from '@/lib/desktop';
import { sessionEvents } from '@/lib/sessionEvents';
import { toast } from '@/components/ui';
export const ProjectsSidebar: React.FC<{ onItemSelect?: () => void }> = ({ onItemSelect }) => {
const projects = useProjectsStore((state) => state.projects);
const addProject = useProjectsStore((state) => state.addProject);
const selectedId = useUIStore((state) => state.settingsProjectsSelectedId);
const setSelectedId = useUIStore((state) => state.setSettingsProjectsSelectedId);
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
const tauriIpcAvailable = React.useMemo(() => isTauriShell(), []);
const handleAddProject = React.useCallback(() => {
if (!tauriIpcAvailable || !isDesktopLocalOriginActive()) {
sessionEvents.requestDirectoryDialog();
return;
}
import('@/lib/desktop')
.then(({ requestDirectoryAccess }) => requestDirectoryAccess(''))
.then((result) => {
if (result.success && result.path) {
const added = addProject(result.path, { id: result.projectId });
if (!added) {
toast.error('Failed to add project', {
description: 'Please select a valid directory.',
});
return;
}
setSelectedId(added.id);
} else if (result.error && result.error !== 'Directory selection cancelled') {
toast.error('Failed to select directory', {
description: result.error,
});
}
})
.catch((error) => {
console.error('Failed to select directory:', error);
toast.error('Failed to select directory');
});
}, [addProject, setSelectedId, tauriIpcAvailable]);
React.useEffect(() => {
if (projects.length === 0) {
if (selectedId !== null) {
setSelectedId(null);
}
return;
}
if (selectedId && projects.some((p) => p.id === selectedId)) {
return;
}
setSelectedId(projects[0].id);
}, [projects, selectedId, setSelectedId]);
return (
<SettingsSidebarLayout
variant="background"
header={
<div className={cn('border-b px-3', 'pt-4 pb-3')}>
<h2 className="text-base font-semibold text-foreground mb-3">Projects</h2>
<div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground">Total {projects.length}</span>
{!isVSCode && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 -my-1 text-muted-foreground"
onClick={handleAddProject}
aria-label="Add project"
>
<RiAddLine className="size-4" />
</Button>
)}
</div>
</div>
}
>
{projects.map((project) => {
const selected = project.id === selectedId;
const Icon = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
const color = project.color ? (PROJECT_COLOR_MAP[project.color] ?? null) : null;
const icon = Icon
? (
<Icon className={cn('h-4 w-4', selected ? 'text-foreground' : 'text-muted-foreground/70')} style={color ? { color } : undefined} />
)
: (
<RiFolderLine className={cn('h-4 w-4', selected ? 'text-foreground' : 'text-muted-foreground/70')} style={color ? { color } : undefined} />
);
return (
<SettingsSidebarItem
key={project.id}
title={project.label || project.path}
icon={icon}
selected={selected}
onSelect={() => {
setSelectedId(project.id);
onItemSelect?.();
}}
/>
);
})}
</SettingsSidebarLayout>
);
};
@@ -2,7 +2,8 @@ import React from 'react';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { ProviderLogo } from '@/components/ui/ProviderLogo'; import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { useConfigStore } from '@/stores/useConfigStore'; import { useConfigStore } from '@/stores/useConfigStore';
import { Button } from '@/components/ui/button'; import { useUIStore } from '@/stores/useUIStore';
import { ButtonSmall } from '@/components/ui/button-small';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { import {
DropdownMenu, DropdownMenu,
@@ -11,7 +12,8 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { toast } from '@/components/ui'; import { toast } from '@/components/ui';
import { RiStackLine, RiToolsLine, RiBrainAi3Line, RiFileImageLine, RiArrowDownSLine, RiCheckLine, RiSearchLine } from '@remixicon/react'; import { RiStackLine, RiToolsLine, RiBrainAi3Line, RiFileImageLine, RiArrowDownSLine, RiCheckLine, RiSearchLine, RiInformationLine, RiEyeLine, RiEyeOffLine } from '@remixicon/react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore'; import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { copyTextToClipboard } from '@/lib/clipboard'; import { copyTextToClipboard } from '@/lib/clipboard';
@@ -140,6 +142,10 @@ export const ProvidersPage: React.FC = () => {
const selectedProviderId = useConfigStore((state) => state.selectedProviderId); const selectedProviderId = useConfigStore((state) => state.selectedProviderId);
const setSelectedProvider = useConfigStore((state) => state.setSelectedProvider); const setSelectedProvider = useConfigStore((state) => state.setSelectedProvider);
const getModelMetadata = useConfigStore((state) => state.getModelMetadata); const getModelMetadata = useConfigStore((state) => state.getModelMetadata);
const hiddenModels = useUIStore((state) => state.hiddenModels);
const toggleHiddenModel = useUIStore((state) => state.toggleHiddenModel);
const hideAllModels = useUIStore((state) => state.hideAllModels);
const showAllModels = useUIStore((state) => state.showAllModels);
const [authMethodsByProvider, setAuthMethodsByProvider] = React.useState<Record<string, AuthMethod[]>>({}); const [authMethodsByProvider, setAuthMethodsByProvider] = React.useState<Record<string, AuthMethod[]>>({});
const [authLoading, setAuthLoading] = React.useState(false); const [authLoading, setAuthLoading] = React.useState(false);
@@ -243,7 +249,14 @@ export const ProvidersPage: React.FC = () => {
); );
const unconnectedProviders = React.useMemo( const unconnectedProviders = React.useMemo(
() => availableProviders.filter((provider) => !connectedProviderIds.has(provider.id)), () =>
availableProviders
.filter((provider) => !connectedProviderIds.has(provider.id))
.sort((a, b) => {
const labelA = (a.name || a.id).toLowerCase();
const labelB = (b.name || b.id).toLowerCase();
return labelA.localeCompare(labelB);
}),
[availableProviders, connectedProviderIds] [availableProviders, connectedProviderIds]
); );
@@ -252,13 +265,8 @@ export const ProvidersPage: React.FC = () => {
return; return;
} }
if (!candidateProviderId && unconnectedProviders.length > 0) {
setCandidateProviderId(unconnectedProviders[0].id);
return;
}
if (candidateProviderId && !unconnectedProviders.some((provider) => provider.id === candidateProviderId)) { if (candidateProviderId && !unconnectedProviders.some((provider) => provider.id === candidateProviderId)) {
setCandidateProviderId(unconnectedProviders[0]?.id ?? ''); setCandidateProviderId('');
} }
}, [selectedProviderId, candidateProviderId, unconnectedProviders]); }, [selectedProviderId, candidateProviderId, unconnectedProviders]);
@@ -510,29 +518,26 @@ export const ProvidersPage: React.FC = () => {
if (isAddMode) { if (isAddMode) {
return ( return (
<ScrollableOverlay outerClassName="h-full" className="w-full"> <ScrollableOverlay keyboardAvoid outerClassName="h-full" className="w-full">
<div className="mx-auto max-w-3xl space-y-6 p-6"> <div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8">
<div className="space-y-1"> <div className="mb-4">
<h1 className="typography-ui-header font-semibold text-lg">Connect provider</h1> <h1 className="typography-ui-header font-semibold text-foreground">Connect Provider</h1>
<p className="typography-body text-muted-foreground">
Choose a provider to connect and set up its authentication.
</p>
</div> </div>
<div className="space-y-4"> <div className="mb-8">
<div className="space-y-1"> <div className="mb-1 px-1">
<h2 className="typography-ui-header font-semibold text-foreground">Provider</h2> <h2 className="typography-ui-header font-medium text-foreground">Select Provider</h2>
<p className="typography-meta text-muted-foreground/80">
Select a provider that is not connected yet.
</p>
</div> </div>
<section className="px-2 pb-2 pt-0">
<div className="flex flex-wrap items-center gap-2 py-1.5">
<span className="typography-ui-label text-foreground">Provider</span>
{availableLoading ? ( {availableLoading ? (
<p className="typography-meta text-muted-foreground">Loading providers…</p> <p className="typography-meta text-muted-foreground">Loading...</p>
) : availableError ? ( ) : availableError ? (
<p className="typography-meta text-muted-foreground">{availableError}</p> <p className="typography-meta text-muted-foreground">{availableError}</p>
) : unconnectedProviders.length === 0 ? ( ) : unconnectedProviders.length === 0 ? (
<p className="typography-meta text-muted-foreground">All available providers are already connected.</p> <p className="typography-meta text-muted-foreground">All providers connected.</p>
) : ( ) : (
<DropdownMenu open={providerDropdownOpen} onOpenChange={(open) => { <DropdownMenu open={providerDropdownOpen} onOpenChange={(open) => {
setProviderDropdownOpen(open); setProviderDropdownOpen(open);
@@ -542,25 +547,27 @@ export const ProvidersPage: React.FC = () => {
<button <button
type="button" type="button"
className={cn( className={cn(
"flex w-fit items-center justify-between gap-2 rounded-lg border border-input bg-transparent px-3 py-2 typography-ui-label", "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",
"hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
)} )}
> >
<span className={candidateProviderId ? "text-foreground" : "text-muted-foreground"}> <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}
<span className={cn("truncate typography-ui-label font-normal", candidateProviderId ? "text-foreground" : "text-muted-foreground")}>
{candidateProviderId {candidateProviderId
? (unconnectedProviders.find(p => p.id === candidateProviderId)?.name || candidateProviderId) ? (unconnectedProviders.find(p => p.id === candidateProviderId)?.name || candidateProviderId)
: "Select provider"} : "Select provider"}
</span> </span>
<RiArrowDownSLine className="h-4 w-4 text-muted-foreground" /> </span>
<RiArrowDownSLine className="h-4 w-4 flex-shrink-0 text-muted-foreground/50" />
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
align="start" align="start"
className="min-w-[200px] p-0" className="w-[280px] p-0"
onCloseAutoFocus={(e) => e.preventDefault()} onCloseAutoFocus={(e) => e.preventDefault()}
> >
<div <div
className="flex items-center gap-2 border-b px-3 py-2" className="flex items-center gap-2 border-b border-[var(--surface-subtle)] px-3 py-2"
onKeyDown={(e) => e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()}
> >
<RiSearchLine className="h-4 w-4 text-muted-foreground" /> <RiSearchLine className="h-4 w-4 text-muted-foreground" />
@@ -569,7 +576,7 @@ export const ProvidersPage: React.FC = () => {
value={providerSearchQuery} value={providerSearchQuery}
onChange={(e) => setProviderSearchQuery(e.target.value)} onChange={(e) => setProviderSearchQuery(e.target.value)}
onKeyDown={(e) => e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()}
placeholder="Search providers..." placeholder="Search..."
className="flex-1 bg-transparent typography-meta outline-none placeholder:text-muted-foreground" className="flex-1 bg-transparent typography-meta outline-none placeholder:text-muted-foreground"
autoFocus autoFocus
/> />
@@ -593,9 +600,12 @@ export const ProvidersPage: React.FC = () => {
}} }}
className="flex items-center justify-between" className="flex items-center justify-between"
> >
<span>{provider.name || provider.id}</span> <span className="flex items-center gap-2 min-w-0">
<ProviderLogo providerId={provider.id} className="h-4 w-4 flex-shrink-0" />
<span className="truncate">{provider.name || provider.id}</span>
</span>
{candidateProviderId === provider.id && ( {candidateProviderId === provider.id && (
<RiCheckLine className="h-4 w-4 text-primary" /> <RiCheckLine className="h-4 w-4 text-[var(--primary-base)]" />
)} )}
</DropdownMenuItem> </DropdownMenuItem>
)); ));
@@ -605,18 +615,32 @@ export const ProvidersPage: React.FC = () => {
</DropdownMenu> </DropdownMenu>
)} )}
</div> </div>
</section>
</div>
{candidateProviderId && ( {candidateProviderId && (
<div className="space-y-4"> <div className="mb-8">
<h2 className="typography-ui-header font-semibold text-foreground">Authentication</h2> <div className="mb-1 px-1">
<h2 className="typography-ui-header font-medium text-foreground">Authentication</h2>
</div>
{authLoading ? ( {authLoading ? (
<p className="typography-meta text-muted-foreground">Loading authentication methods…</p> <p className="typography-meta text-muted-foreground px-2">Loading authentication methods...</p>
) : ( ) : (
<div className="space-y-4"> <section className="px-2 pb-2 pt-0 space-y-4">
<div className="space-y-2"> <div className="py-1.5">
<label className="typography-ui-label font-medium text-foreground">API key</label> <label className="typography-ui-label text-foreground flex items-center gap-1.5">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center"> API Key
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
Keys are sent directly to OpenCode and never stored by OpenChamber.
</TooltipContent>
</Tooltip>
</label>
<div className="flex flex-col sm:flex-row sm:items-center gap-2 mt-1.5">
<Input <Input
type="password" type="password"
value={apiKeyInputs[candidateProviderId] ?? ''} value={apiKeyInputs[candidateProviderId] ?? ''}
@@ -627,19 +651,17 @@ export const ProvidersPage: React.FC = () => {
})) }))
} }
placeholder="sk-..." placeholder="sk-..."
className="flex-1 font-mono text-xs"
/> />
<Button <ButtonSmall
size="sm" size="xs"
className="!font-normal shrink-0"
onClick={() => handleSaveApiKey(candidateProviderId)} onClick={() => handleSaveApiKey(candidateProviderId)}
disabled={authBusyKey === `api:${candidateProviderId}`} disabled={authBusyKey === `api:${candidateProviderId}`}
className="h-8"
> >
{authBusyKey === `api:${candidateProviderId}` ? 'Saving…' : 'Save key'} {authBusyKey === `api:${candidateProviderId}` ? 'Saving...' : 'Save Key'}
</Button> </ButtonSmall>
</div> </div>
<p className="typography-meta text-muted-foreground">
Keys are sent directly to OpenCode and never stored by OpenChamber.
</p>
</div> </div>
{(() => { {(() => {
@@ -653,7 +675,7 @@ export const ProvidersPage: React.FC = () => {
} }
return ( return (
<div className="space-y-3"> <div className="space-y-4 border-t border-[var(--surface-subtle)] pt-2">
{candidateOAuthMethods.map((method, index) => { {candidateOAuthMethods.map((method, index) => {
const methodLabel = method.label || method.name || `OAuth method ${index + 1}`; const methodLabel = method.label || method.name || `OAuth method ${index + 1}`;
const codeKey = `${candidateProviderId}:${index}`; const codeKey = `${candidateProviderId}:${index}`;
@@ -661,75 +683,52 @@ export const ProvidersPage: React.FC = () => {
pendingOAuth?.providerId === candidateProviderId && pendingOAuth?.methodIndex === index; pendingOAuth?.providerId === candidateProviderId && pendingOAuth?.methodIndex === index;
return ( return (
<div key={`${candidateProviderId}-${methodLabel}`} className="space-y-2"> <div key={`${candidateProviderId}-${methodLabel}`} className="space-y-3">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div> <div>
<div className="typography-ui-label font-medium text-foreground">{methodLabel}</div> <div className="typography-ui-label text-foreground">{methodLabel}</div>
{(method.description || method.help) && ( {(method.description || method.help) && (
<div className="typography-meta text-muted-foreground"> <div className="typography-meta text-muted-foreground">
{String(method.description || method.help)} {String(method.description || method.help)}
</div> </div>
)} )}
</div> </div>
<Button <ButtonSmall
size="sm"
variant="outline" variant="outline"
size="xs"
className="!font-normal"
onClick={() => handleOAuthStart(candidateProviderId, index)} onClick={() => handleOAuthStart(candidateProviderId, index)}
disabled={authBusyKey === `oauth:${candidateProviderId}:${index}`} disabled={authBusyKey === `oauth:${candidateProviderId}:${index}`}
className="h-8"
> >
Connect Connect
</Button> </ButtonSmall>
</div> </div>
{oauthDetails[codeKey]?.instructions && ( {oauthDetails[codeKey]?.instructions && (
<p className="typography-meta text-muted-foreground"> <p className="typography-meta text-[var(--primary-base)] bg-[var(--primary-base)]/10 px-2 py-1.5 rounded">
{oauthDetails[codeKey]?.instructions} {oauthDetails[codeKey]?.instructions}
</p> </p>
)} )}
{oauthDetails[codeKey]?.userCode && ( {oauthDetails[codeKey]?.userCode && (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center"> <div className="flex items-center gap-2 mt-2">
<Input value={oauthDetails[codeKey]?.userCode} readOnly /> <Input value={oauthDetails[codeKey]?.userCode} readOnly className="font-mono text-center tracking-widest" />
<Button <ButtonSmall variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthCode(oauthDetails[codeKey]?.userCode ?? '')}>Copy Code</ButtonSmall>
size="sm"
variant="outline"
onClick={() => handleCopyOAuthCode(oauthDetails[codeKey]?.userCode ?? '')}
className="h-8"
>
Copy code
</Button>
</div> </div>
)} )}
{oauthDetails[codeKey]?.url && ( {oauthDetails[codeKey]?.url && (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center"> <div className="flex items-center gap-2 mt-2">
<Input value={oauthDetails[codeKey]?.url} readOnly /> <Input value={oauthDetails[codeKey]?.url} readOnly className="text-xs text-muted-foreground" />
<div className="flex gap-2"> <div className="flex gap-1 shrink-0">
<Button <ButtonSmall variant="outline" size="xs" className="!font-normal" onClick={() => window.open(oauthDetails[codeKey]?.url, '_blank', 'noopener,noreferrer')}>Open</ButtonSmall>
size="sm" <ButtonSmall variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthLink(oauthDetails[codeKey]?.url ?? '')}>Copy</ButtonSmall>
variant="outline"
className="h-8"
asChild
>
<a href={oauthDetails[codeKey]?.url} target="_blank" rel="noopener noreferrer">
Open link
</a>
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleCopyOAuthLink(oauthDetails[codeKey]?.url ?? '')}
className="h-8"
>
Copy link
</Button>
</div> </div>
</div> </div>
)} )}
{isPending && ( {isPending && (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center"> <div className="flex items-center gap-2 mt-2">
<Input <Input
value={oauthCodes[codeKey] ?? ''} value={oauthCodes[codeKey] ?? ''}
onChange={(event) => onChange={(event) =>
@@ -738,18 +737,17 @@ export const ProvidersPage: React.FC = () => {
[codeKey]: event.target.value, [codeKey]: event.target.value,
})) }))
} }
placeholder="Authorization code (if required)" placeholder="Paste authorization code"
className="font-mono text-xs"
/> />
<Button <ButtonSmall
size="sm" size="xs"
className="!font-normal"
onClick={() => handleOAuthComplete(candidateProviderId, index)} onClick={() => handleOAuthComplete(candidateProviderId, index)}
disabled={authBusyKey === `oauth-complete:${candidateProviderId}:${index}`} disabled={authBusyKey === `oauth-complete:${candidateProviderId}:${index}`}
className="h-8"
> >
{authBusyKey === `oauth-complete:${candidateProviderId}:${index}` {authBusyKey === `oauth-complete:${candidateProviderId}:${index}` ? 'Saving...' : 'Complete'}
? 'Saving…' </ButtonSmall>
: 'Complete'}
</Button>
</div> </div>
)} )}
</div> </div>
@@ -758,7 +756,7 @@ export const ProvidersPage: React.FC = () => {
</div> </div>
); );
})()} })()}
</div> </section>
)} )}
</div> </div>
)} )}
@@ -780,7 +778,6 @@ export const ProvidersPage: React.FC = () => {
} }
const providerModels = Array.isArray(selectedProvider.models) ? selectedProvider.models : []; const providerModels = Array.isArray(selectedProvider.models) ? selectedProvider.models : [];
const providerAuthMethods = authMethodsByProvider[selectedProvider.id] ?? []; const providerAuthMethods = authMethodsByProvider[selectedProvider.id] ?? [];
const oauthAuthMethods = providerAuthMethods.filter((method) => normalizeAuthType(method) === 'oauth'); const oauthAuthMethods = providerAuthMethods.filter((method) => normalizeAuthType(method) === 'oauth');
@@ -794,45 +791,59 @@ export const ProvidersPage: React.FC = () => {
return ( return (
<ScrollableOverlay keyboardAvoid outerClassName="h-full" className="w-full"> <ScrollableOverlay keyboardAvoid outerClassName="h-full" className="w-full">
<div className="mx-auto max-w-3xl space-y-6 p-6"> <div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8">
<div className="space-y-1">
<div className="flex items-center gap-2"> {/* Header */}
<ProviderLogo providerId={selectedProvider.id} className="h-5 w-5" /> <div className="mb-4 flex items-center gap-3">
<h1 className="typography-ui-header font-semibold text-lg"> <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} {selectedProvider.name || selectedProvider.id}
</h1> </h2>
</div> <p className="typography-meta text-muted-foreground truncate">
<p className="typography-body text-muted-foreground"> <span className="font-mono">{selectedProvider.id}</span>
Provider ID: {selectedProvider.id}
</p> </p>
</div> </div>
</div>
<div className="space-y-4"> {/* Authentication */}
<div className="flex items-center justify-between gap-2"> <div className="mb-8">
<h2 className="typography-ui-header font-semibold text-foreground">Authentication</h2> <div className="mb-1 px-1 flex items-center justify-between gap-2">
{selectedProviderId !== ADD_PROVIDER_ID && ( <h3 className="typography-ui-header font-medium text-foreground">Authentication</h3>
<Button <ButtonSmall
size="sm"
variant="outline" variant="outline"
size="xs"
className="!font-normal"
onClick={() => setShowAuthPanel((prev) => !prev)} onClick={() => setShowAuthPanel((prev) => !prev)}
className="h-8"
> >
{showAuthPanel ? 'Hide' : 'Reconnect'} {showAuthPanel ? 'Hide' : 'Reconnect'}
</Button> </ButtonSmall>
)}
</div> </div>
{!showAuthPanel && selectedProviderId !== ADD_PROVIDER_ID ? ( <section className="px-2 pb-2 pt-0">
<p className="typography-meta text-muted-foreground"> {!showAuthPanel ? (
Connected. Use Reconnect to update credentials. <div className="flex items-center gap-1.5 py-1.5">
</p> <RiCheckLine className="w-4 h-4 text-[var(--status-success)] shrink-0" />
<span className="typography-ui-label text-foreground">Connected</span>
<span className="typography-meta text-muted-foreground ml-1">· Use Reconnect to update credentials</span>
</div>
) : authLoading ? ( ) : authLoading ? (
<p className="typography-meta text-muted-foreground">Loading authentication methods…</p> <div className="py-1.5 typography-meta text-muted-foreground">Loading authentication methods...</div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="py-1.5">
<label className="typography-ui-label font-medium text-foreground">API key</label> <label className="typography-ui-label text-foreground flex items-center gap-1.5">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center"> API Key
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
Keys are sent directly to OpenCode and never stored by OpenChamber.
</TooltipContent>
</Tooltip>
</label>
<div className="flex flex-col sm:flex-row sm:items-center gap-2 mt-1.5">
<Input <Input
type="password" type="password"
value={apiKeyInputs[selectedProvider.id] ?? ''} value={apiKeyInputs[selectedProvider.id] ?? ''}
@@ -843,23 +854,21 @@ export const ProvidersPage: React.FC = () => {
})) }))
} }
placeholder="sk-..." placeholder="sk-..."
className="flex-1 font-mono text-xs"
/> />
<Button <ButtonSmall
size="sm" size="xs"
className="!font-normal shrink-0"
onClick={() => handleSaveApiKey(selectedProvider.id)} onClick={() => handleSaveApiKey(selectedProvider.id)}
disabled={authBusyKey === `api:${selectedProvider.id}`} disabled={authBusyKey === `api:${selectedProvider.id}`}
className="h-8"
> >
{authBusyKey === `api:${selectedProvider.id}` ? 'Saving…' : 'Save key'} {authBusyKey === `api:${selectedProvider.id}` ? 'Saving...' : 'Save Key'}
</Button> </ButtonSmall>
</div> </div>
<p className="typography-meta text-muted-foreground">
Keys are sent directly to OpenCode and never stored by OpenChamber.
</p>
</div> </div>
{oauthAuthMethods.length > 0 && ( {oauthAuthMethods.length > 0 && (
<div className="space-y-3"> <div className="space-y-4 border-t border-[var(--surface-subtle)] pt-2">
{oauthAuthMethods.map((method, index) => { {oauthAuthMethods.map((method, index) => {
const methodLabel = method.label || method.name || `OAuth method ${index + 1}`; const methodLabel = method.label || method.name || `OAuth method ${index + 1}`;
const codeKey = `${selectedProvider.id}:${index}`; const codeKey = `${selectedProvider.id}:${index}`;
@@ -867,76 +876,52 @@ export const ProvidersPage: React.FC = () => {
pendingOAuth?.providerId === selectedProvider.id && pendingOAuth?.methodIndex === index; pendingOAuth?.providerId === selectedProvider.id && pendingOAuth?.methodIndex === index;
return ( return (
<div key={`${selectedProvider.id}-${methodLabel}`} className="space-y-2"> <div key={`${selectedProvider.id}-${methodLabel}`} className="space-y-3">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div> <div>
<div className="typography-ui-label font-medium text-foreground">{methodLabel}</div> <div className="typography-ui-label text-foreground">{methodLabel}</div>
{(method.description || method.help) && ( {(method.description || method.help) && (
<div className="typography-meta text-muted-foreground"> <div className="typography-meta text-muted-foreground">
{String(method.description || method.help)} {String(method.description || method.help)}
</div> </div>
)} )}
</div> </div>
<Button <ButtonSmall
size="sm"
variant="outline" variant="outline"
size="xs"
className="!font-normal"
onClick={() => handleOAuthStart(selectedProvider.id, index)} onClick={() => handleOAuthStart(selectedProvider.id, index)}
disabled={authBusyKey === `oauth:${selectedProvider.id}:${index}`} disabled={authBusyKey === `oauth:${selectedProvider.id}:${index}`}
className="h-8"
> >
Connect Connect
</Button> </ButtonSmall>
</div> </div>
{oauthDetails[codeKey]?.instructions && ( {oauthDetails[codeKey]?.instructions && (
<p className="typography-meta text-muted-foreground"> <p className="typography-meta text-[var(--primary-base)] bg-[var(--primary-base)]/10 px-2 py-1.5 rounded">
{oauthDetails[codeKey]?.instructions} {oauthDetails[codeKey]?.instructions}
</p> </p>
)} )}
{oauthDetails[codeKey]?.userCode && ( {oauthDetails[codeKey]?.userCode && (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center"> <div className="flex items-center gap-2 mt-2">
<Input value={oauthDetails[codeKey]?.userCode} readOnly /> <Input value={oauthDetails[codeKey]?.userCode} readOnly className="font-mono text-center tracking-widest" />
<Button <ButtonSmall variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthCode(oauthDetails[codeKey]?.userCode ?? '')}>Copy Code</ButtonSmall>
size="sm"
variant="outline"
onClick={() => handleCopyOAuthCode(oauthDetails[codeKey]?.userCode ?? '')}
className="h-8"
>
Copy code
</Button>
</div> </div>
)} )}
{oauthDetails[codeKey]?.url && ( {oauthDetails[codeKey]?.url && (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center"> <div className="flex items-center gap-2 mt-2">
<Input value={oauthDetails[codeKey]?.url} readOnly /> <Input value={oauthDetails[codeKey]?.url} readOnly className="text-xs text-muted-foreground" />
<div className="flex gap-2"> <div className="flex gap-1 shrink-0">
<Button <ButtonSmall variant="outline" size="xs" className="!font-normal" onClick={() => window.open(oauthDetails[codeKey]?.url, '_blank', 'noopener,noreferrer')}>Open</ButtonSmall>
size="sm" <ButtonSmall variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthLink(oauthDetails[codeKey]?.url ?? '')}>Copy</ButtonSmall>
variant="outline"
className="h-8"
asChild
>
<a href={oauthDetails[codeKey]?.url} target="_blank" rel="noopener noreferrer">
Open link
</a>
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleCopyOAuthLink(oauthDetails[codeKey]?.url ?? '')}
className="h-8"
>
Copy link
</Button>
</div> </div>
</div> </div>
)} )}
{isPending && ( {isPending && (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center"> <div className="flex items-center gap-2 mt-2">
<Input <Input
value={oauthCodes[codeKey] ?? ''} value={oauthCodes[codeKey] ?? ''}
onChange={(event) => onChange={(event) =>
@@ -946,17 +931,16 @@ export const ProvidersPage: React.FC = () => {
})) }))
} }
placeholder="Paste authorization code" placeholder="Paste authorization code"
className="font-mono text-xs"
/> />
<Button <ButtonSmall
size="sm" size="xs"
className="!font-normal"
onClick={() => handleOAuthComplete(selectedProvider.id, index)} onClick={() => handleOAuthComplete(selectedProvider.id, index)}
disabled={authBusyKey === `oauth-complete:${selectedProvider.id}:${index}`} disabled={authBusyKey === `oauth-complete:${selectedProvider.id}:${index}`}
className="h-8"
> >
{authBusyKey === `oauth-complete:${selectedProvider.id}:${index}` {authBusyKey === `oauth-complete:${selectedProvider.id}:${index}` ? 'Saving...' : 'Complete'}
? 'Saving…' </ButtonSmall>
: 'Complete'}
</Button>
</div> </div>
)} )}
</div> </div>
@@ -966,53 +950,103 @@ export const ProvidersPage: React.FC = () => {
)} )}
</div> </div>
)} )}
</section>
</div> </div>
<div className="space-y-3"> {/* Connection Details */}
<h2 className="typography-ui-header font-semibold text-foreground">Connection</h2> <div className="mb-8">
{selectedSources && (selectedSources.auth.exists || selectedSources.user.exists || selectedSources.project.exists || selectedSources.custom?.exists) && ( <div className="mb-1 px-1">
<div className="typography-meta text-muted-foreground"> <h3 className="typography-ui-header font-medium text-foreground">Connection Details</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">
<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">
Configured in: {[ Configured in: {[
selectedSources.auth.exists ? 'auth credentials' : null, selectedSources.auth.exists ? 'auth credentials' : null,
selectedSources.user.exists ? 'user config' : null, selectedSources.user.exists ? 'user config' : null,
selectedSources.project.exists ? 'project config' : null, selectedSources.project.exists ? 'project config' : null,
selectedSources.custom?.exists ? 'custom config' : null, selectedSources.custom?.exists ? 'custom config' : null,
].filter(Boolean).join(', ')} ].filter(Boolean).join(', ')}
</div> </span>
) : (
<span className="typography-meta text-muted-foreground">No active configuration source</span>
)} )}
<Button </div>
size="sm"
variant="outline" <ButtonSmall
variant="ghost"
size="xs"
className="!font-normal text-[var(--status-error)] hover:text-[var(--status-error)]"
onClick={() => handleDisconnectProvider(selectedProvider.id)} onClick={() => handleDisconnectProvider(selectedProvider.id)}
disabled={authBusyKey === `disconnect:${selectedProvider.id}`} disabled={authBusyKey === `disconnect:${selectedProvider.id}`}
className="h-8 text-destructive hover:text-destructive hover:bg-destructive/10"
> >
{authBusyKey === `disconnect:${selectedProvider.id}` ? 'Disconnecting…' : 'Disconnect provider'} {authBusyKey === `disconnect:${selectedProvider.id}` ? 'Disconnecting...' : 'Disconnect'}
</Button> </ButtonSmall>
</div>
</section>
</div> </div>
<div className="space-y-4"> {/* Models */}
<div className="space-y-1"> <div className="mb-8">
<h2 className="typography-ui-header font-semibold text-foreground">Models</h2> <div className="mb-1 px-1 flex items-center justify-between gap-2">
<p className="typography-meta text-muted-foreground/80"> <h3 className="typography-ui-header font-medium text-foreground">
Browse and filter models exposed by this provider. Available Models
</p> {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">
<ButtonSmall
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);
}}
>
Hide all
</ButtonSmall>
<ButtonSmall
variant="outline"
size="xs"
className="!font-normal"
onClick={() => showAllModels(selectedProvider.id)}
>
Show all
</ButtonSmall>
</div>
</div> </div>
<section className="px-2 pb-2 pt-0">
<div className="relative mb-2">
<RiSearchLine className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input <Input
value={modelQuery} value={modelQuery}
onChange={(event) => setModelQuery(event.target.value)} onChange={(event) => setModelQuery(event.target.value)}
placeholder="Filter models..." placeholder="Filter models..."
className="h-7 pl-8 w-full"
/> />
</div>
<div className="border-t border-border/40">
{filteredModels.length === 0 ? ( {filteredModels.length === 0 ? (
<p className="typography-meta text-muted-foreground py-3 px-2">No models match this filter.</p> <p className="typography-meta text-muted-foreground py-4 text-center">No models match this filter.</p>
) : ( ) : (
filteredModels.map((model) => { <div className="divide-y divide-[var(--surface-subtle)]">
{filteredModels.map((model) => {
const modelId = typeof model?.id === 'string' ? model.id : ''; const modelId = typeof model?.id === 'string' ? model.id : '';
const modelName = typeof model?.name === 'string' ? model.name : modelId; const modelName = typeof model?.name === 'string' ? model.name : modelId;
const metadata = modelId ? getModelMetadata(selectedProvider.id, modelId) as ModelMetadata | undefined : undefined; const metadata = modelId ? getModelMetadata(selectedProvider.id, modelId) as ModelMetadata | undefined : undefined;
const isHidden = hiddenModels.some(
(item) => item.providerID === selectedProvider.id && item.modelID === modelId
);
const contextTokens = formatTokens(metadata?.limit?.context); const contextTokens = formatTokens(metadata?.limit?.context);
const outputTokens = formatTokens(metadata?.limit?.output); const outputTokens = formatTokens(metadata?.limit?.output);
@@ -1023,15 +1057,19 @@ export const ProvidersPage: React.FC = () => {
if (metadata?.attachment) capabilityIcons.push({ key: 'image', icon: RiFileImageLine, label: 'Image input' }); if (metadata?.attachment) capabilityIcons.push({ key: 'image', icon: RiFileImageLine, label: 'Image input' });
return ( return (
<div key={modelId} className="py-1.5">
<div <div
key={modelId} className={cn(
className="flex items-center gap-2 px-2 py-1.5 border-b border-border/40" "flex items-center gap-3",
isHidden && 'opacity-50',
)}
> >
<span className="typography-meta font-medium text-foreground truncate flex-1 min-w-0"> <span className="typography-meta font-medium text-foreground truncate flex-1 min-w-0">
{modelName} {modelName}
</span> </span>
<div className="flex items-center gap-2 flex-shrink-0">
{(contextTokens || outputTokens) && ( {(contextTokens || outputTokens) && (
<span className="typography-micro text-muted-foreground flex-shrink-0"> <span className="typography-micro text-muted-foreground flex-shrink-0 bg-[var(--surface-muted)] px-1.5 py-0.5 rounded">
{contextTokens ? `${contextTokens} ctx` : ''} {contextTokens ? `${contextTokens} ctx` : ''}
{contextTokens && outputTokens ? ' · ' : ''} {contextTokens && outputTokens ? ' · ' : ''}
{outputTokens ? `${outputTokens} out` : ''} {outputTokens ? `${outputTokens} out` : ''}
@@ -1042,7 +1080,7 @@ export const ProvidersPage: React.FC = () => {
{capabilityIcons.map(({ key, icon: Icon, label }) => ( {capabilityIcons.map(({ key, icon: Icon, label }) => (
<span <span
key={key} key={key}
className="flex h-4 w-4 items-center justify-center text-muted-foreground" className="flex h-5 w-5 rounded items-center justify-center text-muted-foreground bg-[var(--surface-muted)]"
title={label} title={label}
aria-label={label} aria-label={label}
> >
@@ -1051,11 +1089,23 @@ export const ProvidersPage: React.FC = () => {
))} ))}
</div> </div>
)} )}
<button
type="button"
onClick={() => toggleHiddenModel(selectedProvider.id, modelId)}
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-[var(--interactive-hover)]/50"
title={isHidden ? 'Show model in selectors' : 'Hide model from selectors'}
aria-label={isHidden ? 'Show model' : 'Hide model'}
>
{isHidden ? <RiEyeOffLine className="h-3.5 w-3.5" /> : <RiEyeLine className="h-3.5 w-3.5" />}
</button>
</div>
</div>
</div> </div>
); );
}) })}
)}
</div> </div>
)}
</section>
</div> </div>
</div> </div>
</ScrollableOverlay> </ScrollableOverlay>
@@ -1,15 +1,36 @@
import React from 'react'; import React from 'react';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { ProviderLogo } from '@/components/ui/ProviderLogo'; import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { Button } from '@/components/ui/button'; import { ButtonSmall } from '@/components/ui/button-small';
import { useConfigStore } from '@/stores/useConfigStore'; import { useConfigStore } from '@/stores/useConfigStore';
import { useDeviceInfo } from '@/lib/device'; import { useProjectsStore } from '@/stores/useProjectsStore';
import { isVSCodeRuntime } from '@/lib/desktop';
import { RiAddLine, RiStackLine } from '@remixicon/react'; import { RiAddLine, RiStackLine } from '@remixicon/react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { SettingsProjectSelector } from '@/components/sections/shared/SettingsProjectSelector';
import { opencodeClient } from '@/lib/opencode/client';
const ADD_PROVIDER_ID = '__add_provider__'; const ADD_PROVIDER_ID = '__add_provider__';
interface ProviderSourceInfo {
exists: boolean;
path?: string | null;
}
interface ProviderSources {
auth: ProviderSourceInfo;
user: ProviderSourceInfo;
project: ProviderSourceInfo;
custom?: ProviderSourceInfo;
}
const getCurrentDirectory = (): string | null => {
const dir = opencodeClient.getDirectory();
if (typeof dir === 'string' && dir.trim().length > 0) {
return dir.trim();
}
return null;
};
interface ProvidersSidebarProps { interface ProvidersSidebarProps {
onItemSelect?: () => void; onItemSelect?: () => void;
} }
@@ -18,22 +39,80 @@ export const ProvidersSidebar: React.FC<ProvidersSidebarProps> = ({ onItemSelect
const providers = useConfigStore((state) => state.providers); const providers = useConfigStore((state) => state.providers);
const selectedProviderId = useConfigStore((state) => state.selectedProviderId); const selectedProviderId = useConfigStore((state) => state.selectedProviderId);
const setSelectedProvider = useConfigStore((state) => state.setSelectedProvider); const setSelectedProvider = useConfigStore((state) => state.setSelectedProvider);
const { isMobile } = useDeviceInfo(); const activeProjectId = useProjectsStore((s) => s.activeProjectId);
const [sourcesByProvider, setSourcesByProvider] = React.useState<Record<string, ProviderSources>>({});
const directory = React.useMemo(() => {
// tie refresh to active project changes (directory is stored in the client)
void activeProjectId;
return getCurrentDirectory();
}, [activeProjectId]);
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []); React.useEffect(() => {
if (providers.length === 0) {
setSourcesByProvider({});
return;
}
const bgClass = isVSCode ? 'bg-background' : 'bg-sidebar'; let cancelled = false;
const loadAllSources = async () => {
const tasks = providers.map(async (provider) => {
try {
const query = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await fetch(`/api/provider/${encodeURIComponent(provider.id)}/source${query}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
if (!response.ok) {
return;
}
const payload = await response.json().catch(() => null);
const sources = (payload?.sources ?? payload?.data?.sources) as ProviderSources | undefined;
if (!sources) {
return;
}
if (cancelled) {
return;
}
setSourcesByProvider((prev) => ({
...prev,
[provider.id]: sources,
}));
} catch {
// ignore
}
});
await Promise.all(tasks);
};
void loadAllSources();
return () => {
cancelled = true;
};
}, [directory, providers]);
const bgClass = 'bg-background';
const projectProviders = React.useMemo(() => {
return providers.filter((p) => Boolean(sourcesByProvider[p.id]?.project?.exists));
}, [providers, sourcesByProvider]);
const userProviders = React.useMemo(() => {
return providers.filter((p) => !sourcesByProvider[p.id]?.project?.exists);
}, [providers, sourcesByProvider]);
return ( return (
<div className={cn('flex h-full flex-col', bgClass)}> <div className={cn('flex h-full flex-col', bgClass)}>
<div className={cn('border-b px-3', isMobile ? 'mt-2 py-3' : 'py-3')}> <div className="border-b px-3 pt-4 pb-3">
<h2 className="text-base font-semibold text-foreground mb-3">Providers</h2>
<SettingsProjectSelector className="mb-3" />
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground">Total {providers.length}</span> <span className="typography-meta text-muted-foreground">Total {providers.length}</span>
<Button <ButtonSmall
type="button"
variant="ghost" variant="ghost"
size="icon" className="h-7 w-7 px-0 -my-1 text-muted-foreground"
className="h-7 w-7 -my-1 text-muted-foreground"
onClick={() => { onClick={() => {
setSelectedProvider(ADD_PROVIDER_ID); setSelectedProvider(ADD_PROVIDER_ID);
onItemSelect?.(); onItemSelect?.();
@@ -41,8 +120,8 @@ export const ProvidersSidebar: React.FC<ProvidersSidebarProps> = ({ onItemSelect
aria-label="Connect provider" aria-label="Connect provider"
title="Connect provider" title="Connect provider"
> >
<RiAddLine className="size-4" /> <RiAddLine className="h-3.5 w-3.5" />
</Button> </ButtonSmall>
</div> </div>
</div> </div>
@@ -54,7 +133,56 @@ export const ProvidersSidebar: React.FC<ProvidersSidebarProps> = ({ onItemSelect
<p className="typography-meta mt-1 opacity-75">Check your OpenCode configuration</p> <p className="typography-meta mt-1 opacity-75">Check your OpenCode configuration</p>
</div> </div>
) : ( ) : (
providers.map((provider) => { <>
{userProviders.length > 0 && (
<>
<div className="px-2 pb-1.5 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
User Providers
</div>
{userProviders.map((provider) => (
<ProviderListItem
key={provider.id}
provider={provider}
selectedProviderId={selectedProviderId}
onSelect={() => {
setSelectedProvider(provider.id);
onItemSelect?.();
}}
/>
))}
</>
)}
{projectProviders.length > 0 && (
<>
<div className={cn('px-2 pb-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground', userProviders.length > 0 ? 'pt-3' : 'pt-2')}>
Project Providers
</div>
{projectProviders.map((provider) => (
<ProviderListItem
key={provider.id}
provider={provider}
selectedProviderId={selectedProviderId}
onSelect={() => {
setSelectedProvider(provider.id);
onItemSelect?.();
}}
/>
))}
</>
)}
</>
)}
</ScrollableOverlay>
</div>
);
};
const ProviderListItem: React.FC<{
provider: { id: string; name?: string; models?: unknown[] };
selectedProviderId: string;
onSelect: () => void;
}> = ({ provider, selectedProviderId, onSelect }) => {
const modelCount = Array.isArray(provider.models) ? provider.models.length : 0; const modelCount = Array.isArray(provider.models) ? provider.models.length : 0;
const isSelected = provider.id === selectedProviderId; const isSelected = provider.id === selectedProviderId;
@@ -68,10 +196,7 @@ export const ProvidersSidebar: React.FC<ProvidersSidebarProps> = ({ onItemSelect
> >
<button <button
type="button" type="button"
onClick={() => { onClick={onSelect}
setSelectedProvider(provider.id);
onItemSelect?.();
}}
className="flex min-w-0 flex-1 items-center gap-2 rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50" className="flex min-w-0 flex-1 items-center gap-2 rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
tabIndex={0} tabIndex={0}
> >
@@ -85,9 +210,4 @@ export const ProvidersSidebar: React.FC<ProvidersSidebarProps> = ({ onItemSelect
</button> </button>
</div> </div>
); );
})
)}
</ScrollableOverlay>
</div>
);
}; };
@@ -37,7 +37,7 @@ export const SettingsPageLayout: React.FC<SettingsPageLayoutProps> = ({
> >
<div <div
className={cn( className={cn(
'mx-auto max-w-3xl space-y-6 p-3 sm:p-6', 'mx-auto max-w-3xl space-y-6 p-3 sm:p-6 sm:pt-8',
className className
)} )}
> >
@@ -0,0 +1,89 @@
import React from 'react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { RiArrowDownSLine, RiFolderLine } from '@remixicon/react';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { isVSCodeRuntime } from '@/lib/desktop';
import { cn } from '@/lib/utils';
const formatProjectLabel = (label: string): string => {
return label.replace(/[-_]/g, ' ').replace(/\b\w/g, (char) => char.toUpperCase());
};
export const SettingsProjectSelector: React.FC<{ className?: string }> = ({ className }) => {
const projects = useProjectsStore((state) => state.projects);
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
const setActiveProject = useProjectsStore((state) => state.setActiveProject);
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
const sortedProjects = React.useMemo(() => {
return [...projects].sort((a, b) => (a.label || a.path).localeCompare(b.label || b.path));
}, [projects]);
const activeProject = React.useMemo(() => {
if (sortedProjects.length === 0) {
return null;
}
return sortedProjects.find((p) => p.id === activeProjectId) ?? sortedProjects[0];
}, [activeProjectId, sortedProjects]);
if (isVSCode || sortedProjects.length === 0) {
return null;
}
const rawLabel = activeProject?.label && activeProject.label.trim().length > 0
? activeProject.label
: (activeProject?.path.split('/').filter(Boolean).pop() || activeProject?.path || 'Project');
const label = formatProjectLabel(rawLabel);
return (
<div className={cn(className)}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label="Switch project"
title="Switch project"
className={cn(
// Mirror Input sizing so headers align visually.
'text-foreground border border-border/80 appearance-none flex h-8 w-full min-w-0 rounded-lg bg-transparent px-3 py-1 outline-none',
'hover:border-input focus-visible:ring-1 focus-visible:ring-primary/50 focus-visible:border-primary/70',
'flex items-center gap-1.5 text-left'
)}
>
<RiFolderLine className="h-4 w-4 opacity-70" />
<span className="min-w-0 flex-1 truncate typography-ui-label font-medium">{label}</span>
<RiArrowDownSLine className="size-4 opacity-50" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-auto">
<DropdownMenuRadioGroup
value={activeProject?.id ?? ''}
onValueChange={(value) => {
if (!value) return;
setActiveProject(value);
}}
>
{sortedProjects.map((project) => {
const raw = project.label?.trim()
? project.label.trim()
: (project.path.split('/').filter(Boolean).pop() || project.path);
const itemLabel = formatProjectLabel(raw);
return (
<DropdownMenuRadioItem key={project.id} value={project.id}>
<span className="min-w-0 truncate typography-ui">{itemLabel}</span>
</DropdownMenuRadioItem>
);
})}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
};
@@ -68,7 +68,7 @@ export const SettingsSidebarItem: React.FC<SettingsSidebarItemProps> = ({
return ( return (
<div <div
className={cn( className={cn(
'group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200', 'group relative flex items-center rounded-md px-1.5 py-0.5 transition-all duration-200',
selected selected
? 'bg-interactive-selection' ? 'bg-interactive-selection'
: 'hover:bg-interactive-hover', : 'hover:bg-interactive-hover',
@@ -12,6 +12,8 @@ interface SettingsSidebarLayoutProps {
children: React.ReactNode; children: React.ReactNode;
/** Additional className for the outer container */ /** Additional className for the outer container */
className?: string; className?: string;
/** Background style for the sidebar container */
variant?: 'sidebar' | 'background';
} }
/** /**
@@ -32,13 +34,50 @@ export const SettingsSidebarLayout: React.FC<SettingsSidebarLayoutProps> = ({
footer, footer,
children, children,
className, className,
variant = 'sidebar',
}) => { }) => {
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []); const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
// Desktop app: transparent for blur effect const scrollRef = React.useRef<HTMLElement | null>(null);
// VS Code: bg-background (same as page content) const [showTopShadow, setShowTopShadow] = React.useState(false);
// Web/mobile: bg-sidebar const [showBottomShadow, setShowBottomShadow] = React.useState(false);
const bgClass = isVSCode ? 'bg-background' : 'bg-sidebar';
const bgClass = variant === 'background'
? 'bg-background'
: (isVSCode ? 'bg-background' : 'bg-sidebar');
const bgVar = bgClass === 'bg-background'
? 'var(--surface-background)'
: 'var(--surface-muted)';
const updateScrollShadows = React.useCallback(() => {
const el = scrollRef.current;
if (!el) {
setShowTopShadow(false);
setShowBottomShadow(false);
return;
}
const canScroll = el.scrollHeight > el.clientHeight + 1;
if (!canScroll) {
setShowTopShadow(false);
setShowBottomShadow(false);
return;
}
setShowTopShadow(el.scrollTop > 1);
setShowBottomShadow(el.scrollTop + el.clientHeight < el.scrollHeight - 1);
}, []);
React.useEffect(() => {
updateScrollShadows();
}, [children, updateScrollShadows]);
React.useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const onScroll = () => updateScrollShadows();
el.addEventListener('scroll', onScroll, { passive: true });
return () => el.removeEventListener('scroll', onScroll);
}, [updateScrollShadows]);
return ( return (
<div <div
@@ -50,13 +89,29 @@ export const SettingsSidebarLayout: React.FC<SettingsSidebarLayoutProps> = ({
> >
{header} {header}
<div className="relative flex flex-1 min-h-0 flex-col">
<ScrollableOverlay <ScrollableOverlay
ref={scrollRef as unknown as React.Ref<HTMLElement>}
outerClassName="flex-1 min-h-0" outerClassName="flex-1 min-h-0"
className="space-y-1 px-3 py-2 overflow-x-hidden" className="space-y-0.5 px-3 py-2 overflow-x-hidden"
> >
{children} {children}
</ScrollableOverlay> </ScrollableOverlay>
{showTopShadow && (
<div
className="pointer-events-none absolute inset-x-0 top-0 h-4"
style={{ background: `linear-gradient(to bottom, ${bgVar} 0%, transparent 100%)` }}
/>
)}
{showBottomShadow && (
<div
className="pointer-events-none absolute inset-x-0 bottom-0 h-6"
style={{ background: `linear-gradient(to top, ${bgVar} 0%, transparent 100%)` }}
/>
)}
</div>
{footer} {footer}
</div> </div>
); );
@@ -1,12 +1,10 @@
import React from 'react'; import React from 'react';
import type { Extension } from '@codemirror/state'; import { ButtonSmall } from '@/components/ui/button-small';
import { EditorView } from '@codemirror/view';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { toast } from '@/components/ui'; import { toast } from '@/components/ui';
import { useSkillsStore, type SkillConfig, type SkillScope, type SupportingFile, type PendingFile } from '@/stores/useSkillsStore'; import { useSkillsStore, type SkillConfig, type SkillScope, type SupportingFile, type PendingFile } from '@/stores/useSkillsStore';
import { RiAddLine, RiBookOpenLine, RiDeleteBinLine, RiFileLine, RiFolderLine, RiRobot2Line, RiSaveLine, RiUser3Line } from '@remixicon/react'; import { RiAddLine, RiBookOpenLine, RiDeleteBinLine, RiFileLine, RiFolderLine, RiRobot2Line, RiUser3Line } from '@remixicon/react';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { import {
Select, Select,
@@ -23,10 +21,7 @@ import {
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { ButtonLarge } from '@/components/ui/button-large'; import { ButtonLarge } from '@/components/ui/button-large';
import { AnimatedTabs } from '@/components/ui/animated-tabs';
import { SkillsCatalogPage } from './catalog/SkillsCatalogPage'; import { SkillsCatalogPage } from './catalog/SkillsCatalogPage';
import { createFlexokiCodeMirrorTheme } from '@/lib/codemirror/flexokiTheme';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { import {
SKILL_LOCATION_OPTIONS, SKILL_LOCATION_OPTIONS,
locationLabel, locationLabel,
@@ -35,13 +30,15 @@ import {
type SkillLocationValue, type SkillLocationValue,
} from './skillLocations'; } from './skillLocations';
const LazyCodeMirrorEditor = React.lazy(async () => { export interface SkillsPageProps {
const module = await import('@/components/ui/CodeMirrorEditor'); view?: 'installed' | 'catalog';
return { default: module.CodeMirrorEditor }; }
});
export const SkillsPage: React.FC = () => { const SkillsCatalogStandalone: React.FC = () => (
const { currentTheme } = useThemeSystem(); <SkillsCatalogPage mode="external" onModeChange={() => {}} showModeTabs={false} />
);
const SkillsInstalledPage: React.FC = () => {
const { const {
selectedSkillName, selectedSkillName,
getSkillByName, getSkillByName,
@@ -58,155 +55,47 @@ export const SkillsPage: React.FC = () => {
const isNewSkill = Boolean(skillDraft && skillDraft.name === selectedSkillName && !selectedSkill); const isNewSkill = Boolean(skillDraft && skillDraft.name === selectedSkillName && !selectedSkill);
const hasStaleSelection = Boolean(selectedSkillName && !selectedSkill && !skillDraft); const hasStaleSelection = Boolean(selectedSkillName && !selectedSkill && !skillDraft);
type SkillsMode = 'manual' | 'external';
const [mode, setMode] = React.useState<SkillsMode>('manual');
React.useEffect(() => {
if (!isNewSkill && mode !== 'manual') {
setMode('manual');
}
}, [isNewSkill, mode]);
React.useEffect(() => { React.useEffect(() => {
if (!hasStaleSelection) { if (!hasStaleSelection) {
return; return;
} }
// Clear persisted selection if it points to a non-existent skill.
setSelectedSkill(null); setSelectedSkill(null);
}, [hasStaleSelection, setSelectedSkill]); }, [hasStaleSelection, setSelectedSkill]);
const modeTabs = isNewSkill ? (
<AnimatedTabs
tabs={[
{ value: 'manual', label: 'Manual' },
{ value: 'external', label: 'External' },
]}
value={mode}
onValueChange={setMode}
animate={false}
/>
) : null;
const [draftName, setDraftName] = React.useState(''); const [draftName, setDraftName] = React.useState('');
const [draftScope, setDraftScope] = React.useState<SkillScope>('user'); const [draftScope, setDraftScope] = React.useState<SkillScope>('user');
const [draftSource, setDraftSource] = React.useState<'opencode' | 'agents'>('opencode'); const [draftSource, setDraftSource] = React.useState<'opencode' | 'agents'>('opencode');
const [description, setDescription] = React.useState(''); const [description, setDescription] = React.useState('');
const [instructions, setInstructions] = React.useState(''); const [instructions, setInstructions] = React.useState('');
const [supportingFiles, setSupportingFiles] = React.useState<SupportingFile[]>([]); const [supportingFiles, setSupportingFiles] = React.useState<SupportingFile[]>([]);
const [pendingFiles, setPendingFiles] = React.useState<PendingFile[]>([]); // For new skills const [pendingFiles, setPendingFiles] = React.useState<PendingFile[]>([]);
const [isSaving, setIsSaving] = React.useState(false); const [isSaving, setIsSaving] = React.useState(false);
const [isLoading, setIsLoading] = React.useState(false); const [isLoading, setIsLoading] = React.useState(false);
// Track original values to detect changes
const [originalDescription, setOriginalDescription] = React.useState(''); const [originalDescription, setOriginalDescription] = React.useState('');
const [originalInstructions, setOriginalInstructions] = React.useState(''); const [originalInstructions, setOriginalInstructions] = React.useState('');
// File dialog state
const [isFileDialogOpen, setIsFileDialogOpen] = React.useState(false); const [isFileDialogOpen, setIsFileDialogOpen] = React.useState(false);
const [newFileName, setNewFileName] = React.useState(''); const [newFileName, setNewFileName] = React.useState('');
const [newFileContent, setNewFileContent] = React.useState(''); const [newFileContent, setNewFileContent] = React.useState('');
const [editingFilePath, setEditingFilePath] = React.useState<string | null>(null); // null = adding, string = editing const [editingFilePath, setEditingFilePath] = React.useState<string | null>(null);
const [isLoadingFile, setIsLoadingFile] = React.useState(false); const [isLoadingFile, setIsLoadingFile] = React.useState(false);
const [originalFileContent, setOriginalFileContent] = React.useState(''); // Track original for change detection const [originalFileContent, setOriginalFileContent] = React.useState('');
const [deleteFilePath, setDeleteFilePath] = React.useState<string | null>(null); const [deleteFilePath, setDeleteFilePath] = React.useState<string | null>(null);
const [isDeletingFile, setIsDeletingFile] = React.useState(false); const [isDeletingFile, setIsDeletingFile] = React.useState(false);
const [instructionsEditorHeight, setInstructionsEditorHeight] = React.useState(320);
const [instructionsLanguage, setInstructionsLanguage] = React.useState<Extension | null>(null);
const [supportingFileLanguage, setSupportingFileLanguage] = React.useState<Extension | null>(null);
React.useEffect(() => {
let cancelled = false;
const loadInstructionsLanguage = async () => {
const { languageByExtension } = await import('@/lib/codemirror/languageByExtension');
if (cancelled) return;
setInstructionsLanguage(languageByExtension('SKILL.md'));
};
void loadInstructionsLanguage();
return () => {
cancelled = true;
};
}, []);
React.useEffect(() => {
let cancelled = false;
const loadSupportingFileLanguage = async () => {
const targetPath = newFileName.trim();
if (!targetPath) {
setSupportingFileLanguage(null);
return;
}
const { languageByExtension } = await import('@/lib/codemirror/languageByExtension');
if (cancelled) return;
setSupportingFileLanguage(languageByExtension(targetPath));
};
void loadSupportingFileLanguage();
return () => {
cancelled = true;
};
}, [newFileName]);
const instructionsEditorExtensions = React.useMemo(() => {
const extensions: Extension[] = [createFlexokiCodeMirrorTheme(currentTheme), EditorView.lineWrapping];
if (instructionsLanguage) {
extensions.push(instructionsLanguage);
}
return extensions;
}, [currentTheme, instructionsLanguage]);
const supportingFileEditorExtensions = React.useMemo(() => {
const extensions: Extension[] = [createFlexokiCodeMirrorTheme(currentTheme), EditorView.lineWrapping];
if (supportingFileLanguage) {
extensions.push(supportingFileLanguage);
}
return extensions;
}, [currentTheme, supportingFileLanguage]);
const handleStartInstructionsResize = React.useCallback((event: React.MouseEvent<HTMLDivElement>) => {
event.preventDefault();
const startY = event.clientY;
const startHeight = instructionsEditorHeight;
const onMouseMove = (moveEvent: MouseEvent) => {
const deltaY = moveEvent.clientY - startY;
const viewportMax = typeof window !== 'undefined' ? Math.floor(window.innerHeight * 0.75) : 800;
const nextHeight = Math.max(220, Math.min(viewportMax, startHeight + deltaY));
setInstructionsEditorHeight(nextHeight);
};
const onMouseUp = () => {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
};
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
}, [instructionsEditorHeight]);
// Detect if skill-level fields have changed
const hasSkillChanges = isNewSkill const hasSkillChanges = isNewSkill
? (draftName.trim() !== '' || description.trim() !== '' || instructions.trim() !== '' || pendingFiles.length > 0) ? (draftName.trim() !== '' || description.trim() !== '' || instructions.trim() !== '' || pendingFiles.length > 0)
: (description !== originalDescription || instructions !== originalInstructions); : (description !== originalDescription || instructions !== originalInstructions);
// Detect if file content has changed
const hasFileChanges = editingFilePath const hasFileChanges = editingFilePath
? newFileContent !== originalFileContent ? newFileContent !== originalFileContent
: newFileName.trim() !== ''; // For new files, just need a name : newFileName.trim() !== '';
// Load skill details when selection changes
React.useEffect(() => { React.useEffect(() => {
if (mode === 'external') {
return;
}
const loadSkillDetails = async () => { const loadSkillDetails = async () => {
if (isNewSkill && skillDraft) { if (isNewSkill && skillDraft) {
// Prefill from draft (for new or duplicated skills)
setDraftName(skillDraft.name || ''); setDraftName(skillDraft.name || '');
setDraftScope(skillDraft.scope || 'user'); setDraftScope(skillDraft.scope || 'user');
setDraftSource(skillDraft.source === 'agents' ? 'agents' : 'opencode'); setDraftSource(skillDraft.source === 'agents' ? 'agents' : 'opencode');
@@ -221,7 +110,6 @@ export const SkillsPage: React.FC = () => {
try { try {
const detail = await getSkillDetail(selectedSkillName); const detail = await getSkillDetail(selectedSkillName);
if (detail) { if (detail) {
// Get actual content from the API response
const md = detail.sources.md; const md = detail.sources.md;
setDescription(md.description || ''); setDescription(md.description || '');
setInstructions(md.instructions || ''); setInstructions(md.instructions || '');
@@ -238,7 +126,7 @@ export const SkillsPage: React.FC = () => {
}; };
loadSkillDetails(); loadSkillDetails();
}, [selectedSkill, isNewSkill, selectedSkillName, skills, skillDraft, getSkillDetail, mode]); }, [selectedSkill, isNewSkill, selectedSkillName, skills, skillDraft, getSkillDetail]);
const handleSave = async () => { const handleSave = async () => {
const skillName = isNewSkill ? draftName.trim().replace(/\s+/g, '-').toLowerCase() : selectedSkillName?.trim(); const skillName = isNewSkill ? draftName.trim().replace(/\s+/g, '-').toLowerCase() : selectedSkillName?.trim();
@@ -248,7 +136,6 @@ export const SkillsPage: React.FC = () => {
return; return;
} }
// Validate skill name format
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(skillName) || skillName.length > 64) { if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(skillName) || skillName.length > 64) {
toast.error('Skill name must be 1-64 lowercase alphanumeric characters with hyphens, cannot start or end with hyphen'); toast.error('Skill name must be 1-64 lowercase alphanumeric characters with hyphens, cannot start or end with hyphen');
return; return;
@@ -259,7 +146,6 @@ export const SkillsPage: React.FC = () => {
return; return;
} }
// Check for duplicate name when creating new skill
if (isNewSkill && skills.some((s) => s.name === skillName)) { if (isNewSkill && skills.some((s) => s.name === skillName)) {
toast.error('A skill with this name already exists'); toast.error('A skill with this name already exists');
return; return;
@@ -274,7 +160,6 @@ export const SkillsPage: React.FC = () => {
instructions: instructions.trim() || undefined, instructions: instructions.trim() || undefined,
scope: isNewSkill ? draftScope : undefined, scope: isNewSkill ? draftScope : undefined,
source: isNewSkill ? draftSource : undefined, source: isNewSkill ? draftSource : undefined,
// Include pending files when creating new skill
supportingFiles: isNewSkill && pendingFiles.length > 0 ? pendingFiles : undefined, supportingFiles: isNewSkill && pendingFiles.length > 0 ? pendingFiles : undefined,
}; };
@@ -282,14 +167,13 @@ export const SkillsPage: React.FC = () => {
if (isNewSkill) { if (isNewSkill) {
success = await createSkill(config); success = await createSkill(config);
if (success) { if (success) {
setSkillDraft(null); // Clear draft after successful creation setSkillDraft(null);
setPendingFiles([]); // Clear pending files setPendingFiles([]);
setSelectedSkill(skillName); // Select the newly created skill setSelectedSkill(skillName);
} }
} else { } else {
success = await updateSkill(skillName, config); success = await updateSkill(skillName, config);
if (success) { if (success) {
// Update original values to reflect saved state
setOriginalDescription(description.trim()); setOriginalDescription(description.trim());
setOriginalInstructions(instructions.trim()); setOriginalInstructions(instructions.trim());
} }
@@ -320,7 +204,6 @@ export const SkillsPage: React.FC = () => {
setEditingFilePath(filePath); setEditingFilePath(filePath);
setNewFileName(filePath); setNewFileName(filePath);
// For new skills, get content from pending files
if (isNewSkill) { if (isNewSkill) {
const pendingFile = pendingFiles.find(f => f.path === filePath); const pendingFile = pendingFiles.find(f => f.path === filePath);
const content = pendingFile?.content || ''; const content = pendingFile?.content || '';
@@ -330,7 +213,6 @@ export const SkillsPage: React.FC = () => {
return; return;
} }
// For existing skills, load content from server
if (!selectedSkillName) return; if (!selectedSkillName) return;
setIsLoadingFile(true); setIsLoadingFile(true);
@@ -359,16 +241,13 @@ export const SkillsPage: React.FC = () => {
const filePath = newFileName.trim(); const filePath = newFileName.trim();
const isEditing = editingFilePath !== null; const isEditing = editingFilePath !== null;
// For new skills, add/update pending files
if (isNewSkill) { if (isNewSkill) {
if (isEditing) { if (isEditing) {
// Update existing pending file
setPendingFiles(prev => prev.map(f => setPendingFiles(prev => prev.map(f =>
f.path === editingFilePath ? { path: filePath, content: newFileContent } : f f.path === editingFilePath ? { path: filePath, content: newFileContent } : f
)); ));
toast.success(`File "${filePath}" updated`); toast.success(`File "${filePath}" updated`);
} else { } else {
// Check for duplicate
if (pendingFiles.some(f => f.path === filePath)) { if (pendingFiles.some(f => f.path === filePath)) {
toast.error('A file with this name already exists'); toast.error('A file with this name already exists');
return; return;
@@ -381,7 +260,6 @@ export const SkillsPage: React.FC = () => {
return; return;
} }
// For existing skills, write directly to disk
if (!selectedSkillName) { if (!selectedSkillName) {
toast.error('No skill selected'); toast.error('No skill selected');
return; return;
@@ -394,7 +272,6 @@ export const SkillsPage: React.FC = () => {
toast.success(isEditing ? `File "${filePath}" updated` : `File "${filePath}" created`); toast.success(isEditing ? `File "${filePath}" updated` : `File "${filePath}" created`);
setIsFileDialogOpen(false); setIsFileDialogOpen(false);
setEditingFilePath(null); setEditingFilePath(null);
// Refresh skill details to get updated file list
const detail = await getSkillDetail(selectedSkillName); const detail = await getSkillDetail(selectedSkillName);
if (detail) { if (detail) {
setSupportingFiles(detail.sources.md.supportingFiles || []); setSupportingFiles(detail.sources.md.supportingFiles || []);
@@ -405,14 +282,12 @@ export const SkillsPage: React.FC = () => {
}; };
const handleDeleteFile = (filePath: string) => { const handleDeleteFile = (filePath: string) => {
// For new skills, remove from pending files
if (isNewSkill) { if (isNewSkill) {
setPendingFiles(prev => prev.filter(f => f.path !== filePath)); setPendingFiles(prev => prev.filter(f => f.path !== filePath));
toast.success(`File "${filePath}" removed`); toast.success(`File "${filePath}" removed`);
return; return;
} }
// For existing skills, delete from disk
if (!selectedSkillName) { if (!selectedSkillName) {
return; return;
} }
@@ -443,12 +318,6 @@ export const SkillsPage: React.FC = () => {
setIsDeletingFile(false); setIsDeletingFile(false);
}; };
if (isNewSkill && mode === 'external') {
return <SkillsCatalogPage mode={mode} onModeChange={setMode} />;
}
// Show empty state when nothing is selected or selection is stale
if ((!selectedSkillName && !skillDraft) || hasStaleSelection) { if ((!selectedSkillName && !skillDraft) || hasStaleSelection) {
return ( return (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
@@ -473,42 +342,45 @@ export const SkillsPage: React.FC = () => {
return ( return (
<ScrollableOverlay keyboardAvoid outerClassName="h-full" className="w-full"> <ScrollableOverlay keyboardAvoid outerClassName="h-full" className="w-full">
<div className="mx-auto max-w-3xl space-y-6 p-6"> <div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8">
{isNewSkill ? modeTabs : null}
{/* Header */} {/* Header */}
<div className="space-y-1"> <div className="mb-4">
<h1 className="typography-ui-header font-semibold text-lg"> <div className="min-w-0">
<h2 className="typography-ui-header font-semibold text-foreground truncate flex items-center gap-2">
{isNewSkill ? 'New Skill' : selectedSkillName} {isNewSkill ? 'New Skill' : selectedSkillName}
</h1> {selectedSkill?.source === 'claude' && (
{selectedSkill && ( <span className="typography-micro font-normal bg-[var(--surface-muted)] text-muted-foreground px-1.5 py-0.5 rounded">
<p className="typography-meta text-muted-foreground"> Claude-compatible
{locationLabel(selectedSkill.scope, selectedSkill.source)} skill </span>
{selectedSkill.source === 'claude' && ' (Claude-compatible)'}
</p>
)} )}
</h2>
<p className="typography-meta text-muted-foreground truncate">
{selectedSkill ? `${locationLabel(selectedSkill.scope, selectedSkill.source)} skill` : 'Configure a new skill'}
</p>
</div>
</div> </div>
{/* Basic Information */} {/* Basic Information */}
<div className="space-y-4"> <div className="mb-8">
<div className="space-y-1"> <div className="mb-1 px-1">
<h2 className="typography-ui-header font-semibold text-foreground">Basic Information</h2> <h3 className="typography-ui-header font-medium text-foreground">
<p className="typography-meta text-muted-foreground/80"> Basic Information
Configure skill identity and description </h3>
</p>
</div> </div>
<section className="px-2 pb-2 pt-0 space-y-0">
{isNewSkill && ( {isNewSkill && (
<div className="space-y-2"> <div className="py-1.5">
<label className="typography-ui-label font-medium text-foreground"> <span className="typography-ui-label text-foreground">Skill Name & Location</span>
Skill Name & Location <span className="typography-meta text-muted-foreground ml-2">Lowercase, numbers, hyphens</span>
</label> <div className="flex items-center gap-2 mt-1.5">
<div className="flex items-center gap-2">
<Input <Input
value={draftName} value={draftName}
onChange={(e) => setDraftName(e.target.value.toLowerCase().replace(/\s+/g, '-'))} onChange={(e) => setDraftName(e.target.value.toLowerCase().replace(/\s+/g, '-'))}
placeholder="skill-name" placeholder="skill-name"
className="flex-1 text-foreground placeholder:text-muted-foreground" className="h-7 w-40 px-2"
/> />
<Select <Select
value={locationValueFrom(draftScope, draftSource)} value={locationValueFrom(draftScope, draftSource)}
@@ -518,22 +390,22 @@ export const SkillsPage: React.FC = () => {
setDraftSource(next.source === 'agents' ? 'agents' : 'opencode'); setDraftSource(next.source === 'agents' ? 'agents' : 'opencode');
}} }}
> >
<SelectTrigger className="!h-9 w-auto gap-1.5"> <SelectTrigger className="w-fit gap-1.5">
{draftScope === 'user' ? ( {draftScope === 'user' ? (
<RiUser3Line className="h-4 w-4" /> <RiUser3Line className="h-3.5 w-3.5" />
) : ( ) : (
<RiFolderLine className="h-4 w-4" /> <RiFolderLine className="h-3.5 w-3.5" />
)} )}
{draftSource === 'agents' ? <RiRobot2Line className="h-4 w-4" /> : null} {draftSource === 'agents' ? <RiRobot2Line className="h-3.5 w-3.5" /> : null}
<span>{locationLabel(draftScope, draftSource)}</span> <span>{locationLabel(draftScope, draftSource)}</span>
</SelectTrigger> </SelectTrigger>
<SelectContent align="end"> <SelectContent align="start">
{SKILL_LOCATION_OPTIONS.map((option) => ( {SKILL_LOCATION_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value} className="pr-2 [&>span:first-child]:hidden"> <SelectItem key={option.value} value={option.value}>
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{option.scope === 'user' ? <RiUser3Line className="h-4 w-4" /> : <RiFolderLine className="h-4 w-4" />} {option.scope === 'user' ? <RiUser3Line className="h-3.5 w-3.5" /> : <RiFolderLine className="h-3.5 w-3.5" />}
{option.source === 'agents' ? <RiRobot2Line className="h-4 w-4" /> : null} {option.source === 'agents' ? <RiRobot2Line className="h-3.5 w-3.5" /> : null}
<span>{option.label}</span> <span>{option.label}</span>
</div> </div>
<span className="typography-micro text-muted-foreground ml-6">{option.description}</span> <span className="typography-micro text-muted-foreground ml-6">{option.description}</span>
@@ -543,155 +415,112 @@ export const SkillsPage: React.FC = () => {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<p className="typography-meta text-muted-foreground">
Lowercase letters, numbers, and hyphens only. Cannot start or end with hyphen.
</p>
</div> </div>
)} )}
<div className="space-y-2"> <div className="py-1.5">
<label className="typography-ui-label font-medium text-foreground"> <span className="typography-ui-label text-foreground">Description <span className="text-[var(--status-error)]">*</span></span>
Description <span className="text-destructive">*</span> <span className="typography-meta text-muted-foreground ml-2">The agent uses this to decide when to load the skill</span>
</label> <div className="mt-1.5">
<Textarea <Textarea
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
placeholder="Brief description of what this skill does..." placeholder="Brief description of what this skill does..."
rows={2} rows={2}
className="max-h-32 resize-none" className="w-full resize-none min-h-[60px] max-h-32 bg-transparent"
/> />
<p className="typography-meta text-muted-foreground">
The agent uses this to decide when to load the skill
</p>
</div> </div>
</div> </div>
{/* Instructions */} </section>
<div className="space-y-4">
<div className="space-y-1">
<h2 className="typography-h2 font-semibold text-foreground">Instructions</h2>
<p className="typography-meta text-muted-foreground/80">
Detailed instructions for the agent when this skill is loaded
</p>
</div> </div>
<div
className="relative min-h-[220px] max-h-[75vh] rounded-md border border-[var(--interactive-border)] bg-[var(--surface-elevated)] overflow-hidden flex flex-col" {/* Instructions */}
style={{ height: `${instructionsEditorHeight}px` }} <div className="mb-8">
> <div className="mb-1 px-1">
<div className="flex-1 min-h-0"> <h3 className="typography-ui-header font-medium text-foreground">
<React.Suspense Instructions
fallback={( </h3>
</div>
<section className="px-2 pb-2 pt-0">
<Textarea <Textarea
value={instructions} value={instructions}
onChange={(e) => setInstructions(e.target.value)} onChange={(e) => setInstructions(e.target.value)}
placeholder="Step-by-step instructions, guidelines, or reference content..." placeholder="Step-by-step instructions, guidelines, or reference content..."
rows={12} className="min-h-[220px] max-h-[60vh] font-mono typography-meta"
className="h-full border-0 rounded-none font-mono typography-meta resize-none"
/> />
)} </section>
>
<LazyCodeMirrorEditor
value={instructions}
onChange={setInstructions}
extensions={instructionsEditorExtensions}
className="h-full"
/>
</React.Suspense>
</div>
<div
role="separator"
aria-label="Resize instructions editor"
onMouseDown={handleStartInstructionsResize}
className="absolute right-1.5 bottom-1.5 z-10 h-4 w-4 cursor-nwse-resize opacity-80 hover:opacity-100"
>
<svg viewBox="0 0 16 16" className="h-4 w-4 text-[var(--surface-muted-foreground)]" aria-hidden="true">
<path d="M6 14L14 6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<path d="M10 14L14 10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<path d="M2 14L14 2" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</div>
</div>
</div> </div>
{/* Supporting Files */} {/* Supporting Files */}
<div className="space-y-4"> <div className="mb-2">
<div className="flex items-center justify-between"> <div className="mb-1 px-1 flex items-center gap-2">
<div className="space-y-1"> <h3 className="typography-ui-header font-medium text-foreground">
<h2 className="typography-h2 font-semibold text-foreground">Supporting Files</h2> Supporting Files
<p className="typography-meta text-muted-foreground/80"> </h3>
Reference documentation, scripts, or templates <ButtonSmall variant="outline" size="xs" className="!font-normal gap-1" onClick={handleAddFile}>
</p> <RiAddLine className="h-3.5 w-3.5" /> Add File
</div> </ButtonSmall>
<Button
variant="outline"
size="sm"
onClick={handleAddFile}
className="gap-1.5"
>
<RiAddLine className="h-3.5 w-3.5" />
Add File
</Button>
</div> </div>
<section className="px-2 pb-2 pt-0">
{(() => { {(() => {
// For new skills, show pending files
const filesToShow = isNewSkill ? pendingFiles : supportingFiles; const filesToShow = isNewSkill ? pendingFiles : supportingFiles;
if (filesToShow.length === 0) { if (filesToShow.length === 0) {
return ( return (
<p className="typography-meta text-muted-foreground py-2"> <p className="typography-meta text-muted-foreground py-1.5">
{isNewSkill ? 'No files yet. Use "Add File" to include reference materials.' : 'No supporting files. Use "Add File" to include reference materials.'} No supporting files. Use "Add File" to include reference materials.
</p> </p>
); );
} }
return ( return (
<div className="space-y-2"> <div className="divide-y divide-[var(--surface-subtle)]">
{filesToShow.map((file) => ( {filesToShow.map((file) => (
<div <div
key={file.path} key={file.path}
className="flex items-center justify-between px-3 py-2 rounded-lg border bg-muted/30 hover:bg-interactive-hover cursor-pointer transition-colors" className="flex items-center gap-2 py-1.5 cursor-pointer group"
onClick={() => handleEditFile(file.path)} onClick={() => handleEditFile(file.path)}
> >
<div className="flex items-center gap-2 min-w-0"> <RiFileLine className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
<RiFileLine className="h-4 w-4 text-muted-foreground flex-shrink-0" /> <span className="typography-ui-label text-foreground truncate">{file.path}</span>
<span className="typography-ui-label truncate">{file.path}</span>
{isNewSkill && ( {isNewSkill && (
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50"> <span className="typography-micro text-[var(--status-warning)] bg-[var(--status-warning)]/10 px-1.5 py-0.5 rounded flex-shrink-0">
pending pending
</span> </span>
)} )}
</div> <ButtonSmall
<Button
variant="ghost" variant="ghost"
size="icon" className="h-5 w-5 px-0 flex-shrink-0 text-muted-foreground hover:text-[var(--status-error)] opacity-0 group-hover:opacity-100 transition-opacity"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleDeleteFile(file.path); handleDeleteFile(file.path);
}} }}
> >
<RiDeleteBinLine className="h-3.5 w-3.5" /> <RiDeleteBinLine className="h-3 w-3" />
</Button> </ButtonSmall>
</div> </div>
))} ))}
</div> </div>
); );
})()} })()}
</section>
</div> </div>
{/* Save Button */} {/* Save action */}
<div className="flex justify-end border-t border-border/40 pt-4"> <div className="px-2 py-1">
<Button <ButtonSmall
size="sm"
variant="default"
onClick={handleSave} onClick={handleSave}
disabled={isSaving || !hasSkillChanges} disabled={isSaving || !hasSkillChanges}
className="gap-2 h-6 px-2 text-xs w-fit" size="xs"
className="!font-normal"
> >
<RiSaveLine className="h-3 w-3" />
{isSaving ? 'Saving...' : isNewSkill ? 'Create Skill' : 'Save Changes'} {isSaving ? 'Saving...' : isNewSkill ? 'Create Skill' : 'Save Changes'}
</Button> </ButtonSmall>
</div>
</div> </div>
{/* Add/Edit File Dialog */} {/* Add/Edit File Dialog */}
@@ -711,15 +540,14 @@ export const SkillsPage: React.FC = () => {
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button <ButtonLarge
variant="ghost" variant="ghost"
onClick={() => setDeleteFilePath(null)} onClick={() => setDeleteFilePath(null)}
disabled={isDeletingFile} disabled={isDeletingFile}
className="text-foreground hover:bg-interactive-hover hover:text-foreground"
> >
Cancel Cancel
</Button> </ButtonLarge>
<ButtonLarge onClick={handleConfirmDeleteFile} disabled={isDeletingFile}> <ButtonLarge onClick={handleConfirmDeleteFile} disabled={isDeletingFile} className="bg-[var(--status-error)] hover:bg-[var(--status-error)]/90 text-white border-0">
Delete Delete
</ButtonLarge> </ButtonLarge>
</DialogFooter> </DialogFooter>
@@ -730,7 +558,7 @@ export const SkillsPage: React.FC = () => {
setIsFileDialogOpen(open); setIsFileDialogOpen(open);
if (!open) setEditingFilePath(null); if (!open) setEditingFilePath(null);
}}> }}>
<DialogContent className="max-w-2xl max-h-[85vh] flex flex-col" keyboardAvoid> <DialogContent className="max-w-3xl max-h-[85vh] flex flex-col" keyboardAvoid>
<DialogHeader className="flex-shrink-0"> <DialogHeader className="flex-shrink-0">
<DialogTitle>{editingFilePath ? 'Edit Supporting File' : 'Add Supporting File'}</DialogTitle> <DialogTitle>{editingFilePath ? 'Edit Supporting File' : 'Add Supporting File'}</DialogTitle>
<DialogDescription> <DialogDescription>
@@ -742,7 +570,7 @@ export const SkillsPage: React.FC = () => {
<span className="typography-meta text-muted-foreground">Loading file content...</span> <span className="typography-meta text-muted-foreground">Loading file content...</span>
</div> </div>
) : ( ) : (
<div className="space-y-4 flex-1 min-h-0 flex flex-col"> <div className="space-y-4 flex-1 min-h-0 flex flex-col pt-2">
<div className="space-y-2 flex-shrink-0"> <div className="space-y-2 flex-shrink-0">
<label className="typography-ui-label font-medium text-foreground"> <label className="typography-ui-label font-medium text-foreground">
File Path File Path
@@ -751,7 +579,7 @@ export const SkillsPage: React.FC = () => {
value={newFileName} value={newFileName}
onChange={(e) => setNewFileName(e.target.value)} onChange={(e) => setNewFileName(e.target.value)}
placeholder="example.md or docs/reference.txt" placeholder="example.md or docs/reference.txt"
className="text-foreground placeholder:text-muted-foreground" className="text-foreground placeholder:text-muted-foreground focus-visible:ring-[var(--primary-base)]"
disabled={editingFilePath !== null} disabled={editingFilePath !== null}
/> />
{!editingFilePath && ( {!editingFilePath && (
@@ -764,46 +592,36 @@ export const SkillsPage: React.FC = () => {
<label className="typography-ui-label font-medium text-foreground flex-shrink-0"> <label className="typography-ui-label font-medium text-foreground flex-shrink-0">
Content Content
</label> </label>
<div className="h-[45vh] min-h-[220px] max-h-[55vh] rounded-md border border-[var(--interactive-border)] bg-[var(--surface-elevated)] overflow-hidden">
<React.Suspense
fallback={(
<Textarea <Textarea
value={newFileContent} value={newFileContent}
onChange={(e) => setNewFileContent(e.target.value)} onChange={(e) => setNewFileContent(e.target.value)}
placeholder="File content..." placeholder="File content..."
className="h-full border-0 rounded-none font-mono typography-meta resize-none" outerClassName="h-[45vh] min-h-[250px] max-h-[55vh]"
className="h-full min-h-0 font-mono typography-meta"
/> />
)}
>
<LazyCodeMirrorEditor
value={newFileContent}
onChange={setNewFileContent}
extensions={supportingFileEditorExtensions}
className="h-full"
/>
</React.Suspense>
</div>
</div> </div>
</div> </div>
)} )}
<DialogFooter> <DialogFooter className="mt-4">
<Button <ButtonLarge
variant="ghost" variant="ghost"
onClick={() => { onClick={() => {
setIsFileDialogOpen(false); setIsFileDialogOpen(false);
setEditingFilePath(null); setEditingFilePath(null);
}} }}
className="text-foreground hover:bg-interactive-hover hover:text-foreground"
> >
Cancel Cancel
</Button> </ButtonLarge>
<ButtonLarge onClick={handleSaveFile} disabled={isLoadingFile || !hasFileChanges}> <ButtonLarge onClick={handleSaveFile} disabled={isLoadingFile || !hasFileChanges}>
{editingFilePath ? 'Save Changes' : 'Create File'} {editingFilePath ? 'Save Changes' : 'Create File'}
</ButtonLarge> </ButtonLarge>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div>
</ScrollableOverlay> </ScrollableOverlay>
); );
}; };
export const SkillsPage: React.FC<SkillsPageProps> = ({ view = 'installed' }) => {
return view === 'catalog' ? <SkillsCatalogStandalone /> : <SkillsInstalledPage />;
};
@@ -1,8 +1,9 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { Button } from '@/components/ui/button'; import { ButtonSmall } from '@/components/ui/button-small';
import { ButtonLarge } from '@/components/ui/button-large'; import { ButtonLarge } from '@/components/ui/button-large';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { toast } from '@/components/ui'; import { toast } from '@/components/ui';
import { isMobileDeviceViaCSS } from '@/lib/device';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -19,11 +20,9 @@ import {
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { RiAddLine, RiDeleteBinLine, RiFileCopyLine, RiMore2Line, RiEditLine, RiBookOpenLine } from '@remixicon/react'; import { RiAddLine, RiDeleteBinLine, RiFileCopyLine, RiMore2Line, RiEditLine, RiBookOpenLine } from '@remixicon/react';
import { useSkillsStore, type DiscoveredSkill } from '@/stores/useSkillsStore'; import { useSkillsStore, type DiscoveredSkill } from '@/stores/useSkillsStore';
import { useUIStore } from '@/stores/useUIStore';
import { useDeviceInfo } from '@/lib/device';
import { isVSCodeRuntime } from '@/lib/desktop';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { SettingsProjectSelector } from '@/components/sections/shared/SettingsProjectSelector';
import { SidebarGroup } from '@/components/sections/shared/SidebarGroup'; import { SidebarGroup } from '@/components/sections/shared/SidebarGroup';
interface SkillsSidebarProps { interface SkillsSidebarProps {
@@ -35,6 +34,7 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
const [renameNewName, setRenameNewName] = React.useState(''); const [renameNewName, setRenameNewName] = React.useState('');
const [deleteDialogSkill, setDeleteDialogSkill] = React.useState<DiscoveredSkill | null>(null); const [deleteDialogSkill, setDeleteDialogSkill] = React.useState<DiscoveredSkill | null>(null);
const [isDeletePending, setIsDeletePending] = React.useState(false); const [isDeletePending, setIsDeletePending] = React.useState(false);
const [openMenuSkill, setOpenMenuSkill] = React.useState<string | null>(null);
const { const {
selectedSkillName, selectedSkillName,
@@ -43,20 +43,12 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
setSkillDraft, setSkillDraft,
createSkill, createSkill,
deleteSkill, deleteSkill,
loadSkills,
getSkillDetail, getSkillDetail,
} = useSkillsStore(); } = useSkillsStore();
const { setSidebarOpen } = useUIStore(); // Skills are loaded by the Settings shell when this page is active.
const { isMobile } = useDeviceInfo();
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []); const bgClass = 'bg-background';
React.useEffect(() => {
loadSkills();
}, [loadSkills]);
const bgClass = isVSCode ? 'bg-background' : 'bg-sidebar';
const handleCreateNew = () => { const handleCreateNew = () => {
// Generate unique name // Generate unique name
@@ -73,9 +65,7 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
setSelectedSkill(newName); setSelectedSkill(newName);
onItemSelect?.(); onItemSelect?.();
if (isMobile) {
setSidebarOpen(false);
}
}; };
const handleDeleteSkill = async (skill: DiscoveredSkill) => { const handleDeleteSkill = async (skill: DiscoveredSkill) => {
@@ -125,9 +115,7 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
}); });
setSelectedSkill(newName); setSelectedSkill(newName);
if (isMobile) {
setSidebarOpen(false);
}
}; };
const handleOpenRenameDialog = (skill: DiscoveredSkill) => { const handleOpenRenameDialog = (skill: DiscoveredSkill) => {
@@ -214,18 +202,18 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
return ( return (
<div className={cn('flex h-full flex-col', bgClass)}> <div className={cn('flex h-full flex-col', bgClass)}>
<div className={cn('border-b px-3', isMobile ? 'mt-2 py-3' : 'py-3')}> <div className="border-b px-3 pt-4 pb-3">
<h2 className="text-base font-semibold text-foreground mb-3">Skills</h2>
<SettingsProjectSelector className="mb-3" />
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground">Total {skills.length}</span> <span className="typography-meta text-muted-foreground">Total {skills.length}</span>
<Button <ButtonSmall
type="button"
variant="ghost" variant="ghost"
size="icon" className="h-7 w-7 px-0 -my-1 text-muted-foreground"
className="h-7 w-7 -my-1 text-muted-foreground"
onClick={handleCreateNew} onClick={handleCreateNew}
> >
<RiAddLine className="size-4" /> <RiAddLine className="h-3.5 w-3.5" />
</Button> </ButtonSmall>
</div> </div>
</div> </div>
@@ -258,13 +246,13 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
onSelect={() => { onSelect={() => {
setSelectedSkill(skill.name); setSelectedSkill(skill.name);
onItemSelect?.(); onItemSelect?.();
if (isMobile) {
setSidebarOpen(false);
}
}} }}
onRename={() => handleOpenRenameDialog(skill)} onRename={() => handleOpenRenameDialog(skill)}
onDelete={() => handleDeleteSkill(skill)} onDelete={() => handleDeleteSkill(skill)}
onDuplicate={() => handleDuplicateSkill(skill)} onDuplicate={() => handleDuplicateSkill(skill)}
isMenuOpen={openMenuSkill === skill.name}
onMenuOpenChange={(open) => setOpenMenuSkill(open ? skill.name : null)}
/> />
))} ))}
</SidebarGroup> </SidebarGroup>
@@ -277,13 +265,13 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
onSelect={() => { onSelect={() => {
setSelectedSkill(skill.name); setSelectedSkill(skill.name);
onItemSelect?.(); onItemSelect?.();
if (isMobile) {
setSidebarOpen(false);
}
}} }}
onRename={() => handleOpenRenameDialog(skill)} onRename={() => handleOpenRenameDialog(skill)}
onDelete={() => handleDeleteSkill(skill)} onDelete={() => handleDeleteSkill(skill)}
onDuplicate={() => handleDuplicateSkill(skill)} onDuplicate={() => handleDuplicateSkill(skill)}
isMenuOpen={openMenuSkill === skill.name}
onMenuOpenChange={(open) => setOpenMenuSkill(open ? skill.name : null)}
/> />
))} ))}
</> </>
@@ -309,13 +297,13 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
onSelect={() => { onSelect={() => {
setSelectedSkill(skill.name); setSelectedSkill(skill.name);
onItemSelect?.(); onItemSelect?.();
if (isMobile) {
setSidebarOpen(false);
}
}} }}
onRename={() => handleOpenRenameDialog(skill)} onRename={() => handleOpenRenameDialog(skill)}
onDelete={() => handleDeleteSkill(skill)} onDelete={() => handleDeleteSkill(skill)}
onDuplicate={() => handleDuplicateSkill(skill)} onDuplicate={() => handleDuplicateSkill(skill)}
isMenuOpen={openMenuSkill === skill.name}
onMenuOpenChange={(open) => setOpenMenuSkill(open ? skill.name : null)}
/> />
))} ))}
</SidebarGroup> </SidebarGroup>
@@ -328,13 +316,13 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
onSelect={() => { onSelect={() => {
setSelectedSkill(skill.name); setSelectedSkill(skill.name);
onItemSelect?.(); onItemSelect?.();
if (isMobile) {
setSidebarOpen(false);
}
}} }}
onRename={() => handleOpenRenameDialog(skill)} onRename={() => handleOpenRenameDialog(skill)}
onDelete={() => handleDeleteSkill(skill)} onDelete={() => handleDeleteSkill(skill)}
onDuplicate={() => handleDuplicateSkill(skill)} onDuplicate={() => handleDuplicateSkill(skill)}
isMenuOpen={openMenuSkill === skill.name}
onMenuOpenChange={(open) => setOpenMenuSkill(open ? skill.name : null)}
/> />
))} ))}
</> </>
@@ -359,14 +347,14 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button <ButtonLarge
variant="ghost" variant="ghost"
onClick={() => setDeleteDialogSkill(null)} onClick={() => setDeleteDialogSkill(null)}
disabled={isDeletePending} disabled={isDeletePending}
className="text-foreground hover:bg-interactive-hover hover:text-foreground" className="text-foreground hover:bg-interactive-hover hover:text-foreground"
> >
Cancel Cancel
</Button> </ButtonLarge>
<ButtonLarge onClick={handleConfirmDeleteSkill} disabled={isDeletePending}> <ButtonLarge onClick={handleConfirmDeleteSkill} disabled={isDeletePending}>
Delete Delete
</ButtonLarge> </ButtonLarge>
@@ -395,13 +383,13 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
}} }}
/> />
<DialogFooter> <DialogFooter>
<Button <ButtonLarge
variant="ghost" variant="ghost"
onClick={() => setRenameDialogSkill(null)} onClick={() => setRenameDialogSkill(null)}
className="text-foreground hover:bg-interactive-hover hover:text-foreground" className="text-foreground hover:bg-interactive-hover hover:text-foreground"
> >
Cancel Cancel
</Button> </ButtonLarge>
<ButtonLarge onClick={handleRenameSkill}> <ButtonLarge onClick={handleRenameSkill}>
Rename Rename
</ButtonLarge> </ButtonLarge>
@@ -419,6 +407,8 @@ interface SkillListItemProps {
onDelete: () => void; onDelete: () => void;
onRename: () => void; onRename: () => void;
onDuplicate: () => void; onDuplicate: () => void;
isMenuOpen: boolean;
onMenuOpenChange: (open: boolean) => void;
} }
const SkillListItem: React.FC<SkillListItemProps> = ({ const SkillListItem: React.FC<SkillListItemProps> = ({
@@ -428,13 +418,20 @@ const SkillListItem: React.FC<SkillListItemProps> = ({
onDelete, onDelete,
onRename, onRename,
onDuplicate, onDuplicate,
isMenuOpen,
onMenuOpenChange,
}) => { }) => {
const isMobile = isMobileDeviceViaCSS();
return ( return (
<div <div
className={cn( className={cn(
'group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200', 'group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200 select-none',
isSelected ? 'bg-interactive-selection' : 'hover:bg-interactive-hover' isSelected ? 'bg-interactive-selection' : 'hover:bg-interactive-hover'
)} )}
onContextMenu={!isMobile ? (e) => {
e.preventDefault();
onMenuOpenChange(true);
} : undefined}
> >
<div className="flex min-w-0 flex-1 items-center"> <div className="flex min-w-0 flex-1 items-center">
<button <button
@@ -462,15 +459,14 @@ const SkillListItem: React.FC<SkillListItemProps> = ({
</div> </div>
</button> </button>
<DropdownMenu> <DropdownMenu open={isMenuOpen} onOpenChange={onMenuOpenChange}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <ButtonSmall
size="icon"
variant="ghost" variant="ghost"
className="h-6 w-6 flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100" className="h-6 w-6 px-0 flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"
> >
<RiMore2Line className="h-3.5 w-3.5" /> <RiMore2Line className="h-3.5 w-3.5" />
</Button> </ButtonSmall>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit min-w-20"> <DropdownMenuContent align="end" className="w-fit min-w-20">
<DropdownMenuItem <DropdownMenuItem
@@ -9,7 +9,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { ButtonLarge } from '@/components/ui/button-large';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { import {
Select, Select,
@@ -240,12 +240,12 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground">Catalog name</label> <label className="typography-ui-label text-foreground">Catalog name</label>
<Input value={label} onChange={(e) => setLabel(e.target.value)} placeholder="e.g. Team Skills" /> <Input value={label} onChange={(e) => setLabel(e.target.value)} placeholder="e.g. Team Skills" />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground">Repository</label> <label className="typography-ui-label text-foreground">Repository</label>
<Input <Input
value={source} value={source}
onChange={(e) => { onChange={(e) => {
@@ -261,7 +261,7 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground">Optional subpath</label> <label className="typography-ui-label text-foreground">Optional subpath</label>
<Input <Input
value={subpath} value={subpath}
onChange={(e) => { onChange={(e) => {
@@ -274,28 +274,26 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
</div> </div>
{identityOptions.length > 0 && !isVSCodeRuntime() ? ( {identityOptions.length > 0 && !isVSCodeRuntime() ? (
<div className="rounded-lg border bg-muted/20 px-3 py-2"> <div className="space-y-2">
<div className="typography-ui-label font-medium text-foreground">Authentication required</div> <div>
<div className="typography-meta text-muted-foreground mt-1"> <span className="typography-ui-label text-[var(--status-warning)]">Authentication required</span>
Select a Git identity (SSH key) that can access this repository. <span className="typography-meta text-muted-foreground ml-2">Select a Git identity (SSH key)</span>
</div> </div>
<div className="mt-2">
<Select value={gitIdentityId || ''} onValueChange={(v) => setGitIdentityId(v)}> <Select value={gitIdentityId || ''} onValueChange={(v) => setGitIdentityId(v)}>
<SelectTrigger className="!h-9 w-full justify-between"> <SelectTrigger className="w-fit">
<span>{identityOptions.find((i) => i.id === gitIdentityId)?.name || 'Choose identity'}</span> <span>{identityOptions.find((i) => i.id === gitIdentityId)?.name || 'Choose identity'}</span>
</SelectTrigger> </SelectTrigger>
<SelectContent align="start"> <SelectContent align="start">
{identityOptions.map((id) => ( {identityOptions.map((id) => (
<SelectItem key={id.id} value={id.id} className="pr-2 [&>span:first-child]:hidden"> <SelectItem key={id.id} value={id.id}>
{id.name} {id.name}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> <p className="typography-micro text-muted-foreground">
<div className="typography-micro text-muted-foreground mt-2"> Configure identities in Settings - Git Identities.
Configure identities in Settings → Git Identities. </p>
</div>
</div> </div>
) : null} ) : null}
@@ -313,25 +311,24 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}> <ButtonLarge variant="ghost" onClick={() => onOpenChange(false)}>
Cancel Cancel
</Button> </ButtonLarge>
<Button <ButtonLarge
variant="outline" variant="ghost"
onClick={() => void handleScan()} onClick={() => void handleScan()}
disabled={isScanning || !source.trim()} disabled={isScanning || !source.trim()}
className="gap-2" className="gap-2"
> >
<RiGitRepositoryLine className="h-4 w-4" /> <RiGitRepositoryLine className="h-4 w-4" />
{isScanning ? 'Scanning…' : 'Scan'} {isScanning ? 'Scanning...' : 'Scan'}
</Button> </ButtonLarge>
<Button <ButtonLarge
variant="default"
onClick={() => void handleAdd()} onClick={() => void handleAdd()}
disabled={!scanOk || isDuplicate || !label.trim() || !source.trim()} disabled={!scanOk || isDuplicate || !label.trim() || !source.trim()}
> >
Add catalog Add catalog
</Button> </ButtonLarge>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -8,8 +8,8 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { ButtonLarge } from '@/components/ui/button-large'; import { ButtonLarge } from '@/components/ui/button-large';
import { ButtonSmall } from '@/components/ui/button-small';
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -73,8 +73,8 @@ export const InstallConflictsDialog: React.FC<InstallConflictsDialogProps> = ({
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground">{conflicts.length} conflict(s)</span> <span className="typography-meta text-muted-foreground">{conflicts.length} conflict(s)</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setAll('skip')}>Skip all</Button> <ButtonSmall variant="outline" size="xs" className="!font-normal" onClick={() => setAll('skip')}>Skip all</ButtonSmall>
<Button variant="outline" size="sm" onClick={() => setAll('overwrite')}>Overwrite all</Button> <ButtonSmall variant="outline" size="xs" className="!font-normal" onClick={() => setAll('overwrite')}>Overwrite all</ButtonSmall>
</div> </div>
</div> </div>
@@ -82,7 +82,7 @@ export const InstallConflictsDialog: React.FC<InstallConflictsDialogProps> = ({
{conflicts.map((conflict) => ( {conflicts.map((conflict) => (
<div <div
key={conflict.skillName} key={conflict.skillName}
className="flex items-center justify-between gap-3 rounded-lg border bg-muted/20 px-3 py-2" className="flex items-center justify-between gap-3 py-1.5"
> >
<div className="min-w-0"> <div className="min-w-0">
<div className="typography-ui-label truncate">{conflict.skillName}</div> <div className="typography-ui-label truncate">{conflict.skillName}</div>
@@ -95,7 +95,7 @@ export const InstallConflictsDialog: React.FC<InstallConflictsDialogProps> = ({
value={decisions[conflict.skillName] || 'skip'} value={decisions[conflict.skillName] || 'skip'}
onValueChange={(v) => setDecisions((prev) => ({ ...prev, [conflict.skillName]: v as ConflictDecision }))} onValueChange={(v) => setDecisions((prev) => ({ ...prev, [conflict.skillName]: v as ConflictDecision }))}
> >
<SelectTrigger className="!h-9 w-36 justify-between"> <SelectTrigger className="w-fit">
<span className="capitalize">{decisions[conflict.skillName] || 'skip'}</span> <span className="capitalize">{decisions[conflict.skillName] || 'skip'}</span>
</SelectTrigger> </SelectTrigger>
<SelectContent align="end"> <SelectContent align="end">
@@ -113,9 +113,9 @@ export const InstallConflictsDialog: React.FC<InstallConflictsDialogProps> = ({
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}> <ButtonLarge variant="ghost" onClick={() => onOpenChange(false)}>
Cancel Cancel
</Button> </ButtonLarge>
<ButtonLarge <ButtonLarge
onClick={() => onConfirm(decisions)} onClick={() => onConfirm(decisions)}
disabled={!canConfirm} disabled={!canConfirm}
@@ -19,6 +19,7 @@ import {
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { RiFolderLine, RiGitRepositoryLine, RiRobot2Line, RiUser3Line } from '@remixicon/react'; import { RiFolderLine, RiGitRepositoryLine, RiRobot2Line, RiUser3Line } from '@remixicon/react';
@@ -27,6 +28,7 @@ import type { SkillsCatalogItem } from '@/lib/api/types';
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore'; import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
import { useSkillsStore } from '@/stores/useSkillsStore'; import { useSkillsStore } from '@/stores/useSkillsStore';
import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore'; import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { InstallConflictsDialog, type ConflictDecision, type SkillConflict } from './InstallConflictsDialog'; import { InstallConflictsDialog, type ConflictDecision, type SkillConflict } from './InstallConflictsDialog';
import { import {
SKILL_LOCATION_OPTIONS, SKILL_LOCATION_OPTIONS,
@@ -49,6 +51,10 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
const defaultGitIdentityId = useGitIdentitiesStore((s) => s.defaultGitIdentityId); const defaultGitIdentityId = useGitIdentitiesStore((s) => s.defaultGitIdentityId);
const loadDefaultGitIdentityId = useGitIdentitiesStore((s) => s.loadDefaultGitIdentityId); const loadDefaultGitIdentityId = useGitIdentitiesStore((s) => s.loadDefaultGitIdentityId);
const projects = useProjectsStore((s) => s.projects);
const activeProjectId = useProjectsStore((s) => s.activeProjectId);
const [targetProjectId, setTargetProjectId] = React.useState<string | null>(null);
const [source, setSource] = React.useState(''); const [source, setSource] = React.useState('');
const [subpath, setSubpath] = React.useState(''); const [subpath, setSubpath] = React.useState('');
const [scope, setScope] = React.useState<'user' | 'project'>('user'); const [scope, setScope] = React.useState<'user' | 'project'>('user');
@@ -70,6 +76,7 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
targetSource: 'opencode' | 'agents'; targetSource: 'opencode' | 'agents';
selections: Array<{ skillDir: string }>; selections: Array<{ skillDir: string }>;
gitIdentityId?: string; gitIdentityId?: string;
directoryOverride?: string | null;
} | null>(null); } | null>(null);
React.useEffect(() => { React.useEffect(() => {
@@ -78,6 +85,7 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
setSubpath(''); setSubpath('');
setScope('user'); setScope('user');
setTargetSource('opencode'); setTargetSource('opencode');
setTargetProjectId(activeProjectId);
setItems([]); setItems([]);
setSelected({}); setSelected({});
setSearch(''); setSearch('');
@@ -89,7 +97,32 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
setConflicts([]); setConflicts([]);
setBaseInstallRequest(null); setBaseInstallRequest(null);
}, [open, loadDefaultGitIdentityId]); }, [open, loadDefaultGitIdentityId, activeProjectId]);
const resolvedTargetProjectId = React.useMemo(() => {
if (projects.length === 0) {
return null;
}
if (targetProjectId && projects.some((p) => p.id === targetProjectId)) {
return targetProjectId;
}
if (activeProjectId && projects.some((p) => p.id === activeProjectId)) {
return activeProjectId;
}
return projects[0]?.id ?? null;
}, [activeProjectId, projects, targetProjectId]);
const directoryOverride = React.useMemo(() => {
if (scope !== 'project') {
return null;
}
const id = resolvedTargetProjectId;
if (!id) {
return null;
}
const project = projects.find((p) => p.id === id);
return project?.path ?? null;
}, [projects, resolvedTargetProjectId, scope]);
const installedByName = React.useMemo(() => { const installedByName = React.useMemo(() => {
const map = new Map<string, { scope: 'user' | 'project'; source: 'opencode' | 'claude' | 'agents' }>(); const map = new Map<string, { scope: 'user' | 'project'; source: 'opencode' | 'claude' | 'agents' }>();
@@ -189,13 +222,22 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
targetSource, targetSource,
selections: selectedDirs.map((dir) => ({ skillDir: dir })), selections: selectedDirs.map((dir) => ({ skillDir: dir })),
gitIdentityId: gitIdentityId || undefined, gitIdentityId: gitIdentityId || undefined,
directoryOverride,
}; };
const result = await installSkills({ const result = await installSkills(
...request, {
source: request.source,
subpath: request.subpath,
scope: request.scope,
targetSource: request.targetSource,
selections: request.selections,
gitIdentityId: request.gitIdentityId,
conflictPolicy: 'prompt', conflictPolicy: 'prompt',
conflictDecisions: opts.conflictDecisions, conflictDecisions: opts.conflictDecisions,
}); },
{ directory: request.directoryOverride ?? null }
);
if (result.ok) { if (result.ok) {
const installedCount = result.installed?.length || 0; const installedCount = result.installed?.length || 0;
@@ -292,7 +334,7 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
setTargetSource(next.source === 'agents' ? 'agents' : 'opencode'); setTargetSource(next.source === 'agents' ? 'agents' : 'opencode');
}} }}
> >
<SelectTrigger className="!h-9 w-full gap-1.5"> <SelectTrigger size="lg" className="w-full gap-1.5">
{scope === 'user' ? <RiUser3Line className="h-4 w-4" /> : <RiFolderLine className="h-4 w-4" />} {scope === 'user' ? <RiUser3Line className="h-4 w-4" /> : <RiFolderLine className="h-4 w-4" />}
{targetSource === 'agents' ? <RiRobot2Line className="h-4 w-4" /> : null} {targetSource === 'agents' ? <RiRobot2Line className="h-4 w-4" /> : null}
<span>{locationLabel(scope, targetSource)}</span> <span>{locationLabel(scope, targetSource)}</span>
@@ -315,6 +357,32 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
</div> </div>
</div> </div>
{scope === 'project' && (
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground">Project</label>
{projects.length === 0 ? (
<p className="typography-meta text-muted-foreground">No projects available</p>
) : (
<Select
value={resolvedTargetProjectId ?? ''}
onValueChange={(v) => setTargetProjectId(v)}
disabled={projects.length === 1}
>
<SelectTrigger size="lg" className="w-full justify-between">
<SelectValue placeholder="Choose project" />
</SelectTrigger>
<SelectContent align="start">
{projects.map((p) => (
<SelectItem key={p.id} value={p.id} className="pr-2 [&>span:first-child]:hidden">
{p.label || p.path}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
)}
{identities.length > 0 && !isVSCodeRuntime() ? ( {identities.length > 0 && !isVSCodeRuntime() ? (
<div className="rounded-lg border bg-muted/20 px-3 py-2"> <div className="rounded-lg border bg-muted/20 px-3 py-2">
<div className="typography-ui-label font-medium text-foreground">Authentication required</div> <div className="typography-ui-label font-medium text-foreground">Authentication required</div>
@@ -323,7 +391,7 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
</div> </div>
<div className="mt-2"> <div className="mt-2">
<Select value={gitIdentityId || ''} onValueChange={(v) => setGitIdentityId(v)}> <Select value={gitIdentityId || ''} onValueChange={(v) => setGitIdentityId(v)}>
<SelectTrigger className="!h-9 w-full justify-between"> <SelectTrigger size="lg" className="w-full justify-between">
<span>{identities.find((i) => i.id === gitIdentityId)?.name || 'Choose identity'}</span> <span>{identities.find((i) => i.id === gitIdentityId)?.name || 'Choose identity'}</span>
</SelectTrigger> </SelectTrigger>
<SelectContent align="start"> <SelectContent align="start">
@@ -419,11 +487,11 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
</div> </div>
<DialogFooter className="flex-shrink-0"> <DialogFooter className="flex-shrink-0">
<Button variant="ghost" onClick={() => onOpenChange(false)}> <ButtonLarge variant="ghost" onClick={() => onOpenChange(false)}>
Cancel Cancel
</Button> </ButtonLarge>
<ButtonLarge <ButtonLarge
disabled={isInstalling || selectedDirs.length === 0 || !source.trim()} disabled={isInstalling || selectedDirs.length === 0 || !source.trim() || (scope === 'project' && !directoryOverride)}
onClick={() => void doInstall({})} onClick={() => void doInstall({})}
> >
{isInstalling ? 'Installing…' : 'Install selected'} {isInstalling ? 'Installing…' : 'Install selected'}
@@ -9,17 +9,19 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { ButtonLarge } from '@/components/ui/button-large';
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { RiFolderLine, RiRobot2Line, RiUser3Line } from '@remixicon/react'; import { RiFolderLine, RiRobot2Line, RiUser3Line } from '@remixicon/react';
import type { SkillsCatalogItem } from '@/lib/api/types'; import type { SkillsCatalogItem } from '@/lib/api/types';
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore'; import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { InstallConflictsDialog, type ConflictDecision, type SkillConflict } from './InstallConflictsDialog'; import { InstallConflictsDialog, type ConflictDecision, type SkillConflict } from './InstallConflictsDialog';
import { import {
SKILL_LOCATION_OPTIONS, SKILL_LOCATION_OPTIONS,
@@ -39,6 +41,9 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
const { installSkills, isInstalling } = useSkillsCatalogStore(); const { installSkills, isInstalling } = useSkillsCatalogStore();
const [scope, setScope] = React.useState<'user' | 'project'>('user'); const [scope, setScope] = React.useState<'user' | 'project'>('user');
const [targetSource, setTargetSource] = React.useState<'opencode' | 'agents'>('opencode'); const [targetSource, setTargetSource] = React.useState<'opencode' | 'agents'>('opencode');
const projects = useProjectsStore((s) => s.projects);
const activeProjectId = useProjectsStore((s) => s.activeProjectId);
const [targetProjectId, setTargetProjectId] = React.useState<string | null>(null);
const [conflictsOpen, setConflictsOpen] = React.useState(false); const [conflictsOpen, setConflictsOpen] = React.useState(false);
const [conflicts, setConflicts] = React.useState<SkillConflict[]>([]); const [conflicts, setConflicts] = React.useState<SkillConflict[]>([]);
const [baseRequest, setBaseRequest] = React.useState<{ const [baseRequest, setBaseRequest] = React.useState<{
@@ -47,16 +52,43 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
scope: 'user' | 'project'; scope: 'user' | 'project';
targetSource: 'opencode' | 'agents'; targetSource: 'opencode' | 'agents';
skillDir: string; skillDir: string;
directoryOverride?: string | null;
} | null>(null); } | null>(null);
React.useEffect(() => { React.useEffect(() => {
if (!open) return; if (!open) return;
setScope('user'); setScope('user');
setTargetSource('opencode'); setTargetSource('opencode');
setTargetProjectId(activeProjectId);
setConflictsOpen(false); setConflictsOpen(false);
setConflicts([]); setConflicts([]);
setBaseRequest(null); setBaseRequest(null);
}, [open]); }, [open, activeProjectId]);
const resolvedTargetProjectId = React.useMemo(() => {
if (projects.length === 0) {
return null;
}
if (targetProjectId && projects.some((p) => p.id === targetProjectId)) {
return targetProjectId;
}
if (activeProjectId && projects.some((p) => p.id === activeProjectId)) {
return activeProjectId;
}
return projects[0]?.id ?? null;
}, [activeProjectId, projects, targetProjectId]);
const directoryOverride = React.useMemo(() => {
if (scope !== 'project') {
return null;
}
const id = resolvedTargetProjectId;
if (!id) {
return null;
}
const project = projects.find((p) => p.id === id);
return project?.path ?? null;
}, [projects, resolvedTargetProjectId, scope]);
const doInstall = async (request: { const doInstall = async (request: {
source: string; source: string;
@@ -64,6 +96,7 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
scope: 'user' | 'project'; scope: 'user' | 'project';
targetSource: 'opencode' | 'agents'; targetSource: 'opencode' | 'agents';
skillDir: string; skillDir: string;
directoryOverride?: string | null;
conflictDecisions?: Record<string, ConflictDecision>; conflictDecisions?: Record<string, ConflictDecision>;
}) => { }) => {
// Build selection with clawdhub metadata if present // Build selection with clawdhub metadata if present
@@ -86,7 +119,7 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
selections: [selection], selections: [selection],
conflictPolicy: 'prompt', conflictPolicy: 'prompt',
conflictDecisions: request.conflictDecisions, conflictDecisions: request.conflictDecisions,
}); }, { directory: request.directoryOverride ?? null });
if (result.ok) { if (result.ok) {
toast.success('Skill installed successfully'); toast.success('Skill installed successfully');
@@ -101,6 +134,7 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
scope: request.scope, scope: request.scope,
targetSource: request.targetSource, targetSource: request.targetSource,
skillDir: request.skillDir, skillDir: request.skillDir,
directoryOverride: request.directoryOverride ?? null,
}); });
setConflicts(result.error.conflicts); setConflicts(result.error.conflicts);
setConflictsOpen(true); setConflictsOpen(true);
@@ -122,7 +156,7 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
return ( return (
<> <>
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg" keyboardAvoid> <DialogContent className="max-w-md" keyboardAvoid>
<DialogHeader> <DialogHeader>
<DialogTitle>Install skill</DialogTitle> <DialogTitle>Install skill</DialogTitle>
<DialogDescription> <DialogDescription>
@@ -130,21 +164,9 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-3"> <div className="mt-2 space-y-3">
{item.warnings?.length ? ( <div className="flex flex-wrap items-center gap-2">
<div className="rounded-lg border bg-muted/30 px-3 py-2"> <span className="typography-ui-label text-foreground">Destination</span>
<div className="typography-micro text-muted-foreground">Warnings</div>
<ul className="mt-1 space-y-1">
{item.warnings.map((w) => (
<li key={w} className="typography-meta text-muted-foreground">{w}</li>
))}
</ul>
</div>
) : null}
</div>
<DialogFooter className="flex flex-col gap-3 sm:flex-row sm:justify-between sm:items-center">
<div className="flex items-center gap-2">
<Select <Select
value={locationValueFrom(scope, targetSource)} value={locationValueFrom(scope, targetSource)}
onValueChange={(v) => { onValueChange={(v) => {
@@ -153,23 +175,21 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
setTargetSource(next.source === 'agents' ? 'agents' : 'opencode'); setTargetSource(next.source === 'agents' ? 'agents' : 'opencode');
}} }}
> >
<SelectTrigger className="!h-9 w-full sm:w-auto"> <SelectTrigger className="w-fit gap-1.5">
<span className="flex items-center gap-2 whitespace-nowrap"> {scope === 'user' ? <RiUser3Line className="h-3.5 w-3.5" /> : <RiFolderLine className="h-3.5 w-3.5" />}
{scope === 'user' ? <RiUser3Line className="h-4 w-4" /> : <RiFolderLine className="h-4 w-4" />} {targetSource === 'agents' ? <RiRobot2Line className="h-3.5 w-3.5" /> : null}
{targetSource === 'agents' ? <RiRobot2Line className="h-4 w-4" /> : null}
<span>{locationLabel(scope, targetSource)}</span> <span>{locationLabel(scope, targetSource)}</span>
</span>
</SelectTrigger> </SelectTrigger>
<SelectContent align="start"> <SelectContent align="start">
{SKILL_LOCATION_OPTIONS.map((option) => ( {SKILL_LOCATION_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value} className="pr-2 [&>span:first-child]:hidden"> <SelectItem key={option.value} value={option.value} className="pr-2 [&>span:first-child]:hidden">
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{option.scope === 'user' ? <RiUser3Line className="h-4 w-4" /> : <RiFolderLine className="h-4 w-4" />} {option.scope === 'user' ? <RiUser3Line className="h-3.5 w-3.5" /> : <RiFolderLine className="h-3.5 w-3.5" />}
{option.source === 'agents' ? <RiRobot2Line className="h-4 w-4" /> : null} {option.source === 'agents' ? <RiRobot2Line className="h-3.5 w-3.5" /> : null}
<span>{option.label}</span> <span>{option.label}</span>
</div> </div>
<span className="typography-micro text-muted-foreground ml-6">{option.description}</span> <span className="typography-micro text-muted-foreground ml-5">{option.description}</span>
</div> </div>
</SelectItem> </SelectItem>
))} ))}
@@ -177,14 +197,48 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
</Select> </Select>
</div> </div>
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end"> {scope === 'project' && (
<Button className="w-full sm:w-auto" variant="ghost" onClick={() => onOpenChange(false)}> <div className="flex flex-wrap items-center gap-2">
<span className="typography-ui-label text-foreground">Project</span>
{projects.length === 0 ? (
<span className="typography-meta text-muted-foreground">No projects available</span>
) : (
<Select
value={resolvedTargetProjectId ?? ''}
onValueChange={(v) => setTargetProjectId(v)}
disabled={projects.length === 1}
>
<SelectTrigger className="w-fit">
<SelectValue placeholder="Choose project" />
</SelectTrigger>
<SelectContent align="start">
{projects.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.label || p.path}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
)}
{item.warnings?.length ? (
<div className="typography-micro text-[var(--status-warning)] bg-[var(--status-warning)]/10 px-2 py-1.5 rounded">
{item.warnings.join(' · ')}
</div>
) : null}
</div>
<DialogFooter>
<ButtonLarge
variant="ghost"
onClick={() => onOpenChange(false)}
>
Cancel Cancel
</Button> </ButtonLarge>
<Button <ButtonLarge
className="w-full sm:w-auto" disabled={isInstalling || !item.installable || (scope === 'project' && !directoryOverride)}
variant="default"
disabled={isInstalling || !item.installable}
onClick={() => onClick={() =>
void doInstall({ void doInstall({
source: item.repoSource, source: item.repoSource,
@@ -192,12 +246,12 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
scope, scope,
targetSource, targetSource,
skillDir: item.skillDir, skillDir: item.skillDir,
directoryOverride,
}) })
} }
> >
{isInstalling ? 'Installing…' : 'Install'} {isInstalling ? 'Installing...' : 'Install'}
</Button> </ButtonLarge>
</div>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -215,6 +269,7 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
targetSource: baseRequest.targetSource, targetSource: baseRequest.targetSource,
skillDir: baseRequest.skillDir, skillDir: baseRequest.skillDir,
conflictDecisions: decisions, conflictDecisions: decisions,
directoryOverride: baseRequest.directoryOverride ?? null,
}); });
setConflictsOpen(false); setConflictsOpen(false);
}} }}
@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { Button } from '@/components/ui/button'; import { ButtonSmall } from '@/components/ui/button-small';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { AnimatedTabs } from '@/components/ui/animated-tabs'; import { AnimatedTabs } from '@/components/ui/animated-tabs';
@@ -12,16 +12,19 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { ButtonLarge } from '@/components/ui/button-large';
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { RiAddLine, RiDeleteBinLine, RiRefreshLine, RiDownloadLine, RiStarLine } from '@remixicon/react'; import { RiAddLine, RiDeleteBinLine, RiRefreshLine, RiDownloadLine, RiStarLine, RiSearchLine } from '@remixicon/react';
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore'; import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
import { cn } from '@/lib/utils';
import type { SkillsCatalogItem } from '@/lib/api/types'; import type { SkillsCatalogItem } from '@/lib/api/types';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
@@ -36,6 +39,7 @@ type SkillsMode = 'manual' | 'external';
interface SkillsCatalogPageProps { interface SkillsCatalogPageProps {
mode: SkillsMode; mode: SkillsMode;
onModeChange: (mode: SkillsMode) => void; onModeChange: (mode: SkillsMode) => void;
showModeTabs?: boolean;
} }
const loadSettings = async (): Promise<DesktopSettings | null> => { const loadSettings = async (): Promise<DesktopSettings | null> => {
@@ -61,7 +65,7 @@ const loadSettings = async (): Promise<DesktopSettings | null> => {
} }
}; };
export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onModeChange }) => { export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onModeChange, showModeTabs = true }) => {
const { const {
sources, sources,
itemsBySource, itemsBySource,
@@ -142,8 +146,12 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
return ( return (
<ScrollableOverlay keyboardAvoid outerClassName="h-full" className="w-full"> <ScrollableOverlay keyboardAvoid outerClassName="h-full" className="w-full">
<div className="mx-auto max-w-3xl space-y-6 p-6"> <div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8">
<div className="space-y-3">
{/* Header */}
<div className="mb-4">
{showModeTabs && (
<div className="mb-4">
<AnimatedTabs <AnimatedTabs
tabs={[ tabs={[
{ value: 'manual', label: 'Manual' }, { value: 'manual', label: 'Manual' },
@@ -153,40 +161,39 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
onValueChange={onModeChange} onValueChange={onModeChange}
animate={false} animate={false}
/> />
<div className="space-y-1">
<h1 className="typography-ui-header font-semibold text-lg">Skills Catalog</h1>
<p className="typography-meta text-muted-foreground">
Browse curated repositories and install skills into your OpenCode configuration.
</p>
</div> </div>
)}
<h2 className="typography-ui-header font-semibold text-foreground px-1">Skills Catalog</h2>
</div> </div>
<div className="space-y-3"> {/* Source & Search */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between"> <div className="mb-8">
<div className="flex-1 space-y-2"> <div className="mb-1 px-1">
<label className="typography-ui-label font-medium text-foreground">Source</label> <h3 className="typography-ui-header font-medium text-foreground">Source Repository</h3>
</div>
<section className="px-2 pb-2 pt-0 space-y-0">
<div className="flex flex-wrap items-center gap-2 py-1.5">
<Select <Select
value={selectedSourceId || ''} value={selectedSourceId || ''}
onValueChange={(v) => setSelectedSource(v)} onValueChange={(v) => setSelectedSource(v)}
> >
<SelectTrigger className="!h-9 w-full justify-between"> <SelectTrigger className="w-fit">
<span>{selectedSource?.label || 'Select source'}</span> <SelectValue placeholder="Select source" />
</SelectTrigger> </SelectTrigger>
<SelectContent align="start"> <SelectContent align="start">
{sources.map((src) => ( {sources.map((src) => (
<SelectItem key={src.id} value={src.id} className="pr-2 [&>span:first-child]:hidden"> <SelectItem key={src.id} value={src.id}>
{src.label} {src.label}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div>
<div className="flex items-center gap-2"> <ButtonSmall
<Button
type="button"
variant="outline" variant="outline"
size="xs"
className="!font-normal h-6 w-6 px-0"
onClick={() => { onClick={() => {
if (selectedSourceId) { if (selectedSourceId) {
void loadSource(selectedSourceId, { refresh: true }); void loadSource(selectedSourceId, { refresh: true });
@@ -195,124 +202,131 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
} }
}} }}
disabled={isLoadingCatalog || isLoadingSource} disabled={isLoadingCatalog || isLoadingSource}
className="gap-2" title="Refresh"
> >
<RiRefreshLine className="h-4 w-4" /> <RiRefreshLine className={cn("h-3.5 w-3.5", (isLoadingCatalog || isLoadingSource) && "animate-spin")} />
Refresh </ButtonSmall>
</Button>
{isCustomSource ? ( {isCustomSource && (
<Button <ButtonSmall
type="button" variant="ghost"
variant="outline" size="xs"
className="!font-normal h-6 w-6 px-0 text-[var(--status-error)] hover:text-[var(--status-error)]"
onClick={() => setIsRemoveCatalogDialogOpen(true)} onClick={() => setIsRemoveCatalogDialogOpen(true)}
disabled={isRemovingCatalog} disabled={isRemovingCatalog}
className="gap-2" title="Remove Catalog"
> >
<RiDeleteBinLine className="h-4 w-4" /> <RiDeleteBinLine className="h-3.5 w-3.5" />
Remove </ButtonSmall>
</Button> )}
) : null}
<Button <ButtonSmall
type="button" size="xs"
variant="default" className="!font-normal gap-1"
onClick={() => setAddCatalogOpen(true)} onClick={() => setAddCatalogOpen(true)}
className="gap-2"
> >
<RiAddLine className="h-4 w-4" /> <RiAddLine className="h-3.5 w-3.5" /> Add Catalog
Add catalog </ButtonSmall>
</Button>
</div>
</div> </div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> <div className="py-1.5">
<div className="relative">
<RiSearchLine className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input <Input
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
placeholder="Search skills…" placeholder="Search skills..."
className="max-w-md" className="h-7 pl-8 w-full sm:w-64"
/> />
<div className="typography-meta text-muted-foreground">
{isLoadingCatalog ? 'Loading…' : `${filtered.length} skill(s)`}
</div> </div>
<span className="typography-meta text-muted-foreground mt-1 block">
{isLoadingCatalog ? 'Loading...' : `${filtered.length} skill(s) found`}
</span>
</div>
</section>
</div> </div>
{lastCatalogError ? ( {/* Error State */}
<div className="rounded-lg border bg-muted/20 px-3 py-2"> {lastCatalogError && (
<div className="typography-ui-label font-medium text-foreground">Catalog error</div> <div className="mb-8 rounded-lg border border-[var(--status-error-border)] bg-[var(--status-error-background)] px-4 py-3">
<div className="typography-meta text-muted-foreground mt-1">{lastCatalogError.message}</div> <div className="typography-ui-label font-medium text-[var(--status-error)]">Catalog error</div>
</div> <div className="typography-meta text-[var(--status-error)]/80 mt-1">{lastCatalogError.message}</div>
) : null}
</div> </div>
)}
<div className="space-y-2"> {/* Skills List */}
<div className="mb-8">
<section className="px-2 pb-2 pt-0">
{filtered.length === 0 && !isLoadingSource ? ( {filtered.length === 0 && !isLoadingSource ? (
<div className="py-10 text-center text-muted-foreground"> <div className="py-8 text-center text-muted-foreground">
<p className="typography-body">No skills found</p> <p className="typography-body">No skills found</p>
<p className="typography-meta mt-1 opacity-75">Try a different search or refresh the catalog</p> <p className="typography-meta mt-1 opacity-75">Try a different search or refresh the catalog</p>
</div> </div>
) : isLoadingSource ? ( ) : isLoadingSource ? (
<div className="py-10 text-center text-muted-foreground"> <div className="py-8 text-center text-muted-foreground">
<p className="typography-body">Loading skills…</p> <RiRefreshLine className="mx-auto mb-3 h-5 w-5 animate-spin opacity-50" />
<p className="typography-meta">Loading skills...</p>
</div> </div>
) : ( ) : (
<> <div className="divide-y divide-[var(--surface-subtle)]">
{filtered.map((item) => { {filtered.map((item) => {
const installed = item.installed?.isInstalled; const installed = item.installed?.isInstalled;
const installedScope = item.installed?.scope; const installedScope = item.installed?.scope;
return ( return (
<div <div key={`${item.sourceId}:${item.skillDir}`} className="py-2">
key={`${item.sourceId}:${item.skillDir}`} <div className="flex items-start justify-between gap-4">
className="rounded-lg border bg-muted/10 px-3 py-2" <div className="min-w-0 flex-1">
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="typography-ui-label truncate">{item.skillName}</div> <span className="typography-ui-label font-medium text-foreground truncate">{item.skillName}</span>
{installed ? ( {installed && (
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50"> <span className="typography-micro text-[var(--status-success)] bg-[var(--status-success)]/10 px-1.5 py-0.5 rounded flex-shrink-0">
installed ({installedScope || 'unknown'}) installed ({installedScope || 'unknown'})
</span> </span>
) : null} )}
{!item.installable ? ( {!item.installable && (
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50"> <span className="typography-micro text-[var(--status-warning)] bg-[var(--status-warning)]/10 px-1.5 py-0.5 rounded flex-shrink-0">
not installable not installable
</span> </span>
) : null} )}
</div> </div>
{item.description ? ( {item.description ? (
<div className="typography-meta text-muted-foreground mt-0.5 line-clamp-2">{item.description}</div> <div className="typography-meta text-muted-foreground mt-0.5 line-clamp-2">{item.description}</div>
) : ( ) : (
<div className="typography-micro text-muted-foreground mt-0.5">No description provided</div> <div className="typography-meta text-muted-foreground/50 mt-0.5 italic">No description provided</div>
)}
{item.clawdhub && (
<div className="typography-micro text-muted-foreground mt-1.5 flex items-center gap-3">
{item.clawdhub.owner && (
<span>by <span className="font-medium text-foreground/80">{item.clawdhub.owner}</span></span>
)} )}
{item.clawdhub ? (
<div className="typography-micro text-muted-foreground mt-1 flex items-center gap-3">
{item.clawdhub.owner ? (
<span>by {item.clawdhub.owner}</span>
) : null}
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<RiDownloadLine className="h-3 w-3" /> <RiDownloadLine className="h-3 w-3" />
{item.clawdhub.downloads?.toLocaleString() ?? 0} {item.clawdhub.downloads?.toLocaleString() ?? 0}
</span> </span>
{(item.clawdhub.stars ?? 0) > 0 ? ( {(item.clawdhub.stars ?? 0) > 0 && (
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<RiStarLine className="h-3 w-3" /> <RiStarLine className="h-3 w-3" />
{item.clawdhub.stars} {item.clawdhub.stars}
</span> </span>
) : null} )}
<span>v{item.clawdhub.version}</span> <span className="bg-[var(--surface-muted)] px-1.5 py-0.5 rounded">v{item.clawdhub.version}</span>
</div> </div>
) : null} )}
{item.warnings?.length ? ( {item.warnings?.length ? (
<div className="typography-micro text-muted-foreground mt-1">{item.warnings.join(' · ')}</div> <div className="typography-micro text-[var(--status-warning)] mt-1.5 bg-[var(--status-warning)]/10 px-2 py-1 rounded w-fit">
{item.warnings.join(' · ')}
</div>
) : null} ) : null}
</div> </div>
<Button <ButtonSmall
type="button"
variant="outline" variant="outline"
size="sm" size="xs"
className="gap-2" className="!font-normal shrink-0"
disabled={!item.installable} disabled={!item.installable}
onClick={() => { onClick={() => {
setInstallItem(item); setInstallItem(item);
@@ -320,29 +334,34 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
}} }}
> >
Install Install
</Button> </ButtonSmall>
</div> </div>
</div> </div>
); );
})} })}
{isClawdHubSource && hasMoreClawdHub ? (
<div className="flex justify-center pt-2">
<Button
type="button"
variant="outline"
onClick={() => void loadMoreClawdHub()}
disabled={isLoadingMore || isLoadingSource}
>
{isLoadingMore ? 'Loading…' : 'Load more'}
</Button>
</div> </div>
) : null} )}
</> </section>
{isClawdHubSource && hasMoreClawdHub && !isLoadingSource && filtered.length > 0 && (
<div className="flex justify-center mt-2 px-2">
<ButtonSmall
variant="outline"
size="xs"
className="!font-normal"
onClick={() => void loadMoreClawdHub()}
disabled={isLoadingMore}
>
{isLoadingMore ? 'Loading...' : 'Load More Skills'}
</ButtonSmall>
</div>
)} )}
</div> </div>
{/* Dialogs */}
<AddCatalogDialog open={addCatalogOpen} onOpenChange={setAddCatalogOpen} /> <AddCatalogDialog open={addCatalogOpen} onOpenChange={setAddCatalogOpen} />
<InstallSkillDialog open={installDialogOpen} onOpenChange={setInstallDialogOpen} item={installItem} /> <InstallSkillDialog open={installDialogOpen} onOpenChange={setInstallDialogOpen} item={installItem} />
<Dialog <Dialog
open={isRemoveCatalogDialogOpen} open={isRemoveCatalogDialogOpen}
onOpenChange={(open) => { onOpenChange={(open) => {
@@ -357,19 +376,20 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
<DialogDescription>Are you sure you want to remove this catalog?</DialogDescription> <DialogDescription>Are you sure you want to remove this catalog?</DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button <ButtonLarge
variant="ghost" variant="ghost"
onClick={() => setIsRemoveCatalogDialogOpen(false)} onClick={() => setIsRemoveCatalogDialogOpen(false)}
disabled={isRemovingCatalog} disabled={isRemovingCatalog}
> >
Cancel Cancel
</Button> </ButtonLarge>
<Button variant="destructive" onClick={() => void removeSelectedCatalog()} disabled={isRemovingCatalog}> <ButtonLarge className="bg-[var(--status-error)] hover:bg-[var(--status-error)]/90 text-white" onClick={() => void removeSelectedCatalog()} disabled={isRemovingCatalog}>
Remove Remove Catalog
</Button> </ButtonLarge>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </div>
</ScrollableOverlay> </ScrollableOverlay>
); );
@@ -4,7 +4,7 @@ import { formatPercent, formatWindowLabel, calculatePace, calculateExpectedUsage
import { UsageProgressBar } from './UsageProgressBar'; import { UsageProgressBar } from './UsageProgressBar';
import { PaceIndicator } from './PaceIndicator'; import { PaceIndicator } from './PaceIndicator';
import { useQuotaStore } from '@/stores/useQuotaStore'; import { useQuotaStore } from '@/stores/useQuotaStore';
import { Switch } from '@/components/ui/switch'; import { Checkbox } from '@/components/ui/checkbox';
interface UsageCardProps { interface UsageCardProps {
title: string; title: string;
@@ -30,67 +30,63 @@ export const UsageCard: React.FC<UsageCardProps> = ({
const resetLabel = window.resetAfterFormatted ?? window.resetAtFormatted ?? ''; const resetLabel = window.resetAfterFormatted ?? window.resetAtFormatted ?? '';
const windowLabel = formatWindowLabel(title); const windowLabel = formatWindowLabel(title);
// Calculate pace info for the usage window
// Pass the title (window label) to infer windowSeconds when not provided by the API
const paceInfo = React.useMemo(() => { const paceInfo = React.useMemo(() => {
return calculatePace(window.usedPercent, window.resetAt, window.windowSeconds, title); return calculatePace(window.usedPercent, window.resetAt, window.windowSeconds, title);
}, [window.usedPercent, window.resetAt, window.windowSeconds, title]); }, [window.usedPercent, window.resetAt, window.windowSeconds, title]);
// Calculate expected marker position for weekly/monthly quotas
const expectedMarkerPercent = React.useMemo(() => { const expectedMarkerPercent = React.useMemo(() => {
if (!paceInfo || paceInfo.dailyAllocationPercent === null) { if (!paceInfo || paceInfo.dailyAllocationPercent === null) {
return null; return null;
} }
// Show marker based on elapsed time ratio
const expectedUsed = calculateExpectedUsagePercent(paceInfo.elapsedRatio); const expectedUsed = calculateExpectedUsagePercent(paceInfo.elapsedRatio);
// If displaying remaining, invert the marker position
return displayMode === 'remaining' ? 100 - expectedUsed : expectedUsed; return displayMode === 'remaining' ? 100 - expectedUsed : expectedUsed;
}, [paceInfo, displayMode]); }, [paceInfo, displayMode]);
return ( return (
<div className="rounded-xl border border-[var(--interactive-border)] bg-[var(--surface-elevated)]/60 p-4 shadow-sm"> <div className="py-3">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1 flex items-center gap-2">
<div className="typography-ui-label text-foreground truncate">{windowLabel}</div> {showToggle && (
<Checkbox
checked={toggleEnabled}
onChange={(checked) => onToggle?.(checked)}
ariaLabel="Show in dropdown"
/>
)}
<div className="min-w-0 flex flex-col">
<span className="typography-ui-label text-foreground truncate">{windowLabel}</span>
{subtitle && ( {subtitle && (
<div className="typography-micro text-muted-foreground truncate">{subtitle}</div> <span className="typography-meta text-muted-foreground truncate">{subtitle}</span>
)} )}
</div> </div>
{showToggle ? ( </div>
<Switch <div className="typography-ui-label text-foreground tabular-nums flex items-center justify-end">
checked={toggleEnabled}
onCheckedChange={onToggle}
aria-label="Show in dropdown"
/>
) : (
<div className="typography-ui-label text-foreground tabular-nums">
{percentLabel === '-' ? '' : percentLabel} {percentLabel === '-' ? '' : percentLabel}
</div> </div>
)}
</div> </div>
<div className="mt-3"> <div className="mt-2.5">
<UsageProgressBar <UsageProgressBar
percent={displayPercent} percent={displayPercent}
tonePercent={window.usedPercent} tonePercent={window.usedPercent}
expectedMarkerPercent={expectedMarkerPercent} expectedMarkerPercent={expectedMarkerPercent}
className="h-1.5"
/> />
<div className="mt-1 text-right typography-micro text-muted-foreground text-[10px]"> <div className="mt-1 flex items-center justify-between">
<span className="typography-micro text-muted-foreground">
{resetLabel ? `Resets ${resetLabel}` : ''}
</span>
<span className="typography-micro text-muted-foreground">
{barLabel} {barLabel}
</span>
</div> </div>
</div> </div>
{/* Pace indicator - only shown when we have pace info */}
{paceInfo && ( {paceInfo && (
<div className="mt-2"> <div className="mt-1.5">
<PaceIndicator paceInfo={paceInfo} /> <PaceIndicator paceInfo={paceInfo} />
</div> </div>
)} )}
<div className="mt-3 flex items-center justify-between text-muted-foreground">
<span className="typography-micro">Resets</span>
<span className="typography-micro tabular-nums">{resetLabel}</span>
</div>
</div> </div>
); );
}; };
@@ -1,9 +1,9 @@
import React from 'react'; import React from 'react';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Checkbox } from '@/components/ui/checkbox';
import { UsageCard } from './UsageCard'; import { UsageCard } from './UsageCard';
import { QUOTA_PROVIDERS } from '@/lib/quota'; import { QUOTA_PROVIDERS } from '@/lib/quota';
import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore'; import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
import { Switch } from '@/components/ui/switch';
import { updateDesktopSettings } from '@/lib/persistence'; import { updateDesktopSettings } from '@/lib/persistence';
import { ProviderLogo } from '@/components/ui/ProviderLogo'; import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { import {
@@ -11,9 +11,10 @@ import {
CollapsibleContent, CollapsibleContent,
CollapsibleTrigger, CollapsibleTrigger,
} from '@/components/ui/collapsible'; } from '@/components/ui/collapsible';
import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react'; import { RiArrowDownSLine, RiArrowRightSLine, RiInformationLine } from '@remixicon/react';
import type { UsageWindows, QuotaProviderId } from '@/types'; import type { UsageWindows, QuotaProviderId } from '@/types';
import { getAllModelFamilies, getDisplayModelName, sortModelFamilies, groupModelsByFamilyWithGetter } from '@/lib/quota/model-families'; import { getAllModelFamilies, getDisplayModelName, sortModelFamilies, groupModelsByFamilyWithGetter } from '@/lib/quota/model-families';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
const formatTime = (timestamp: number | null) => { const formatTime = (timestamp: number | null) => {
if (!timestamp) return '-'; if (!timestamp) return '-';
@@ -83,7 +84,6 @@ export const UsagePage: React.FC = () => {
void updateDesktopSettings({ usageDropdownProviders: next }); void updateDesktopSettings({ usageDropdownProviders: next });
}, [dropdownProviderIds, selectedProviderId, setDropdownProviderIds]); }, [dropdownProviderIds, selectedProviderId, setDropdownProviderIds]);
// Get models for the selected provider
const providerModels = React.useMemo((): ModelInfo[] => { const providerModels = React.useMemo((): ModelInfo[] => {
if (!usage?.models) return []; if (!usage?.models) return [];
return Object.entries(usage.models) return Object.entries(usage.models)
@@ -91,14 +91,12 @@ export const UsagePage: React.FC = () => {
.filter((model) => Object.keys(model.windows.windows).length > 0); .filter((model) => Object.keys(model.windows.windows).length > 0);
}, [usage?.models]); }, [usage?.models]);
// Apply default selections on mount if no prior selections exist
React.useEffect(() => { React.useEffect(() => {
if (selectedProviderId && providerModels.length > 0) { if (selectedProviderId && providerModels.length > 0) {
applyDefaultSelections(selectedProviderId, providerModels.map((m) => m.name)); applyDefaultSelections(selectedProviderId, providerModels.map((m) => m.name));
} }
}, [selectedProviderId, providerModels, applyDefaultSelections]); }, [selectedProviderId, providerModels, applyDefaultSelections]);
// Group models by family
const modelsByFamily = React.useMemo(() => { const modelsByFamily = React.useMemo(() => {
if (!selectedProviderId || providerModels.length === 0) { if (!selectedProviderId || providerModels.length === 0) {
return new Map<string | null, ModelInfo[]>(); return new Map<string | null, ModelInfo[]>();
@@ -110,16 +108,13 @@ export const UsagePage: React.FC = () => {
); );
}, [providerModels, selectedProviderId]); }, [providerModels, selectedProviderId]);
// Get sorted families
const sortedFamilies = React.useMemo(() => { const sortedFamilies = React.useMemo(() => {
if (!selectedProviderId) return []; if (!selectedProviderId) return [];
const families = getAllModelFamilies(selectedProviderId as QuotaProviderId); const families = getAllModelFamilies(selectedProviderId as QuotaProviderId);
return sortModelFamilies(families); return sortModelFamilies(families);
}, [selectedProviderId]); }, [selectedProviderId]);
// Collapsible state for family sections (persist per provider)
const [collapsedFamilies, setCollapsedFamilies] = React.useState<Record<string, boolean>>(() => { const [collapsedFamilies, setCollapsedFamilies] = React.useState<Record<string, boolean>>(() => {
// Default: all families start expanded (not collapsed)
return {}; return {};
}); });
@@ -133,7 +128,6 @@ export const UsagePage: React.FC = () => {
const handleModelToggle = React.useCallback((modelName: string) => { const handleModelToggle = React.useCallback((modelName: string) => {
if (!selectedProviderId) return; if (!selectedProviderId) return;
toggleModelSelected(selectedProviderId, modelName); toggleModelSelected(selectedProviderId, modelName);
// Also update settings to persist
const currentSelected = selectedModels[selectedProviderId] ?? []; const currentSelected = selectedModels[selectedProviderId] ?? [];
const isSelected = currentSelected.includes(modelName); const isSelected = currentSelected.includes(modelName);
const nextSelected = isSelected const nextSelected = isSelected
@@ -143,7 +137,6 @@ export const UsagePage: React.FC = () => {
void updateDesktopSettings({ usageSelectedModels: nextSettings }); void updateDesktopSettings({ usageSelectedModels: nextSettings });
}, [selectedProviderId, selectedModels, toggleModelSelected]); }, [selectedProviderId, selectedModels, toggleModelSelected]);
// Get selected models for this provider
const providerSelectedModels = selectedProviderId ? (selectedModels[selectedProviderId] ?? []) : []; const providerSelectedModels = selectedProviderId ? (selectedModels[selectedProviderId] ?? []) : [];
if (!selectedProviderId) { if (!selectedProviderId) {
@@ -156,71 +149,104 @@ export const UsagePage: React.FC = () => {
return ( return (
<ScrollableOverlay keyboardAvoid outerClassName="h-full" className="w-full"> <ScrollableOverlay keyboardAvoid outerClassName="h-full" className="w-full">
<div className="mx-auto max-w-3xl space-y-6 p-6"> <div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8">
<div className="space-y-2">
<div className="flex items-start justify-between gap-3"> {/* Header */}
<div className="space-y-1"> <div className="mb-4 flex items-center gap-3">
<div className="flex items-center gap-2"> <ProviderLogo providerId={selectedProviderId} className="h-5 w-5 shrink-0" />
<ProviderLogo providerId={selectedProviderId} className="h-5 w-5" /> <div className="min-w-0">
<h1 className="typography-ui-header font-semibold text-lg">{providerName} Usage</h1> <h2 className="typography-ui-header font-semibold text-foreground truncate">
</div> {providerName} Usage
<p className="typography-meta text-muted-foreground"> </h2>
{isLoading ? 'Refreshing usage...' : `Last updated ${formatTime(lastUpdated)}`} <p className="typography-meta text-muted-foreground truncate">
{isLoading ? (
<span className="animate-pulse">Refreshing usage...</span>
) : (
`Last updated: ${formatTime(lastUpdated)}`
)}
</p> </p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2">
<span className="typography-micro text-muted-foreground">Show in dropdown</span> {/* Options */}
<Switch <div 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} checked={showInDropdown}
onCheckedChange={handleDropdownToggle} onChange={handleDropdownToggle}
aria-label={`Show ${providerName} in usage dropdown`} ariaLabel="Show in header menu"
className="data-[state=checked]:bg-[var(--status-info)]"
/> />
<div className="flex min-w-0 items-center gap-1.5">
<span className="typography-ui-label text-foreground">Show in Header Menu</span>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
When enabled, this provider's usage will be visible in the quick access dropdown menu in the app header.
</TooltipContent>
</Tooltip>
</div>
</div> </div>
</div> </div>
{/* State Messages */}
{!selectedResult && ( {!selectedResult && (
<div className="rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)]/60 p-4 text-muted-foreground"> <div className="mb-8 px-2">
<p className="typography-body">No usage data available yet.</p> <p className="typography-ui-label text-foreground">No usage data available yet.</p>
</div> </div>
)} )}
{error && ( {error && (
<div className="rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)]/60 p-4 text-muted-foreground"> <div className="mb-8 rounded-lg border border-[var(--status-error-border)] bg-[var(--status-error-background)] px-4 py-3">
<p className="typography-body">Failed to refresh usage data.</p> <p className="typography-ui-label font-medium text-[var(--status-error)]">Failed to refresh usage data</p>
<p className="typography-meta mt-1">{error}</p> <p className="typography-meta text-[var(--status-error)]/80 mt-1">{error}</p>
</div> </div>
)} )}
{selectedResult && !selectedResult.configured && ( {selectedResult && !selectedResult.configured && (
<div className="rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)]/60 p-4 text-muted-foreground"> <div className="mb-8 rounded-lg border border-[var(--status-warning-border)] bg-[var(--status-warning-background)] px-4 py-3">
<p className="typography-body">Provider is not configured yet.</p> <p className="typography-ui-label font-medium text-[var(--status-warning)]">Provider not configured</p>
<p className="typography-meta mt-1"> <p className="typography-meta text-[var(--status-warning)]/80 mt-1">
Add credentials in the Providers tab to enable usage tracking. Add credentials in the Providers tab to enable usage tracking.
</p> </p>
</div> </div>
)} )}
{/* Overall Usage Windows */}
{usage?.windows && Object.keys(usage.windows).length > 0 && ( {usage?.windows && Object.keys(usage.windows).length > 0 && (
<div className="space-y-3"> <div 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]) => ( {Object.entries(usage.windows).map(([label, window]) => (
<UsageCard key={label} title={label} window={window} /> <UsageCard key={label} title={label} window={window} />
))} ))}
</div> </div>
</section>
</div>
)} )}
{/* Models Section - Grouped by Family */} {/* Models Section */}
{providerModels.length > 0 && ( {providerModels.length > 0 && (
<div className="space-y-4"> <div className="mb-8">
<div className="space-y-1"> <div className="mb-1 px-1">
<h2 className="typography-ui-header font-semibold text-foreground">Models</h2> <h3 className="typography-ui-header font-medium text-foreground">Model Quotas</h3>
<p className="typography-meta text-muted-foreground">
Toggle on to show in header dropdown
</p>
</div> </div>
{/* Predefined families (Gemini, Claude) */} <div className="space-y-3">
{/* Predefined families */}
{sortedFamilies.map((family) => { {sortedFamilies.map((family) => {
const familyModels = modelsByFamily.get(family.id) ?? []; const familyModels = modelsByFamily.get(family.id) ?? [];
if (familyModels.length === 0) return null; if (familyModels.length === 0) return null;
@@ -228,25 +254,26 @@ export const UsagePage: React.FC = () => {
const isCollapsed = collapsedFamilies[family.id] ?? false; const isCollapsed = collapsedFamilies[family.id] ?? false;
return ( return (
<section key={family.id} className="p-2">
<Collapsible <Collapsible
key={family.id}
open={!isCollapsed} open={!isCollapsed}
onOpenChange={() => toggleFamilyCollapsed(family.id)} onOpenChange={() => toggleFamilyCollapsed(family.id)}
> >
<CollapsibleTrigger className="flex w-full items-center justify-between py-2 text-left group"> <CollapsibleTrigger className="flex w-full items-center justify-between py-0.5 group">
<div className="space-y-0.5"> <div className="flex items-center gap-1.5 text-left">
<div className="typography-ui-label font-semibold text-foreground">{family.label}</div> <span className="typography-ui-label font-normal text-foreground">{family.label}</span>
<p className="typography-meta text-muted-foreground"> <span className="typography-micro text-muted-foreground">
{familyModels.length} model{familyModels.length !== 1 ? 's' : ''} ({familyModels.length})
</p> </span>
</div> </div>
{isCollapsed ? ( {isCollapsed ? (
<RiArrowRightSLine className="h-5 w-5 text-muted-foreground group-hover:text-foreground transition-colors" /> <RiArrowRightSLine className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors" />
) : ( ) : (
<RiArrowDownSLine className="h-5 w-5 text-muted-foreground group-hover:text-foreground transition-colors" /> <RiArrowDownSLine className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors" />
)} )}
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent className="pt-2 space-y-3"> <CollapsibleContent>
<div className="divide-y divide-[var(--surface-subtle)] mt-1">
{familyModels.map((model) => { {familyModels.map((model) => {
const entries = Object.entries(model.windows.windows); const entries = Object.entries(model.windows.windows);
if (entries.length === 0) return null; if (entries.length === 0) return null;
@@ -265,8 +292,10 @@ export const UsagePage: React.FC = () => {
/> />
); );
})} })}
</div>
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
</section>
); );
})} })}
@@ -278,24 +307,26 @@ export const UsagePage: React.FC = () => {
const isCollapsed = collapsedFamilies['other'] ?? false; const isCollapsed = collapsedFamilies['other'] ?? false;
return ( return (
<section className="p-2">
<Collapsible <Collapsible
open={!isCollapsed} open={!isCollapsed}
onOpenChange={() => toggleFamilyCollapsed('other')} onOpenChange={() => toggleFamilyCollapsed('other')}
> >
<CollapsibleTrigger className="flex w-full items-center justify-between py-2 text-left group"> <CollapsibleTrigger className="flex w-full items-center justify-between py-0.5 group">
<div className="space-y-0.5"> <div className="flex items-center gap-1.5 text-left">
<div className="typography-ui-label font-semibold text-foreground">Other</div> <span className="typography-ui-label font-normal text-foreground">Other Models</span>
<p className="typography-meta text-muted-foreground"> <span className="typography-micro text-muted-foreground">
{otherModels.length} model{otherModels.length !== 1 ? 's' : ''} ({otherModels.length})
</p> </span>
</div> </div>
{isCollapsed ? ( {isCollapsed ? (
<RiArrowRightSLine className="h-5 w-5 text-muted-foreground group-hover:text-foreground transition-colors" /> <RiArrowRightSLine className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors" />
) : ( ) : (
<RiArrowDownSLine className="h-5 w-5 text-muted-foreground group-hover:text-foreground transition-colors" /> <RiArrowDownSLine className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors" />
)} )}
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent className="pt-2 space-y-3"> <CollapsibleContent>
<div className="divide-y divide-[var(--surface-subtle)] mt-1">
{otherModels.map((model) => { {otherModels.map((model) => {
const entries = Object.entries(model.windows.windows); const entries = Object.entries(model.windows.windows);
if (entries.length === 0) return null; if (entries.length === 0) return null;
@@ -314,19 +345,24 @@ export const UsagePage: React.FC = () => {
/> />
); );
})} })}
</div>
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
</section>
); );
})()} })()}
</div> </div>
</div>
)} )}
{selectedResult?.configured && usage && Object.keys(usage.windows ?? {}).length === 0 && {selectedResult?.configured && usage && Object.keys(usage.windows ?? {}).length === 0 &&
providerModels.length === 0 && ( providerModels.length === 0 && (
<div className="rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)]/60 p-4 text-muted-foreground"> <div className="mb-8 px-2">
<p className="typography-body">No quota windows reported for this provider.</p> <p className="typography-ui-label text-foreground">No quota windows reported</p>
<p className="typography-meta text-muted-foreground mt-1">This provider does not currently report any rate limits or usage quotas.</p>
</div> </div>
)} )}
</div> </div>
</ScrollableOverlay> </ScrollableOverlay>
); );
@@ -1,12 +1,10 @@
import React from 'react'; import React from 'react';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { ProviderLogo } from '@/components/ui/ProviderLogo'; import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { Button } from '@/components/ui/button'; import { ButtonSmall } from '@/components/ui/button-small';
import { Switch } from '@/components/ui/switch'; import { Checkbox } from '@/components/ui/checkbox';
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useDeviceInfo } from '@/lib/device';
import { isVSCodeRuntime } from '@/lib/desktop';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { QUOTA_PROVIDERS, resolveUsageTone } from '@/lib/quota'; import { QUOTA_PROVIDERS, resolveUsageTone } from '@/lib/quota';
import { useQuotaStore } from '@/stores/useQuotaStore'; import { useQuotaStore } from '@/stores/useQuotaStore';
@@ -41,9 +39,6 @@ export const UsageSidebar: React.FC<UsageSidebarProps> = ({ onItemSelect }) => {
const setUsageRefreshInterval = useQuotaStore((state) => state.setRefreshInterval); const setUsageRefreshInterval = useQuotaStore((state) => state.setRefreshInterval);
const setUsageDisplayMode = useQuotaStore((state) => state.setDisplayMode); const setUsageDisplayMode = useQuotaStore((state) => state.setDisplayMode);
const loadUsageSettings = useQuotaStore((state) => state.loadSettings); const loadUsageSettings = useQuotaStore((state) => state.loadSettings);
const { isMobile } = useDeviceInfo();
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
React.useEffect(() => { React.useEffect(() => {
void loadUsageSettings(); void loadUsageSettings();
@@ -79,22 +74,22 @@ export const UsageSidebar: React.FC<UsageSidebarProps> = ({ onItemSelect }) => {
void persistUsageSettings({ usageDisplayMode: value }); void persistUsageSettings({ usageDisplayMode: value });
}, [persistUsageSettings, setUsageDisplayMode]); }, [persistUsageSettings, setUsageDisplayMode]);
const bgClass = isVSCode ? 'bg-background' : 'bg-sidebar'; const bgClass = 'bg-background';
return ( return (
<div className={cn('flex h-full flex-col', bgClass)}> <div className={cn('flex h-full flex-col', bgClass)}>
<div className={cn('border-b px-3', isMobile ? 'mt-2 py-3' : 'py-3')}> <div className="border-b px-3 pt-4 pb-3">
<h2 className="text-base font-semibold text-foreground mb-3">Usage</h2>
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground">Total {QUOTA_PROVIDERS.length}</span> <span className="typography-meta text-muted-foreground">Total {QUOTA_PROVIDERS.length}</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Tooltip delayDuration={700}> <Tooltip delayDuration={700}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span className="inline-flex"> <span className="inline-flex">
<Switch <Checkbox
checked={usageAutoRefresh} checked={usageAutoRefresh}
onCheckedChange={handleUsageAutoRefreshChange} onChange={handleUsageAutoRefreshChange}
aria-label="Toggle auto refresh" ariaLabel="Toggle auto refresh"
className="data-[state=checked]:bg-[var(--status-info)]"
/> />
</span> </span>
</TooltipTrigger> </TooltipTrigger>
@@ -107,42 +102,36 @@ export const UsageSidebar: React.FC<UsageSidebarProps> = ({ onItemSelect }) => {
onValueChange={handleUsageRefreshIntervalChange} onValueChange={handleUsageRefreshIntervalChange}
disabled={!usageAutoRefresh} disabled={!usageAutoRefresh}
> >
<SelectTrigger size="sm" className="min-w-[72px]"> <SelectTrigger className="w-fit">
<SelectValue placeholder="Interval" /> <SelectValue placeholder="Interval" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="30000" className="pr-2 [&>span:first-child]:hidden">30s</SelectItem> <SelectItem value="30000">30s</SelectItem>
<SelectItem value="60000" className="pr-2 [&>span:first-child]:hidden">1m</SelectItem> <SelectItem value="60000">1m</SelectItem>
<SelectItem value="300000" className="pr-2 [&>span:first-child]:hidden">5m</SelectItem> <SelectItem value="300000">5m</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<Button <ButtonSmall
type="button"
variant="ghost" variant="ghost"
size="icon" className="h-7 w-7 px-0 text-muted-foreground"
className="h-7 w-7 -my-1 text-muted-foreground overflow-hidden"
onClick={() => fetchAllQuotas()} onClick={() => fetchAllQuotas()}
aria-label="Refresh usage" aria-label="Refresh usage"
title="Refresh usage" title="Refresh usage"
disabled={isLoading} disabled={isLoading}
> >
<RiRefreshLine className={cn('size-4', isLoading && 'animate-spin')} /> <RiRefreshLine className={cn('h-3.5 w-3.5', isLoading && 'animate-spin')} />
</Button> </ButtonSmall>
</div> </div>
</div> </div>
<div className="mt-2 flex items-center justify-between gap-2"> <div className="mt-2 flex items-center justify-between gap-2">
<span className="typography-micro text-muted-foreground">Display</span> <span className="typography-micro text-muted-foreground">Display</span>
<Select value={usageDisplayMode} onValueChange={handleUsageDisplayModeChange}> <Select value={usageDisplayMode} onValueChange={handleUsageDisplayModeChange}>
<SelectTrigger size="sm" className="min-w-[140px]"> <SelectTrigger className="w-fit">
<SelectValue placeholder="Display mode" /> <SelectValue placeholder="Display mode" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="usage" className="pr-2 [&>span:first-child]:hidden"> <SelectItem value="usage">Usage</SelectItem>
Usage <SelectItem value="remaining">Quota remaining</SelectItem>
</SelectItem>
<SelectItem value="remaining" className="pr-2 [&>span:first-child]:hidden">
Quota remaining
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -74,7 +74,7 @@ export function GitHubIssuePickerDialog({
const githubAuthStatus = useGitHubAuthStore((state) => state.status); const githubAuthStatus = useGitHubAuthStore((state) => state.status);
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked); const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
const setSidebarSection = useUIStore((state) => state.setSidebarSection); const setSettingsPage = useUIStore((state) => state.setSettingsPage);
const activeProject = useProjectsStore((state) => state.getActiveProject()); const activeProject = useProjectsStore((state) => state.getActiveProject());
const projectDirectory = activeProject?.path ?? null; const projectDirectory = activeProject?.path ?? null;
@@ -181,9 +181,9 @@ export function GitHubIssuePickerDialog({
const repoUrl = result?.repo?.url ?? null; const repoUrl = result?.repo?.url ?? null;
const openGitHubSettings = React.useCallback(() => { const openGitHubSettings = React.useCallback(() => {
setSidebarSection('settings'); setSettingsPage('github');
setSettingsDialogOpen(true); setSettingsDialogOpen(true);
}, [setSettingsDialogOpen, setSidebarSection]); }, [setSettingsDialogOpen, setSettingsPage]);
const filtered = React.useMemo(() => { const filtered = React.useMemo(() => {
const q = query.trim().toLowerCase(); const q = query.trim().toLowerCase();
@@ -110,7 +110,7 @@ export function GitHubPullRequestPickerDialog({
const githubAuthStatus = useGitHubAuthStore((state) => state.status); const githubAuthStatus = useGitHubAuthStore((state) => state.status);
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked); const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
const setSidebarSection = useUIStore((state) => state.setSidebarSection); const setSettingsPage = useUIStore((state) => state.setSettingsPage);
const activeProject = useProjectsStore((state) => state.getActiveProject()); const activeProject = useProjectsStore((state) => state.getActiveProject());
const projectDirectory = activeProject?.path ?? null; const projectDirectory = activeProject?.path ?? null;
@@ -300,9 +300,9 @@ export function GitHubPullRequestPickerDialog({
const repoUrl = result?.repo?.url ?? null; const repoUrl = result?.repo?.url ?? null;
const openGitHubSettings = React.useCallback(() => { const openGitHubSettings = React.useCallback(() => {
setSidebarSection('settings'); setSettingsPage('github');
setSettingsDialogOpen(true); setSettingsDialogOpen(true);
}, [setSettingsDialogOpen, setSidebarSection]); }, [setSettingsDialogOpen, setSettingsPage]);
const filtered = React.useMemo(() => { const filtered = React.useMemo(() => {
const q = query.trim().toLowerCase(); const q = query.trim().toLowerCase();
@@ -21,6 +21,7 @@ import { removeProjectWorktree } from '@/lib/worktrees/worktreeManager';
import { useSessionStore } from '@/stores/useSessionStore'; import { useSessionStore } from '@/stores/useSessionStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useProjectsStore } from '@/stores/useProjectsStore'; import { useProjectsStore } from '@/stores/useProjectsStore';
import { useUIStore } from '@/stores/useUIStore';
import { useFileSystemAccess } from '@/hooks/useFileSystemAccess'; import { useFileSystemAccess } from '@/hooks/useFileSystemAccess';
import { isDesktopLocalOriginActive, isTauriShell } from '@/lib/desktop'; import { isDesktopLocalOriginActive, isTauriShell } from '@/lib/desktop';
import { useDeviceInfo } from '@/lib/device'; import { useDeviceInfo } from '@/lib/device';
@@ -64,6 +65,8 @@ export const SessionDialogs: React.FC = () => {
loadSessions, loadSessions,
getWorktreeMetadata, getWorktreeMetadata,
} = useSessionStore(); } = useSessionStore();
const showDeletionDialog = useUIStore((state) => state.showDeletionDialog);
const setShowDeletionDialog = useUIStore((state) => state.setShowDeletionDialog);
const { currentDirectory, homeDirectory, isHomeReady } = useDirectoryStore(); const { currentDirectory, homeDirectory, isHomeReady } = useDirectoryStore();
const { projects, addProject, activeProjectId } = useProjectsStore(); const { projects, addProject, activeProjectId } = useProjectsStore();
const { requestAccess, startAccessing } = useFileSystemAccess(); const { requestAccess, startAccessing } = useFileSystemAccess();
@@ -194,11 +197,52 @@ export const SessionDialogs: React.FC = () => {
setDirtyWorktreePaths(new Set()); setDirtyWorktreePaths(new Set());
}, []); }, []);
const deleteSessionsWithoutDialog = React.useCallback(async (payload: { sessions: Session[]; dateLabel?: string }) => {
if (payload.sessions.length === 0) {
return;
}
if (payload.sessions.length === 1) {
const target = payload.sessions[0];
const success = await deleteSession(target.id);
if (success) {
toast.success('Session deleted');
} else {
toast.error('Failed to delete session');
}
return;
}
const ids = payload.sessions.map((session) => session.id);
const { deletedIds, failedIds } = await deleteSessions(ids);
if (deletedIds.length > 0) {
const successDescription = failedIds.length > 0
? `${failedIds.length} session${failedIds.length === 1 ? '' : 's'} could not be deleted.`
: payload.dateLabel
? `Removed all sessions from ${payload.dateLabel}.`
: undefined;
toast.success(`Deleted ${deletedIds.length} session${deletedIds.length === 1 ? '' : 's'}`, {
description: renderToastDescription(successDescription),
});
}
if (failedIds.length > 0) {
toast.error(`Failed to delete ${failedIds.length} session${failedIds.length === 1 ? '' : 's'}`, {
description: renderToastDescription('Please try again in a moment.'),
});
}
}, [deleteSession, deleteSessions]);
React.useEffect(() => { React.useEffect(() => {
return sessionEvents.onDeleteRequest((payload) => { return sessionEvents.onDeleteRequest((payload) => {
if (!showDeletionDialog && (payload.mode ?? 'session') === 'session') {
void deleteSessionsWithoutDialog(payload);
return;
}
openDeleteDialog(payload); openDeleteDialog(payload);
}); });
}, [openDeleteDialog]); }, [openDeleteDialog, showDeletionDialog, deleteSessionsWithoutDialog]);
React.useEffect(() => { React.useEffect(() => {
return sessionEvents.onDirectoryRequest(() => { return sessionEvents.onDirectoryRequest(() => {
@@ -634,7 +678,17 @@ export const SessionDialogs: React.FC = () => {
</div> </div>
</div> </div>
) : ( ) : (
<> <div className="flex w-full items-center justify-between gap-3">
<button
type="button"
onClick={() => setShowDeletionDialog(!showDeletionDialog)}
className="inline-flex items-center gap-1.5 typography-meta text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50"
aria-pressed={!showDeletionDialog}
>
{!showDeletionDialog ? <RiCheckboxLine className="size-4 text-primary" /> : <RiCheckboxBlankLine className="size-4" />}
Never ask
</button>
<div className="flex items-center gap-2">
<Button variant="ghost" onClick={closeDeleteDialog} disabled={isProcessingDelete}> <Button variant="ghost" onClick={closeDeleteDialog} disabled={isProcessingDelete}>
Cancel Cancel
</Button> </Button>
@@ -645,7 +699,8 @@ export const SessionDialogs: React.FC = () => {
? 'Delete session' ? 'Delete session'
: 'Delete sessions'} : 'Delete sessions'}
</Button> </Button>
</> </div>
</div>
); );
const deleteDialogTitle = isWorktreeDelete const deleteDialogTitle = isWorktreeDelete
@@ -51,6 +51,8 @@ import {
RiAddLine, RiAddLine,
RiArrowDownSLine, RiArrowDownSLine,
RiArrowRightSLine, RiArrowRightSLine,
RiCheckboxBlankLine,
RiCheckboxLine,
RiCheckLine, RiCheckLine,
RiCloseLine, RiCloseLine,
RiDeleteBinLine, RiDeleteBinLine,
@@ -194,25 +196,14 @@ const getSessionCreatedAt = (session: Session): number => {
return toFiniteNumber(session.time?.created) ?? 0; return toFiniteNumber(session.time?.created) ?? 0;
}; };
const getSessionUpdatedAt = (session: Session, attentionStates?: Map<string, { lastUserMessageAt: number | null; lastStatusChangeAt: number }>): number => { const getSessionUpdatedAt = (session: Session): number => {
const baseUpdated = toFiniteNumber(session.time?.updated) ?? 0; return toFiniteNumber(session.time?.updated) ?? toFiniteNumber(session.time?.created) ?? 0;
if (!attentionStates) return baseUpdated;
const attention = attentionStates.get(session.id);
if (!attention) return baseUpdated;
return Math.max(
baseUpdated,
attention.lastUserMessageAt ?? 0,
attention.lastStatusChangeAt ?? 0
);
}; };
const compareSessionsByPinnedAndTime = ( const compareSessionsByPinnedAndTime = (
a: Session, a: Session,
b: Session, b: Session,
pinnedSessionIds: Set<string>, pinnedSessionIds: Set<string>
attentionStates?: Map<string, { lastUserMessageAt: number | null; lastStatusChangeAt: number }>
): number => { ): number => {
const aPinned = pinnedSessionIds.has(a.id); const aPinned = pinnedSessionIds.has(a.id);
const bPinned = pinnedSessionIds.has(b.id); const bPinned = pinnedSessionIds.has(b.id);
@@ -224,7 +215,7 @@ const compareSessionsByPinnedAndTime = (
return getSessionCreatedAt(b) - getSessionCreatedAt(a); return getSessionCreatedAt(b) - getSessionCreatedAt(a);
} }
return getSessionUpdatedAt(b, attentionStates) - getSessionUpdatedAt(a, attentionStates); return getSessionUpdatedAt(b) - getSessionUpdatedAt(a);
}; };
const centerDragOverlayUnderPointer: Modifier = ({ transform, activeNodeRect, activatorEvent }) => { const centerDragOverlayUnderPointer: Modifier = ({ transform, activeNodeRect, activatorEvent }) => {
@@ -883,6 +874,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen); const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
const openMultiRunLauncher = useUIStore((state) => state.openMultiRunLauncher); const openMultiRunLauncher = useUIStore((state) => state.openMultiRunLauncher);
const notifyOnSubtasks = useUIStore((state) => state.notifyOnSubtasks); const notifyOnSubtasks = useUIStore((state) => state.notifyOnSubtasks);
const showDeletionDialog = useUIStore((state) => state.showDeletionDialog);
const setShowDeletionDialog = useUIStore((state) => state.setShowDeletionDialog);
const settingsAutoCreateWorktree = useConfigStore((state) => state.settingsAutoCreateWorktree); const settingsAutoCreateWorktree = useConfigStore((state) => state.settingsAutoCreateWorktree);
// Session Folders store // Session Folders store
@@ -1026,8 +1019,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
}, []); }, []);
const sortedSessions = React.useMemo(() => { const sortedSessions = React.useMemo(() => {
return [...sessions].sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds, sessionAttentionStates)); return [...sessions].sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds));
}, [sessions, pinnedSessionIds, sessionAttentionStates]); }, [sessions, pinnedSessionIds]);
React.useEffect(() => { React.useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -1081,9 +1074,9 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
collection.push(session); collection.push(session);
map.set(parentID, collection); map.set(parentID, collection);
}); });
map.forEach((list) => list.sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds, sessionAttentionStates))); map.forEach((list) => list.sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds)));
return map; return map;
}, [sortedSessions, pinnedSessionIds, sessionAttentionStates]); }, [sortedSessions, pinnedSessionIds]);
React.useEffect(() => { React.useEffect(() => {
const directories = new Set<string>(); const directories = new Set<string>();
@@ -1314,18 +1307,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const deleteSession = useSessionStore((state) => state.deleteSession); const deleteSession = useSessionStore((state) => state.deleteSession);
const deleteSessions = useSessionStore((state) => state.deleteSessions); const deleteSessions = useSessionStore((state) => state.deleteSessions);
const handleDeleteSession = React.useCallback( const executeDeleteSession = React.useCallback(
(session: Session) => { async (session: Session) => {
const descendants = collectDescendants(session.id);
setDeleteSessionConfirm({ session, descendantCount: descendants.length });
},
[collectDescendants],
);
const confirmDeleteSession = React.useCallback(async () => {
if (!deleteSessionConfirm) return;
const { session } = deleteSessionConfirm;
setDeleteSessionConfirm(null);
const descendants = collectDescendants(session.id); const descendants = collectDescendants(session.id);
if (descendants.length === 0) { if (descendants.length === 0) {
const success = await deleteSession(session.id); const success = await deleteSession(session.id);
@@ -1334,7 +1317,9 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
} else { } else {
toast.error('Failed to delete session'); toast.error('Failed to delete session');
} }
} else { return;
}
const ids = [session.id, ...descendants.map((s) => s.id)]; const ids = [session.id, ...descendants.map((s) => s.id)];
const { deletedIds, failedIds } = await deleteSessions(ids); const { deletedIds, failedIds } = await deleteSessions(ids);
if (deletedIds.length > 0) { if (deletedIds.length > 0) {
@@ -1343,8 +1328,28 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
if (failedIds.length > 0) { if (failedIds.length > 0) {
toast.error(`Failed to delete ${failedIds.length} session${failedIds.length === 1 ? '' : 's'}`); toast.error(`Failed to delete ${failedIds.length} session${failedIds.length === 1 ? '' : 's'}`);
} }
},
[collectDescendants, deleteSession, deleteSessions],
);
const handleDeleteSession = React.useCallback(
(session: Session) => {
const descendants = collectDescendants(session.id);
if (!showDeletionDialog) {
void executeDeleteSession(session);
return;
} }
}, [deleteSessionConfirm, collectDescendants, deleteSession, deleteSessions]); setDeleteSessionConfirm({ session, descendantCount: descendants.length });
},
[collectDescendants, showDeletionDialog, executeDeleteSession],
);
const confirmDeleteSession = React.useCallback(async () => {
if (!deleteSessionConfirm) return;
const { session } = deleteSessionConfirm;
setDeleteSessionConfirm(null);
await executeDeleteSession(session);
}, [deleteSessionConfirm, executeDeleteSession]);
const confirmDeleteFolder = React.useCallback(() => { const confirmDeleteFolder = React.useCallback(() => {
if (!deleteFolderConfirm) return; if (!deleteFolderConfirm) return;
@@ -1396,6 +1401,24 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
}); });
}, [safeStorage]); }, [safeStorage]);
const createFolderAndStartRename = React.useCallback(
(scopeKey: string, parentId?: string | null) => {
if (!scopeKey) {
return null;
}
if (parentId && collapsedFolderIds.has(parentId)) {
toggleFolderCollapse(parentId);
}
const newFolder = createFolder(scopeKey, 'New folder', parentId);
setRenamingFolderId(newFolder.id);
setRenameFolderDraft(newFolder.name);
return newFolder;
},
[collapsedFolderIds, toggleFolderCollapse, createFolder],
);
const buildNode = React.useCallback( const buildNode = React.useCallback(
(session: Session): SessionNode => { (session: Session): SessionNode => {
const children = childrenMap.get(session.id) ?? []; const children = childrenMap.get(session.id) ?? [];
@@ -1418,7 +1441,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
projectIsRepo: boolean, projectIsRepo: boolean,
) => { ) => {
const normalizedProjectRoot = normalizePath(projectRoot ?? null); const normalizedProjectRoot = normalizePath(projectRoot ?? null);
const sortedProjectSessions = [...projectSessions].sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds, sessionAttentionStates)); const sortedProjectSessions = [...projectSessions].sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds));
const sessionMap = new Map(sortedProjectSessions.map((session) => [session.id, session])); const sessionMap = new Map(sortedProjectSessions.map((session) => [session.id, session]));
const childrenMap = new Map<string, Session[]>(); const childrenMap = new Map<string, Session[]>();
@@ -1431,7 +1454,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
collection.push(session); collection.push(session);
childrenMap.set(parentID, collection); childrenMap.set(parentID, collection);
}); });
childrenMap.forEach((list) => list.sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds, sessionAttentionStates))); childrenMap.forEach((list) => list.sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds)));
// Build worktree lookup map // Build worktree lookup map
const worktreeByPath = new Map<string, WorktreeMetadata>(); const worktreeByPath = new Map<string, WorktreeMetadata>();
@@ -1564,7 +1587,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
return groups; return groups;
}, },
[homeDirectory, worktreeMetadata, pinnedSessionIds, gitDirectories, sessionAttentionStates] [homeDirectory, worktreeMetadata, pinnedSessionIds, gitDirectories]
); );
const toggleGroupSessionLimit = React.useCallback((groupId: string) => { const toggleGroupSessionLimit = React.useCallback((groupId: string) => {
@@ -2406,7 +2429,10 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => {
const newFolder = createFolder(sessionDirectory, 'New folder'); const newFolder = createFolderAndStartRename(sessionDirectory);
if (!newFolder) {
return;
}
addSessionToFolder(sessionDirectory, newFolder.id, session.id); addSessionToFolder(sessionDirectory, newFolder.id, session.id);
}} }}
> >
@@ -2480,7 +2506,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
getSessionFolderId, getSessionFolderId,
addSessionToFolder, addSessionToFolder,
removeSessionFromFolder, removeSessionFromFolder,
createFolder, createFolderAndStartRename,
notifyOnSubtasks, notifyOnSubtasks,
foldersMap, // trigger re-render when folder data changes (getFoldersForScope is a stable fn selector) foldersMap, // trigger re-render when folder data changes (getFoldersForScope is a stable fn selector)
], ],
@@ -2504,7 +2530,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const nodes = folder.sessionIds const nodes = folder.sessionIds
.map((sid) => group.sessions.find((node) => node.session.id === sid)) .map((sid) => group.sessions.find((node) => node.session.id === sid))
.filter((n): n is SessionNode => Boolean(n)) .filter((n): n is SessionNode => Boolean(n))
.sort((a, b) => compareSessionsByPinnedAndTime(a.session, b.session, pinnedSessionIds, sessionAttentionStates)); .sort((a, b) => compareSessionsByPinnedAndTime(a.session, b.session, pinnedSessionIds));
return { folder, nodes }; return { folder, nodes };
}); });
@@ -2561,6 +2587,10 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
}} }}
onDelete={() => { onDelete={() => {
if (!folderScopeKey) return; if (!folderScopeKey) return;
if (!showDeletionDialog) {
deleteFolder(folderScopeKey, folder.id);
return;
}
// Count affected sub-folders and sessions for the confirm dialog // Count affected sub-folders and sessions for the confirm dialog
const subFolderCount = allFoldersForGroup.filter(({ folder: f }) => f.parentId === folder.id).length; const subFolderCount = allFoldersForGroup.filter(({ folder: f }) => f.parentId === folder.id).length;
const sessionCount = nodes.length; const sessionCount = nodes.length;
@@ -2606,7 +2636,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
}} }}
onNewSubFolder={depth === 0 ? () => { onNewSubFolder={depth === 0 ? () => {
if (!folderScopeKey) return; if (!folderScopeKey) return;
createFolder(folderScopeKey, 'New folder', folder.id); createFolderAndStartRename(folderScopeKey, folder.id);
} : undefined} } : undefined}
/> />
)} )}
@@ -2750,27 +2780,12 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
) : null} ) : null}
<DropdownMenu>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button <button
type="button" type="button"
onClick={(e) => e.stopPropagation()} onClick={(event) => {
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50" event.stopPropagation();
aria-label={`New session or folder in ${group.label}`}
>
<RiAddLine className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
<p>New session or folder</p>
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="min-w-[160px]">
<DropdownMenuItem
onClick={() => {
if (projectId && projectId !== activeProjectId) { if (projectId && projectId !== activeProjectId) {
setActiveProject(projectId); setActiveProject(projectId);
} }
@@ -2780,22 +2795,16 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
} }
openNewSessionDraft({ directoryOverride: group.directory }); openNewSessionDraft({ directoryOverride: group.directory });
}} }}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={`New session in ${group.label}`}
> >
<RiAddLine className="mr-1.5 h-4 w-4" /> <RiAddLine className="h-4 w-4" />
New session </button>
</DropdownMenuItem> </TooltipTrigger>
{folderScopeKey ? ( <TooltipContent side="bottom" sideOffset={4}>
<DropdownMenuItem <p>New session</p>
onClick={() => { </TooltipContent>
createFolder(folderScopeKey, 'New folder'); </Tooltip>
}}
>
<RiFolderAddLine className="mr-1.5 h-4 w-4" />
New folder
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
) : null} ) : null}
</div> </div>
@@ -2856,14 +2865,14 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
getFoldersForScope, getFoldersForScope,
collapsedFolderIds, collapsedFolderIds,
toggleFolderCollapse, toggleFolderCollapse,
createFolder, createFolderAndStartRename,
renameFolder, renameFolder,
deleteFolder, deleteFolder,
showDeletionDialog,
addSessionToFolder, addSessionToFolder,
renamingFolderId, renamingFolderId,
renameFolderDraft, renameFolderDraft,
pinnedSessionIds, pinnedSessionIds,
sessionAttentionStates,
foldersMap, // trigger re-render when folder data changes (getFoldersForScope is a stable fn selector) foldersMap, // trigger re-render when folder data changes (getFoldersForScope is a stable fn selector)
] ]
); );
@@ -3396,7 +3405,17 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
: `"${deleteSessionConfirm?.session.title || 'Untitled Session'}" will be permanently deleted.`} : `"${deleteSessionConfirm?.session.title || 'Untitled Session'}" will be permanently deleted.`}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter className="w-full sm:items-center sm:justify-between">
<button
type="button"
onClick={() => setShowDeletionDialog(!showDeletionDialog)}
className="inline-flex items-center gap-1.5 typography-ui-label text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50"
aria-pressed={!showDeletionDialog}
>
{!showDeletionDialog ? <RiCheckboxLine className="h-4 w-4 text-primary" /> : <RiCheckboxBlankLine className="h-4 w-4" />}
Never ask
</button>
<div className="flex items-center gap-2">
<button <button
type="button" type="button"
onClick={() => setDeleteSessionConfirm(null)} onClick={() => setDeleteSessionConfirm(null)}
@@ -3411,6 +3430,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
> >
Delete Delete
</button> </button>
</div>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -18,6 +18,8 @@ import { useDeviceInfo } from '@/lib/device';
import { RiAddLine, RiChatAi3Line, RiCheckLine, RiCodeLine, RiComputerLine, RiGitBranchLine, RiLayoutLeftLine, RiLayoutRightLine, RiMoonLine, RiQuestionLine, RiSettings3Line, RiSunLine, RiTerminalBoxLine, RiTimeLine } from '@remixicon/react'; import { RiAddLine, RiChatAi3Line, RiCheckLine, RiCodeLine, RiComputerLine, RiGitBranchLine, RiLayoutLeftLine, RiLayoutRightLine, RiMoonLine, RiQuestionLine, RiSettings3Line, RiSunLine, RiTerminalBoxLine, RiTimeLine } from '@remixicon/react';
import { createWorktreeSession } from '@/lib/worktreeSessionCreator'; import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts'; import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts';
import { isVSCodeRuntime, isWebRuntime } from '@/lib/desktop';
import { SETTINGS_PAGE_METADATA, SETTINGS_GROUP_LABELS, type SettingsRuntimeContext } from '@/lib/settings/metadata';
export const CommandPalette: React.FC = () => { export const CommandPalette: React.FC = () => {
const { const {
@@ -26,6 +28,7 @@ export const CommandPalette: React.FC = () => {
setHelpDialogOpen, setHelpDialogOpen,
setActiveMainTab, setActiveMainTab,
setSettingsDialogOpen, setSettingsDialogOpen,
setSettingsPage,
setSessionSwitcherOpen, setSessionSwitcherOpen,
setTimelineDialogOpen, setTimelineDialogOpen,
toggleSidebar, toggleSidebar,
@@ -112,6 +115,34 @@ export const CommandPalette: React.FC = () => {
handleClose(); handleClose();
}; };
const handleOpenSettingsPage = (slug: string) => {
setSettingsPage(slug);
setSettingsDialogOpen(true);
handleClose();
};
const settingsRuntimeCtx = React.useMemo<SettingsRuntimeContext>(() => {
const isDesktop = typeof window !== 'undefined' && Boolean((window as unknown as { __TAURI__?: unknown }).__TAURI__);
return { isVSCode: isVSCodeRuntime(), isWeb: isWebRuntime(), isDesktop };
}, []);
const settingsPages = React.useMemo(() => {
return SETTINGS_PAGE_METADATA
.filter((p) => p.slug !== 'home')
.filter((p) => (p.isAvailable ? p.isAvailable(settingsRuntimeCtx) : true));
}, [settingsRuntimeCtx]);
const settingsItems = React.useMemo(() => {
const groupLabel = (group: string) => (SETTINGS_GROUP_LABELS as Record<string, string>)[group] ?? group;
return settingsPages
.slice()
.sort((a, b) => {
const g = groupLabel(a.group).localeCompare(groupLabel(b.group));
if (g !== 0) return g;
return a.title.localeCompare(b.title);
});
}, [settingsPages]);
const handleToggleRightSidebar = () => { const handleToggleRightSidebar = () => {
toggleRightSidebar(); toggleRightSidebar();
handleClose(); handleClose();
@@ -234,6 +265,19 @@ export const CommandPalette: React.FC = () => {
<span>Open Settings</span> <span>Open Settings</span>
<CommandShortcut>{shortcut('open_settings')}</CommandShortcut> <CommandShortcut>{shortcut('open_settings')}</CommandShortcut>
</CommandItem> </CommandItem>
<CommandItem onSelect={() => handleOpenSettingsPage('skills.catalog')}>
<RiSettings3Line className="mr-2 h-4 w-4" />
<span>Open Skills Catalog</span>
</CommandItem>
</CommandGroup>
<CommandGroup heading="Settings">
{settingsItems.map((page) => (
<CommandItem key={page.slug} onSelect={() => handleOpenSettingsPage(page.slug)}>
<RiSettings3Line className="mr-2 h-4 w-4" />
<span>{SETTINGS_GROUP_LABELS[page.group]}: {page.title}</span>
</CommandItem>
))}
</CommandGroup> </CommandGroup>
<CommandSeparator /> <CommandSeparator />
@@ -30,7 +30,7 @@ export const ProviderLogo: React.FC<ProviderLogoProps> = ({
<img <img
src={src} src={src}
alt={alt || `${providerId} logo`} alt={alt || `${providerId} logo`}
className={cn('dark:invert', className)} className={cn('dark:invert object-contain', className)}
onError={handleError} onError={handleError}
/> />
); );
+118 -117
View File
@@ -2,7 +2,6 @@ import React, { useState, useCallback, useEffect, useMemo } from 'react';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
@@ -66,6 +65,11 @@ function stripChangelogHeading(sectionRaw: string): string {
return sectionRaw.replace(/^## \[[^\]]+\] - \d{4}-\d{2}-\d{2}\s*\n?/, '').trim(); return sectionRaw.replace(/^## \[[^\]]+\] - \d{4}-\d{2}-\d{2}\s*\n?/, '').trim();
} }
function processChangelogMentions(content: string): string {
// Convert @username to markdown links so they can be styled via css
return content.replace(/(^|[^a-zA-Z0-9])@([a-zA-Z0-9-]+)/g, '$1[@$2](https://github.com/$2)');
}
function compareSemverDesc(a: string, b: string): number { function compareSemverDesc(a: string, b: string): number {
const pa = a.split('.').map((v) => Number.parseInt(v, 10)); const pa = a.split('.').map((v) => Number.parseInt(v, 10));
const pb = b.split('.').map((v) => Number.parseInt(v, 10)); const pb = b.split('.').map((v) => Number.parseInt(v, 10));
@@ -177,11 +181,30 @@ export const UpdateDialog: React.FC<UpdateDialogProps> = ({
if (result.ok) { if (result.ok) {
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
} else {
// Clipboard access denied
} }
}; };
const handleOpenExternal = useCallback(async (url: string) => {
if (typeof window === 'undefined') return;
// Try Tauri backend
type TauriShell = { shell?: { open?: (url: string) => Promise<unknown> } };
const tauri = (window as unknown as { __TAURI__?: TauriShell }).__TAURI__;
if (tauri?.shell?.open) {
try {
await tauri.shell.open(url);
return;
} catch {
// fall through to window.open
}
}
try {
window.open(url, '_blank', 'noopener,noreferrer');
} catch {
// ignore
}
}, []);
const handleWebUpdate = useCallback(async () => { const handleWebUpdate = useCallback(async () => {
setWebUpdateState('updating'); setWebUpdateState('updating');
setWebError(null); setWebError(null);
@@ -231,7 +254,7 @@ export const UpdateDialog: React.FC<UpdateDialogProps> = ({
return { return {
kind: 'raw', kind: 'raw',
title: "What's new", title: "What's new",
content: body, content: processChangelogMentions(body),
}; };
} }
@@ -242,102 +265,110 @@ export const UpdateDialog: React.FC<UpdateDialogProps> = ({
sections: sorted.map((section) => ({ sections: sorted.map((section) => ({
version: section.version, version: section.version,
dateLabel: formatIsoDateForUI(section.date), dateLabel: formatIsoDateForUI(section.date),
content: stripChangelogHeading(section.raw) || body, content: processChangelogMentions(stripChangelogHeading(section.raw) || body),
})), })),
}; };
}, [info?.body]); }, [info?.body]);
return ( return (
<Dialog open={open} onOpenChange={isWebUpdating ? undefined : onOpenChange}> <Dialog open={open} onOpenChange={isWebUpdating ? undefined : onOpenChange}>
<DialogContent className="max-w-md"> <DialogContent className="max-w-4xl p-5 bg-background border-[var(--interactive-border)]" showCloseButton={true}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<RiDownloadCloudLine className="h-5 w-5 text-primary" />
{webUpdateState === 'restarting' || webUpdateState === 'reconnecting'
? 'Updating...'
: 'Update Available'}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 mt-2"> {/* Header Section */}
<div className="flex items-center mb-1">
<DialogTitle className="flex items-center gap-2.5">
<RiDownloadCloudLine className="h-5 w-5 text-[var(--primary-base)]" />
<span className="text-lg font-semibold text-foreground">
{webUpdateState === 'restarting' || webUpdateState === 'reconnecting'
? 'Updating OpenChamber...'
: 'Update Available'}
</span>
</DialogTitle>
{/* Version Diff */}
{(info?.currentVersion || info?.version) && ( {(info?.currentVersion || info?.version) && (
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 font-mono text-sm ml-3">
{info?.currentVersion && ( {info?.currentVersion && (
<span className="font-mono">{info.currentVersion}</span> <span className="text-muted-foreground">{info.currentVersion}</span>
)} )}
{info?.currentVersion && info?.version && ( {info?.currentVersion && info?.version && (
<span className="text-muted-foreground">→</span> <span className="text-muted-foreground/50">→</span>
)} )}
{info?.version && ( {info?.version && (
<span className="font-mono text-primary">{info.version}</span> <span className="text-[var(--primary-base)] font-medium">{info.version}</span>
)} )}
</div> </div>
)} )}
</div>
{/* Content Body */}
<div className="space-y-2">
{/* Web update progress */} {/* Web update progress */}
{isWebRuntime && isWebUpdating && ( {isWebRuntime && isWebUpdating && (
<div className="space-y-3"> <div className="rounded-lg bg-[var(--surface-elevated)]/30 p-5 border border-[var(--surface-subtle)]">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<RiLoaderLine className="h-5 w-5 animate-spin text-primary" /> <RiLoaderLine className="h-5 w-5 animate-spin text-[var(--primary-base)]" />
<div className="text-sm"> <div className="typography-ui-label text-foreground">
{webUpdateState === 'updating' && 'Installing update...'} {webUpdateState === 'updating' && 'Installing update...'}
{webUpdateState === 'restarting' && 'Server restarting...'} {webUpdateState === 'restarting' && 'Server restarting...'}
{webUpdateState === 'reconnecting' && 'Waiting for server...'} {webUpdateState === 'reconnecting' && 'Waiting for server...'}
</div> </div>
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="mt-2 text-xs text-muted-foreground">
The page will reload automatically when the update is complete. The page will reload automatically when the update is complete.
</p> </p>
</div> </div>
)} )}
{/* Changelog Rendering */}
{changelog && !isWebUpdating && ( {changelog && !isWebUpdating && (
<div className="space-y-2"> <div className="rounded-lg border border-[var(--surface-subtle)] bg-[var(--surface-elevated)]/20 overflow-hidden">
<div className="flex items-center justify-between gap-3">
<div className="typography-ui-label font-medium text-foreground/90">
{changelog.title}
</div>
</div>
<ScrollableOverlay <ScrollableOverlay
className={cn( className="max-h-[400px] p-0"
'max-h-56 rounded-md border border-border/70',
'bg-background/40 p-3'
)}
fillContainer={false} fillContainer={false}
> >
{changelog.kind === 'raw' ? ( {changelog.kind === 'raw' ? (
<SimpleMarkdownRenderer
content={changelog.content}
className="typography-markdown-body text-foreground/90 leading-relaxed pr-3 break-words"
/>
) : (
<div className="space-y-4 pr-3">
{changelog.sections.map((section, idx) => (
<div <div
key={section.version} className="p-4 typography-markdown-body text-foreground leading-relaxed break-words [&_a]:!text-[var(--primary-base)] [&_a]:!no-underline hover:[&_a]:!underline"
className={cn( onClickCapture={(e) => {
idx > 0 && 'border-t border-border/40 pt-3' const target = e.target as HTMLElement;
)} const a = target.closest('a');
> if (a && a.href) {
<div className="flex items-center gap-2 mb-2"> e.preventDefault();
<span e.stopPropagation();
className={cn( void handleOpenExternal(a.href);
'typography-ui-badge font-mono', }
'bg-primary/10 text-primary', }}
'px-2 py-0.5 rounded-md'
)}
> >
<SimpleMarkdownRenderer content={changelog.content} disableLinkSafety={true} />
</div>
) : (
<div className="divide-y divide-[var(--surface-subtle)]">
{changelog.sections.map((section) => (
<div key={section.version} className="p-4 hover:bg-background/40 transition-colors">
<div className="flex items-center gap-3 mb-3">
<span className="typography-ui-label font-mono text-[var(--primary-base)] bg-[var(--primary-base)]/10 px-1.5 py-0.5 rounded">
v{section.version} v{section.version}
</span> </span>
<span className="typography-micro text-muted-foreground"> <span className="text-sm font-medium text-muted-foreground">
{section.dateLabel} {section.dateLabel}
</span> </span>
</div> </div>
<SimpleMarkdownRenderer <div
content={section.content} className="typography-markdown-body text-foreground leading-relaxed break-words [&_a]:!text-[var(--primary-base)] [&_a]:!no-underline hover:[&_a]:!underline"
className="typography-markdown-body text-foreground/90 leading-relaxed break-words" onClickCapture={(e) => {
/> const target = e.target as HTMLElement;
const a = target.closest('a');
if (a && a.href) {
e.preventDefault();
e.stopPropagation();
void handleOpenExternal(a.href);
}
}}
>
<SimpleMarkdownRenderer content={section.content} disableLinkSafety={true} />
</div>
</div> </div>
))} ))}
</div> </div>
@@ -346,24 +377,24 @@ export const UpdateDialog: React.FC<UpdateDialogProps> = ({
</div> </div>
)} )}
{/* Web runtime: show CLI command only on error as fallback */} {/* Web runtime fallback command */}
{isWebRuntime && webUpdateState === 'error' && ( {isWebRuntime && webUpdateState === 'error' && (
<div className="space-y-2"> <div className="space-y-2 mt-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 typography-meta text-muted-foreground">
<RiTerminalLine className="h-4 w-4" /> <RiTerminalLine className="h-4 w-4" />
<span>Or update via terminal:</span> <span>Or update via terminal:</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 p-1 pl-3 bg-[var(--surface-elevated)]/50 rounded-md border border-[var(--surface-subtle)]">
<code className="flex-1 px-3 py-2 bg-muted rounded-md font-mono text-sm text-foreground overflow-x-auto"> <code className="flex-1 font-mono text-sm text-foreground overflow-x-auto whitespace-nowrap">
{updateCommand} {updateCommand}
</code> </code>
<button <button
onClick={handleCopyCommand} onClick={handleCopyCommand}
className={cn( className={cn(
'flex items-center justify-center p-2 rounded-md', 'flex items-center justify-center p-2 rounded',
'text-muted-foreground hover:text-foreground hover:bg-interactive-hover', 'text-muted-foreground hover:text-foreground hover:bg-[var(--interactive-hover)]',
'transition-colors', 'transition-colors',
copied && 'text-primary' copied && 'text-[var(--status-success)]'
)} )}
title={copied ? 'Copied!' : 'Copy command'} title={copied ? 'Copied!' : 'Copy command'}
> >
@@ -377,55 +408,48 @@ export const UpdateDialog: React.FC<UpdateDialogProps> = ({
</div> </div>
)} )}
{/* Desktop runtime: show download progress */} {/* Desktop progress bar */}
{!isWebRuntime && downloading && ( {!isWebRuntime && downloading && (
<div className="space-y-2"> <div className="space-y-2 mt-4">
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Downloading...</span> <span className="text-muted-foreground">Downloading update payload...</span>
<span className="font-mono">{progressPercent}%</span> <span className="font-mono text-foreground">{progressPercent}%</span>
</div> </div>
<div className="h-2 bg-muted rounded-full overflow-hidden"> <div className="h-1.5 bg-[var(--surface-subtle)] rounded-full overflow-hidden">
<div <div
className="h-full bg-primary transition-all duration-300" className="h-full bg-[var(--primary-base)] transition-all duration-300"
style={{ width: `${progressPercent}%` }} style={{ width: `${progressPercent}%` }}
/> />
</div> </div>
</div> </div>
)} )}
{/* Error display */}
{(error || webError) && ( {(error || webError) && (
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-lg"> <div className="p-3 mt-4 bg-[var(--status-error-background)] border border-[var(--status-error-border)] rounded-lg">
<p className="text-sm text-destructive">{error || webError}</p> <p className="text-sm text-[var(--status-error)]">{error || webError}</p>
</div> </div>
)} )}
</div>
<div className="flex gap-2 pt-2"> {/* Action Footer */}
<div className="mt-4 flex items-center justify-between gap-4">
<a <a
href={releaseUrl} href={releaseUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className={cn( className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors shrink-0"
'flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-md',
'text-sm text-muted-foreground',
'hover:text-foreground hover:bg-interactive-hover',
'transition-colors'
)}
> >
<RiExternalLinkLine className="h-4 w-4" /> <RiExternalLinkLine className="h-4 w-4" />
GitHub GitHub
</a> </a>
{/* Desktop runtime buttons */} <div className="flex-1 flex justify-end">
{/* Desktop Buttons */}
{!isWebRuntime && !downloaded && !downloading && ( {!isWebRuntime && !downloaded && !downloading && (
<button <button
onClick={onDownload} onClick={onDownload}
className={cn( className="flex items-center justify-center gap-2 px-5 py-2 rounded-md text-sm font-medium bg-[var(--primary-base)] text-[var(--primary-foreground)] hover:opacity-90 transition-opacity"
'flex-1 flex items-center justify-center gap-2 px-3 py-1.5 rounded-md',
'text-sm font-medium',
'bg-primary text-primary-foreground',
'hover:bg-primary/90',
'transition-colors'
)}
> >
<RiDownloadLine className="h-4 w-4" /> <RiDownloadLine className="h-4 w-4" />
Download Update Download Update
@@ -435,12 +459,7 @@ export const UpdateDialog: React.FC<UpdateDialogProps> = ({
{!isWebRuntime && downloading && ( {!isWebRuntime && downloading && (
<button <button
disabled disabled
className={cn( className="flex items-center justify-center gap-2 px-5 py-2 rounded-md text-sm font-medium bg-[var(--primary-base)]/50 text-[var(--primary-foreground)] cursor-not-allowed"
'flex-1 flex items-center justify-center gap-2 px-3 py-1.5 rounded-md',
'text-sm font-medium',
'bg-primary/50 text-primary-foreground',
'cursor-not-allowed'
)}
> >
<RiLoaderLine className="h-4 w-4 animate-spin" /> <RiLoaderLine className="h-4 w-4 animate-spin" />
Downloading... Downloading...
@@ -450,46 +469,28 @@ export const UpdateDialog: React.FC<UpdateDialogProps> = ({
{!isWebRuntime && downloaded && ( {!isWebRuntime && downloaded && (
<button <button
onClick={onRestart} onClick={onRestart}
className={cn( className="flex items-center justify-center gap-2 px-5 py-2 rounded-md text-sm font-medium bg-[var(--status-success)] text-white hover:opacity-90 transition-opacity"
'flex-1 flex items-center justify-center gap-2 px-3 py-1.5 rounded-md',
'text-sm font-medium',
'bg-primary text-primary-foreground',
'hover:bg-primary/90',
'transition-colors'
)}
> >
<RiRestartLine className="h-4 w-4" /> <RiRestartLine className="h-4 w-4" />
Restart to Update Restart to Update
</button> </button>
)} )}
{/* Web runtime: Update Now button */} {/* Web Buttons */}
{isWebRuntime && !isWebUpdating && ( {isWebRuntime && !isWebUpdating && (
<button <button
onClick={handleWebUpdate} onClick={handleWebUpdate}
className={cn( className="flex items-center justify-center gap-2 px-5 py-2 rounded-md text-sm font-medium bg-[var(--primary-base)] text-[var(--primary-foreground)] hover:opacity-90 transition-opacity"
'flex-1 flex items-center justify-center gap-2 px-3 py-1.5 rounded-md',
'text-sm font-medium',
'bg-primary text-primary-foreground',
'hover:bg-primary/90',
'transition-colors'
)}
> >
<RiDownloadLine className="h-4 w-4" /> <RiDownloadLine className="h-4 w-4" />
Update Now Update Now
</button> </button>
)} )}
{/* Web runtime: updating state */}
{isWebRuntime && isWebUpdating && ( {isWebRuntime && isWebUpdating && (
<button <button
disabled disabled
className={cn( className="flex items-center justify-center gap-2 px-5 py-2 rounded-md text-sm font-medium bg-[var(--primary-base)]/50 text-[var(--primary-foreground)] cursor-not-allowed"
'flex-1 flex items-center justify-center gap-2 px-3 py-1.5 rounded-md',
'text-sm font-medium',
'bg-primary/50 text-primary-foreground',
'cursor-not-allowed'
)}
> >
<RiLoaderLine className="h-4 w-4 animate-spin" /> <RiLoaderLine className="h-4 w-4 animate-spin" />
Updating... Updating...
@@ -6,13 +6,14 @@ import { type VariantProps } from "class-variance-authority"
function ButtonSmall({ function ButtonSmall({
className, className,
variant, variant,
size = "sm",
...props ...props
}: React.ComponentProps<"button"> & VariantProps<typeof buttonVariants>) { }: React.ComponentProps<"button"> & VariantProps<typeof buttonVariants>) {
return ( return (
<Button <Button
variant={variant} variant={variant}
size="sm" size={size}
className={cn("h-6 px-2 text-xs", className)} className={cn(size === "sm" && "h-7 px-2", className)}
{...props} {...props}
/> />
) )
+1
View File
@@ -25,6 +25,7 @@ const buttonVariants = cva(
size: { size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3", default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-lg gap-1.5 px-3 has-[>svg]:px-2.5", sm: "h-8 rounded-lg gap-1.5 px-3 has-[>svg]:px-2.5",
xs: "h-6 rounded-md gap-1 px-1.5 typography-micro has-[>svg]:px-1.5",
lg: "h-10 rounded-lg px-6 has-[>svg]:px-4", lg: "h-10 rounded-lg px-6 has-[>svg]:px-4",
icon: "size-9", icon: "size-9",
}, },
+215 -91
View File
@@ -1,22 +1,43 @@
import * as React from "react" import * as React from "react"
import { RiArrowDownSLine, RiArrowUpSLine } from "@remixicon/react" import { RiAddLine, RiSubtractLine } from "@remixicon/react"
import { useDeviceInfo } from "@/lib/device"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
export interface NumberInputProps export interface NumberInputProps
extends Omit<React.ComponentProps<"input">, "value" | "onChange" | "type"> { extends Omit<React.ComponentProps<"input">, "value" | "onChange" | "type"> {
value: number value?: number
onValueChange: (value: number) => void onValueChange: (value: number) => void
min?: number min?: number
max?: number max?: number
step?: number step?: number
containerClassName?: string containerClassName?: string
fallbackValue?: number
onClear?: () => void
emptyLabel?: string
} }
function clamp(value: number, min: number, max: number) { function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value)) return Math.min(max, Math.max(min, value))
} }
function getStepDecimals(step: number) {
if (!Number.isFinite(step)) return 0
const stepString = String(step)
if (stepString.includes("e-")) {
const [, exp] = stepString.split("e-")
return Number(exp) || 0
}
const parts = stepString.split(".")
return parts.length === 2 ? parts[1]!.length : 0
}
function normalizeToStep(value: number, step: number) {
const decimals = getStepDecimals(step)
if (decimals <= 0) return value
return Number(value.toFixed(decimals))
}
const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>( const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
( (
{ {
@@ -28,164 +49,267 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
className, className,
containerClassName, containerClassName,
onBlur, onBlur,
onKeyDown,
disabled, disabled,
fallbackValue,
onClear,
emptyLabel = '—',
...props ...props
}, },
ref ref
) => { ) => {
const inputRef = React.useRef<HTMLInputElement | null>(null) const [draft, setDraft] = React.useState(() => (value === undefined ? '' : String(value)))
const { isMobile } = useDeviceInfo()
const ignoreNextClickRef = React.useRef(false)
const swallowNextClickCleanupRef = React.useRef<(() => void) | null>(null)
React.useImperativeHandle(ref, () => inputRef.current as HTMLInputElement) const swallowNextClick = React.useCallback(() => {
if (typeof document === 'undefined' || typeof window === 'undefined') {
const [draft, setDraft] = React.useState(() => String(value)) return
React.useEffect(() => {
if (document.activeElement !== inputRef.current) {
setDraft(String(value))
} }
}, [value])
const focusInput = React.useCallback(() => { swallowNextClickCleanupRef.current?.()
inputRef.current?.focus()
const handleCaptureClick = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(event as any).stopImmediatePropagation?.()
swallowNextClickCleanupRef.current?.()
}
const timeoutId = window.setTimeout(() => {
swallowNextClickCleanupRef.current?.()
}, 700)
const cleanup = () => {
window.clearTimeout(timeoutId)
document.removeEventListener('click', handleCaptureClick, true)
swallowNextClickCleanupRef.current = null
}
swallowNextClickCleanupRef.current = cleanup
document.addEventListener('click', handleCaptureClick, true)
}, []) }, [])
const applyValue = React.useCallback( React.useEffect(() => {
(nextValue: number) => { return () => {
const clamped = clamp(nextValue, min, max) swallowNextClickCleanupRef.current?.()
onValueChange(clamped) }
setDraft(String(clamped)) }, [])
React.useEffect(() => {
setDraft(value === undefined ? '' : String(value))
}, [value])
const baseValue = React.useMemo(() => {
if (value !== undefined) return value
if (fallbackValue !== undefined) return fallbackValue
if (Number.isFinite(min)) return min
return 0
}, [fallbackValue, min, value])
const commitValue = React.useCallback(
(rawValue: number) => {
const clamped = clamp(rawValue, min, max)
onValueChange(normalizeToStep(clamped, step))
}, },
[max, min, onValueChange] [max, min, onValueChange, step]
) )
const currentNumericValue = React.useCallback(() => { const handleChange = React.useCallback(
const parsed = Number(draft) (event: React.ChangeEvent<HTMLInputElement>) => {
if (Number.isFinite(parsed)) {
return parsed
}
return value
}, [draft, value])
const handleIncrement = React.useCallback(() => {
applyValue(currentNumericValue() + step)
focusInput()
}, [applyValue, currentNumericValue, focusInput, step])
const handleDecrement = React.useCallback(() => {
applyValue(currentNumericValue() - step)
focusInput()
}, [applyValue, currentNumericValue, focusInput, step])
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const nextDraft = event.target.value const nextDraft = event.target.value
setDraft(nextDraft) setDraft(nextDraft)
if (nextDraft.trim() === '') {
onClear?.()
return
}
const parsed = Number(nextDraft) const parsed = Number(nextDraft)
if (!Number.isFinite(parsed)) { if (!Number.isFinite(parsed)) {
return return
} }
onValueChange(clamp(parsed, min, max)) commitValue(parsed)
},
[commitValue, onClear]
)
const handleBlur = React.useCallback(
(event: React.FocusEvent<HTMLInputElement>) => {
if (draft.trim() === '') {
if (!onClear) {
setDraft(value === undefined ? '' : String(value))
}
onBlur?.(event)
return
} }
const handleBlur = (event: React.FocusEvent<HTMLInputElement>) => {
const parsed = Number(draft) const parsed = Number(draft)
if (!Number.isFinite(parsed)) { if (!Number.isFinite(parsed)) {
setDraft(String(value)) setDraft(value === undefined ? '' : String(value))
} else { } else {
const clamped = clamp(parsed, min, max) const clamped = clamp(parsed, min, max)
if (clamped !== parsed) { const normalized = normalizeToStep(clamped, step)
onValueChange(clamped) if (normalized !== value) {
onValueChange(normalized)
} }
setDraft(String(clamped)) setDraft(String(normalized))
} }
onBlur?.(event) onBlur?.(event)
},
[draft, max, min, onBlur, onClear, onValueChange, step, value]
)
const incrementDisabled = Boolean(disabled || baseValue >= max)
const decrementDisabled = Boolean(disabled || baseValue <= min)
const handleMobileDecrement = () => {
if (!decrementDisabled) {
commitValue(baseValue - step)
}
} }
const handleKeyDownInternal = (event: React.KeyboardEvent<HTMLInputElement>) => { const handleMobileIncrement = () => {
if (event.key === "ArrowUp") { if (!incrementDisabled) {
event.preventDefault() commitValue(baseValue + step)
handleIncrement()
return
} }
if (event.key === "ArrowDown") {
event.preventDefault()
handleDecrement()
return
}
onKeyDown?.(event)
} }
const numericValue = currentNumericValue() const handleMobileTouchActivate = (handler: () => void) => (event: React.TouchEvent) => {
const decrementDisabled = disabled || numericValue <= min event.preventDefault()
const incrementDisabled = disabled || numericValue >= max event.stopPropagation()
// Touch on iOS often triggers a follow-up click; ignore it.
ignoreNextClickRef.current = true
// Also swallow the synthetic click anywhere (prevents layout-shift clicks).
swallowNextClick()
handler()
}
const handleMobileClickActivate = (handler: () => void) => (event: React.MouseEvent) => {
if (ignoreNextClickRef.current) {
ignoreNextClickRef.current = false
event.preventDefault()
event.stopPropagation()
return
}
handler()
}
if (isMobile) {
return (
<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",
"[-webkit-user-select:none] [-webkit-touch-callout:none]",
"disabled:pointer-events-none disabled:opacity-50",
containerClassName
)}
>
<button
type="button"
aria-label="Decrease value"
disabled={decrementDisabled}
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",
"text-muted-foreground",
"disabled:pointer-events-none disabled:opacity-50",
!decrementDisabled && "active:bg-interactive-hover"
)}
>
<RiSubtractLine className="block h-4 w-4" />
</button>
<div
className={cn(
"flex h-full min-w-0 w-14 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}
</div>
<button
type="button"
aria-label="Increase value"
disabled={incrementDisabled}
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",
"text-muted-foreground",
"disabled:pointer-events-none disabled:opacity-50",
!incrementDisabled && "active:bg-interactive-hover"
)}
>
<RiAddLine className="block h-4 w-4" />
</button>
</div>
)
}
return ( return (
<div <div
className={cn( className={cn(
"flex h-8 items-stretch overflow-hidden rounded-lg border border-border bg-background", "flex h-7 shrink-0 items-stretch overflow-x-hidden overflow-y-hidden rounded-lg border border-border bg-transparent",
"focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]", "disabled:pointer-events-none disabled:opacity-50",
disabled && "opacity-50",
containerClassName containerClassName
)} )}
> >
<button
type="button"
aria-label="Decrease value"
disabled={decrementDisabled}
onClick={() => commitValue(baseValue - 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",
"text-muted-foreground hover:bg-interactive-hover hover:text-foreground",
"disabled:pointer-events-none disabled:opacity-50"
)}
>
<RiSubtractLine className="block h-3.5 w-3.5" />
</button>
<input <input
{...props} {...props}
ref={inputRef} ref={ref}
type="number" type="text"
inputMode="numeric" inputMode={props.inputMode ?? 'numeric'}
min={Number.isFinite(min) ? min : undefined}
max={Number.isFinite(max) ? max : undefined}
step={step}
value={draft} value={draft}
onChange={handleChange} onChange={handleChange}
onBlur={handleBlur} onBlur={handleBlur}
onKeyDown={handleKeyDownInternal}
disabled={disabled} disabled={disabled}
spellCheck={false} spellCheck={false}
autoComplete="off" autoComplete="off"
autoCorrect="off" autoCorrect="off"
autoCapitalize="off" autoCapitalize="off"
className={cn( className={cn(
"h-full w-14 bg-transparent px-1.5 text-center typography-ui-label text-foreground", "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]",
"placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground", "placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground",
"border-0 outline-none", "appearance-none outline-none [appearance:textfield] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
"disabled:pointer-events-none disabled:cursor-not-allowed", "disabled:pointer-events-none disabled:cursor-not-allowed",
"[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none",
className className
)} )}
/> />
<div className="flex w-6 flex-col border-l border-border">
<button <button
type="button" type="button"
aria-label="Increase value" aria-label="Increase value"
disabled={incrementDisabled} disabled={incrementDisabled}
onClick={handleIncrement} onClick={() => commitValue(baseValue + step)}
className={cn( className={cn(
"flex flex-1 items-center justify-center", "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",
"text-muted-foreground hover:bg-interactive-hover hover:text-foreground", "text-muted-foreground hover:bg-interactive-hover hover:text-foreground",
"disabled:pointer-events-none disabled:opacity-50" "disabled:pointer-events-none disabled:opacity-50"
)} )}
> >
<RiArrowUpSLine className="h-3.5 w-3.5" /> <RiAddLine className="block h-3.5 w-3.5" />
</button> </button>
<button
type="button"
aria-label="Decrease value"
disabled={decrementDisabled}
onClick={handleDecrement}
className={cn(
"flex flex-1 items-center justify-center border-t border-border",
"text-muted-foreground hover:bg-interactive-hover hover:text-foreground",
"disabled:pointer-events-none disabled:opacity-50"
)}
>
<RiArrowDownSLine className="h-3.5 w-3.5" />
</button>
</div>
</div> </div>
) )
} }
+69
View File
@@ -0,0 +1,69 @@
import React from 'react';
import { RiRadioButtonFill, RiRadioButtonLine } from '@remixicon/react';
import { cn } from '@/lib/utils';
interface RadioProps {
checked: boolean;
onChange: () => void;
disabled?: boolean;
ariaLabel?: string;
className?: string;
iconClassName?: string;
}
export const Radio = React.memo<RadioProps>(function Radio({
checked,
onChange,
disabled = false,
ariaLabel,
className,
iconClassName,
}) {
const handleClick = React.useCallback(
(event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
if (!disabled && !checked) {
onChange();
}
},
[checked, disabled, onChange]
);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
if (!disabled && !checked) {
onChange();
}
}
},
[checked, disabled, onChange]
);
return (
<button
type="button"
role="radio"
onClick={handleClick}
onKeyDown={handleKeyDown}
disabled={disabled}
aria-checked={checked}
aria-label={ariaLabel}
className={cn(
'flex size-5 shrink-0 items-center justify-center rounded',
'text-muted-foreground hover:text-foreground',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
disabled && 'cursor-not-allowed opacity-50',
className
)}
>
{checked ? (
<RiRadioButtonFill className={cn('size-4 text-primary', iconClassName)} />
) : (
<RiRadioButtonLine className={cn('size-4', iconClassName)} />
)}
</button>
);
});
File diff suppressed because it is too large Load Diff
@@ -24,7 +24,7 @@ export const SettingsWindow: React.FC<SettingsWindowProps> = ({ open, onOpenChan
aria-describedby={descriptionId} aria-describedby={descriptionId}
className={cn( className={cn(
'fixed z-50 top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%]', 'fixed z-50 top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%]',
'w-[90vw] max-w-[1200px] h-[85vh] max-h-[900px]', 'w-[90vw] max-w-[960px] h-[85vh] max-h-[900px]',
'rounded-xl border shadow-2xl overflow-hidden', 'rounded-xl border shadow-2xl overflow-hidden',
'bg-background' 'bg-background'
)} )}
@@ -812,8 +812,13 @@ export const TerminalView: React.FC = () => {
<Button <Button
type="button" type="button"
size="sm" size="sm"
variant={activeModifier === 'ctrl' ? 'default' : 'outline'} variant="outline"
className="h-6 w-9 p-0" className={cn(
"h-6 w-9 p-0",
activeModifier === 'ctrl'
? 'border-[var(--primary-base)] text-[var(--primary-base)] bg-[var(--primary-base)]/10 hover:text-[var(--primary-base)]'
: undefined
)}
onClick={() => handleModifierToggle('ctrl')} onClick={() => handleModifierToggle('ctrl')}
disabled={quickKeysDisabled} disabled={quickKeysDisabled}
> >
@@ -823,8 +828,13 @@ export const TerminalView: React.FC = () => {
<Button <Button
type="button" type="button"
size="sm" size="sm"
variant={activeModifier === 'cmd' ? 'default' : 'outline'} variant="outline"
className="h-6 w-9 p-0" className={cn(
"h-6 w-9 p-0",
activeModifier === 'cmd'
? 'border-[var(--primary-base)] text-[var(--primary-base)] bg-[var(--primary-base)]/10 hover:text-[var(--primary-base)]'
: undefined
)}
onClick={() => handleModifierToggle('cmd')} onClick={() => handleModifierToggle('cmd')}
disabled={quickKeysDisabled} disabled={quickKeysDisabled}
> >
@@ -278,15 +278,15 @@ export const PullRequestSection: React.FC<{
const githubAuthStatus = useGitHubAuthStore((state) => state.status); const githubAuthStatus = useGitHubAuthStore((state) => state.status);
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked); const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
const setSidebarSection = useUIStore((state) => state.setSidebarSection); const setSettingsPage = useUIStore((state) => state.setSettingsPage);
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
const currentSessionId = useSessionStore((state) => state.currentSessionId); const currentSessionId = useSessionStore((state) => state.currentSessionId);
const { isMobile, hasTouchInput } = useDeviceInfo(); const { isMobile, hasTouchInput } = useDeviceInfo();
const openGitHubSettings = React.useCallback(() => { const openGitHubSettings = React.useCallback(() => {
setSidebarSection('settings'); setSettingsPage('github');
setSettingsDialogOpen(true); setSettingsDialogOpen(true);
}, [setSettingsDialogOpen, setSidebarSection]); }, [setSettingsDialogOpen, setSettingsPage]);
const snapshotKey = React.useMemo(() => getPullRequestSnapshotKey(directory, branch), [directory, branch]); const snapshotKey = React.useMemo(() => getPullRequestSnapshotKey(directory, branch), [directory, branch]);
const initialSnapshot = React.useMemo( const initialSnapshot = React.useMemo(
+11 -3
View File
@@ -15,7 +15,13 @@ export interface ModelListItem {
export const useModelLists = () => { export const useModelLists = () => {
const { providers } = useConfigStore(); const { providers } = useConfigStore();
const { favoriteModels, recentModels } = useUIStore(); const favoriteModels = useUIStore((state) => state.favoriteModels);
const recentModels = useUIStore((state) => state.recentModels);
const hiddenModels = useUIStore((state) => state.hiddenModels);
const isHidden = React.useCallback((providerID: string, modelID: string) => {
return hiddenModels.some((item) => item.providerID === providerID && item.modelID === modelID);
}, [hiddenModels]);
const favoriteModelsList = React.useMemo(() => { const favoriteModelsList = React.useMemo(() => {
return favoriteModels return favoriteModels
@@ -25,10 +31,11 @@ export const useModelLists = () => {
const providerModels = Array.isArray(provider.models) ? provider.models : []; const providerModels = Array.isArray(provider.models) ? provider.models : [];
const model = providerModels.find((m: ProviderModel) => m.id === modelID); const model = providerModels.find((m: ProviderModel) => m.id === modelID);
if (!model) return null; if (!model) return null;
if (isHidden(providerID, modelID)) return null;
return { provider, model, providerID, modelID }; return { provider, model, providerID, modelID };
}) })
.filter((item): item is ModelListItem => item !== null); .filter((item): item is ModelListItem => item !== null);
}, [favoriteModels, providers]); }, [favoriteModels, providers, isHidden]);
const recentModelsList = React.useMemo(() => { const recentModelsList = React.useMemo(() => {
return recentModels return recentModels
@@ -38,13 +45,14 @@ export const useModelLists = () => {
const providerModels = Array.isArray(provider.models) ? provider.models : []; const providerModels = Array.isArray(provider.models) ? provider.models : [];
const model = providerModels.find((m: ProviderModel) => m.id === modelID); const model = providerModels.find((m: ProviderModel) => m.id === modelID);
if (!model) return null; if (!model) return null;
if (isHidden(providerID, modelID)) return null;
return { provider, model, providerID, modelID }; return { provider, model, providerID, modelID };
}) })
.filter((item): item is ModelListItem => item !== null) .filter((item): item is ModelListItem => item !== null)
.filter(({ providerID, modelID }) => .filter(({ providerID, modelID }) =>
!favoriteModels.some(fav => fav.providerID === providerID && fav.modelID === modelID) !favoriteModels.some(fav => fav.providerID === providerID && fav.modelID === modelID)
); );
}, [recentModels, providers, favoriteModels]); }, [recentModels, providers, favoriteModels, isHidden]);
return { favoriteModelsList, recentModelsList }; return { favoriteModelsList, recentModelsList };
}; };
+36 -8
View File
@@ -17,9 +17,36 @@ const LOCAL_PROVIDER_LOGO_MAP = new Map<string, string>();
const LOGO_ALIAS = new Map<string, string>([ const LOGO_ALIAS = new Map<string, string>([
['codex', 'openai'], ['codex', 'openai'],
['chatgpt', 'openai'],
['claude', 'anthropic'], ['claude', 'anthropic'],
['gemini', 'google'],
['evroc-ai', 'evroc'],
['evrocai', 'evroc'],
]); ]);
const normalizeProviderId = (providerId: string | null | undefined) => {
return (providerId ?? '')
.toLowerCase()
.trim()
.replace(/^models\./, '')
.replace(/^provider\./, '')
.replace(/\s+/g, '-');
};
const buildLogoCandidates = (providerId: string | null | undefined) => {
const normalized = normalizeProviderId(providerId);
if (!normalized) {
return [] as string[];
}
const compact = normalized.replace(/[^a-z0-9_\-./:]/g, '');
const primary = compact.split(/[/:]/)[0] || compact;
const candidates = [compact, primary, LOGO_ALIAS.get(compact), LOGO_ALIAS.get(primary)]
.filter((value): value is string => Boolean(value && value.length > 0));
return [...new Set(candidates)];
};
for (const [path, url] of Object.entries(localLogoModules)) { for (const [path, url] of Object.entries(localLogoModules)) {
const match = path.match(/provider-logos\/([^/]+)\.svg$/i); const match = path.match(/provider-logos\/([^/]+)\.svg$/i);
if (match?.[1] && url) { if (match?.[1] && url) {
@@ -28,22 +55,23 @@ for (const [path, url] of Object.entries(localLogoModules)) {
} }
export function useProviderLogo(providerId: string | null | undefined): UseProviderLogoReturn { export function useProviderLogo(providerId: string | null | undefined): UseProviderLogoReturn {
const normalizedId = providerId?.toLowerCase() ?? null; const candidates = buildLogoCandidates(providerId);
const resolvedId = normalizedId ? LOGO_ALIAS.get(normalizedId) ?? normalizedId : null; const localResolvedId = candidates.find((candidate) => LOCAL_PROVIDER_LOGO_MAP.has(candidate)) ?? null;
const hasLocalLogo = resolvedId ? LOCAL_PROVIDER_LOGO_MAP.has(resolvedId) : false; const remoteResolvedId = candidates[0] ?? null;
const localLogoSrc = resolvedId ? LOCAL_PROVIDER_LOGO_MAP.get(resolvedId) ?? null : null; const hasLocalLogo = Boolean(localResolvedId);
const localLogoSrc = localResolvedId ? LOCAL_PROVIDER_LOGO_MAP.get(localResolvedId) ?? null : null;
const [source, setSource] = useState<LogoSource>(hasLocalLogo ? 'local' : 'remote'); const [source, setSource] = useState<LogoSource>(hasLocalLogo ? 'local' : 'remote');
useEffect(() => { useEffect(() => {
setSource(hasLocalLogo ? 'local' : 'remote'); setSource(hasLocalLogo ? 'local' : 'remote');
}, [hasLocalLogo, resolvedId]); }, [hasLocalLogo, localResolvedId, remoteResolvedId]);
const handleError = useCallback(() => { const handleError = useCallback(() => {
setSource((current) => (current === 'local' && hasLocalLogo ? 'remote' : 'none')); setSource((current) => (current === 'local' && hasLocalLogo ? 'remote' : 'none'));
}, [hasLocalLogo]); }, [hasLocalLogo]);
if (!resolvedId) { if (!localResolvedId && !remoteResolvedId) {
return { src: null, onError: handleError, hasLogo: false }; return { src: null, onError: handleError, hasLogo: false };
} }
@@ -55,9 +83,9 @@ export function useProviderLogo(providerId: string | null | undefined): UseProvi
}; };
} }
if (source === 'remote') { if (source === 'remote' && remoteResolvedId) {
return { return {
src: `https://models.dev/logos/${resolvedId}.svg`, src: `https://models.dev/logos/${remoteResolvedId}.svg`,
onError: handleError, onError: handleError,
hasLogo: true, hasLogo: true,
}; };
+17 -18
View File
@@ -3,8 +3,8 @@ import { useSessionStore } from '@/stores/useSessionStore';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { parseRoute, updateBrowserURL, hasRouteParams } from '@/lib/router'; import { parseRoute, updateBrowserURL, hasRouteParams } from '@/lib/router';
import type { RouteState, AppRouteState } from '@/lib/router'; import type { RouteState, AppRouteState } from '@/lib/router';
import type { SidebarSection } from '@/constants/sidebar';
import type { MainTab } from '@/stores/useUIStore'; import type { MainTab } from '@/stores/useUIStore';
import { resolveSettingsSlug } from '@/lib/settings/metadata';
/** /**
* Check if running in VS Code webview context. * Check if running in VS Code webview context.
@@ -41,7 +41,7 @@ export function useRouter(): void {
const setCurrentSession = useSessionStore((state) => state.setCurrentSession); const setCurrentSession = useSessionStore((state) => state.setCurrentSession);
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
const setSidebarSection = useUIStore((state) => state.setSidebarSection); const setSettingsPage = useUIStore((state) => state.setSettingsPage);
const navigateToDiff = useUIStore((state) => state.navigateToDiff); const navigateToDiff = useUIStore((state) => state.navigateToDiff);
/** /**
@@ -65,8 +65,8 @@ export function useRouter(): void {
} }
// 2. Handle settings (takes precedence over tabs - it's a full-screen overlay) // 2. Handle settings (takes precedence over tabs - it's a full-screen overlay)
if (route.settingsSection) { if (route.settingsPath) {
setSidebarSection(route.settingsSection); setSettingsPage(resolveSettingsSlug(route.settingsPath));
setSettingsDialogOpen(true); setSettingsDialogOpen(true);
// Don't process tab when settings is open // Don't process tab when settings is open
return; return;
@@ -90,7 +90,7 @@ export function useRouter(): void {
isApplyingRouteRef.current = false; isApplyingRouteRef.current = false;
} }
}, },
[setCurrentSession, setActiveMainTab, setSettingsDialogOpen, setSidebarSection, navigateToDiff] [setCurrentSession, setActiveMainTab, setSettingsDialogOpen, setSettingsPage, navigateToDiff]
); );
/** /**
@@ -104,7 +104,7 @@ export function useRouter(): void {
sessionId: sessionState.currentSessionId, sessionId: sessionState.currentSessionId,
tab: uiState.activeMainTab, tab: uiState.activeMainTab,
isSettingsOpen: uiState.isSettingsDialogOpen, isSettingsOpen: uiState.isSettingsDialogOpen,
settingsSection: uiState.sidebarSection, settingsPath: uiState.settingsPage,
diffFile: uiState.pendingDiffFile, diffFile: uiState.pendingDiffFile,
}; };
}, []); }, []);
@@ -183,7 +183,7 @@ export function useRouter(): void {
let prevTab: MainTab = useUIStore.getState().activeMainTab; let prevTab: MainTab = useUIStore.getState().activeMainTab;
let prevSettingsOpen: boolean = useUIStore.getState().isSettingsDialogOpen; let prevSettingsOpen: boolean = useUIStore.getState().isSettingsDialogOpen;
let prevSettingsSection: SidebarSection = useUIStore.getState().sidebarSection; let prevSettingsPath: string = useUIStore.getState().settingsPage;
let prevDiffFile: string | null = useUIStore.getState().pendingDiffFile; let prevDiffFile: string | null = useUIStore.getState().pendingDiffFile;
const unsubscribe = useUIStore.subscribe((state) => { const unsubscribe = useUIStore.subscribe((state) => {
@@ -194,17 +194,17 @@ export function useRouter(): void {
const tabChanged = state.activeMainTab !== prevTab; const tabChanged = state.activeMainTab !== prevTab;
const settingsOpenChanged = state.isSettingsDialogOpen !== prevSettingsOpen; const settingsOpenChanged = state.isSettingsDialogOpen !== prevSettingsOpen;
const settingsSectionChanged = state.sidebarSection !== prevSettingsSection; const settingsPathChanged = state.settingsPage !== prevSettingsPath;
const diffFileChanged = state.pendingDiffFile !== prevDiffFile && state.activeMainTab === 'diff'; const diffFileChanged = state.pendingDiffFile !== prevDiffFile && state.activeMainTab === 'diff';
// Update tracking vars // Update tracking vars
prevTab = state.activeMainTab; prevTab = state.activeMainTab;
prevSettingsOpen = state.isSettingsDialogOpen; prevSettingsOpen = state.isSettingsDialogOpen;
prevSettingsSection = state.sidebarSection; prevSettingsPath = state.settingsPage;
prevDiffFile = state.pendingDiffFile; prevDiffFile = state.pendingDiffFile;
// Only sync if something relevant changed // Only sync if something relevant changed
if (tabChanged || settingsOpenChanged || settingsSectionChanged || diffFileChanged) { if (tabChanged || settingsOpenChanged || settingsPathChanged || diffFileChanged) {
syncURLFromState(); syncURLFromState();
} }
}); });
@@ -263,8 +263,8 @@ export function navigateToRoute(route: Partial<RouteState>): void {
if (route.sessionId) { if (route.sessionId) {
void useSessionStore.getState().setCurrentSession(route.sessionId); void useSessionStore.getState().setCurrentSession(route.sessionId);
} }
if (route.settingsSection) { if (route.settingsPath) {
useUIStore.getState().setSidebarSection(route.settingsSection); useUIStore.getState().setSettingsPage(resolveSettingsSlug(route.settingsPath));
useUIStore.getState().setSettingsDialogOpen(true); useUIStore.getState().setSettingsDialogOpen(true);
} else if (route.tab) { } else if (route.tab) {
useUIStore.getState().setActiveMainTab(route.tab); useUIStore.getState().setActiveMainTab(route.tab);
@@ -281,8 +281,8 @@ export function navigateToRoute(route: Partial<RouteState>): void {
if (route.sessionId) { if (route.sessionId) {
params.set('session', route.sessionId); params.set('session', route.sessionId);
} }
if (route.settingsSection) { if (route.settingsPath) {
params.set('settings', route.settingsSection); params.set('settings', route.settingsPath);
} else if (route.tab && route.tab !== 'chat') { } else if (route.tab && route.tab !== 'chat') {
if (useUIStore.getState().isSettingsDialogOpen) { if (useUIStore.getState().isSettingsDialogOpen) {
useUIStore.getState().setSettingsDialogOpen(false); useUIStore.getState().setSettingsDialogOpen(false);
@@ -302,8 +302,8 @@ export function navigateToRoute(route: Partial<RouteState>): void {
if (route.sessionId) { if (route.sessionId) {
void useSessionStore.getState().setCurrentSession(route.sessionId); void useSessionStore.getState().setCurrentSession(route.sessionId);
} }
if (route.settingsSection) { if (route.settingsPath) {
useUIStore.getState().setSidebarSection(route.settingsSection); useUIStore.getState().setSettingsPage(resolveSettingsSlug(route.settingsPath));
useUIStore.getState().setSettingsDialogOpen(true); useUIStore.getState().setSettingsDialogOpen(true);
} else if (route.tab) { } else if (route.tab) {
useUIStore.getState().setActiveMainTab(route.tab); useUIStore.getState().setActiveMainTab(route.tab);
@@ -331,8 +331,7 @@ export function getShareableURL(): string {
} }
if (uiState.isSettingsDialogOpen) { if (uiState.isSettingsDialogOpen) {
const settingsSection = uiState.sidebarSection === 'sessions' ? 'settings' : uiState.sidebarSection; params.set('settings', uiState.settingsPage || 'home');
params.set('settings', settingsSection);
} else if (uiState.activeMainTab !== 'chat') { } else if (uiState.activeMainTab !== 'chat') {
params.set('tab', uiState.activeMainTab); params.set('tab', uiState.activeMainTab);
} }
+3 -1
View File
@@ -1070,5 +1070,7 @@ textarea[data-terminal-hidden-input="true"]::placeholder {
.animate-grid-pulse, .animate-grid-pulse,
.animate-attention-diamond-pulse { .animate-attention-diamond-pulse {
animation: none; animation: none;
} }
} }
+1
View File
@@ -511,6 +511,7 @@ export interface SettingsPayload {
pinnedDirectories?: string[]; pinnedDirectories?: string[];
showReasoningTraces?: boolean; showReasoningTraces?: boolean;
showTextJustificationActivity?: boolean; showTextJustificationActivity?: boolean;
showDeletionDialog?: boolean;
nativeNotificationsEnabled?: boolean; nativeNotificationsEnabled?: boolean;
notificationMode?: 'always' | 'hidden-only'; notificationMode?: 'always' | 'hidden-only';
autoDeleteEnabled?: boolean; autoDeleteEnabled?: boolean;
@@ -5,6 +5,7 @@ import type { DesktopSettings } from '@/lib/desktop';
type AppearanceSlice = { type AppearanceSlice = {
showReasoningTraces: boolean; showReasoningTraces: boolean;
showTextJustificationActivity: boolean; showTextJustificationActivity: boolean;
showDeletionDialog: boolean;
nativeNotificationsEnabled: boolean; nativeNotificationsEnabled: boolean;
notificationMode: 'always' | 'hidden-only'; notificationMode: 'always' | 'hidden-only';
notifyOnSubtasks: boolean; notifyOnSubtasks: boolean;
@@ -45,6 +46,7 @@ export const startAppearanceAutoSave = (): void => {
let previous: AppearanceSlice = { let previous: AppearanceSlice = {
showReasoningTraces: useUIStore.getState().showReasoningTraces, showReasoningTraces: useUIStore.getState().showReasoningTraces,
showTextJustificationActivity: useUIStore.getState().showTextJustificationActivity, showTextJustificationActivity: useUIStore.getState().showTextJustificationActivity,
showDeletionDialog: useUIStore.getState().showDeletionDialog,
nativeNotificationsEnabled: useUIStore.getState().nativeNotificationsEnabled, nativeNotificationsEnabled: useUIStore.getState().nativeNotificationsEnabled,
notificationMode: useUIStore.getState().notificationMode, notificationMode: useUIStore.getState().notificationMode,
notifyOnSubtasks: useUIStore.getState().notifyOnSubtasks, notifyOnSubtasks: useUIStore.getState().notifyOnSubtasks,
@@ -92,6 +94,7 @@ export const startAppearanceAutoSave = (): void => {
const current: AppearanceSlice = { const current: AppearanceSlice = {
showReasoningTraces: state.showReasoningTraces, showReasoningTraces: state.showReasoningTraces,
showTextJustificationActivity: state.showTextJustificationActivity, showTextJustificationActivity: state.showTextJustificationActivity,
showDeletionDialog: state.showDeletionDialog,
nativeNotificationsEnabled: state.nativeNotificationsEnabled, nativeNotificationsEnabled: state.nativeNotificationsEnabled,
notificationMode: state.notificationMode, notificationMode: state.notificationMode,
notifyOnSubtasks: state.notifyOnSubtasks, notifyOnSubtasks: state.notifyOnSubtasks,
@@ -123,6 +126,9 @@ export const startAppearanceAutoSave = (): void => {
if (current.showTextJustificationActivity !== previous.showTextJustificationActivity) { if (current.showTextJustificationActivity !== previous.showTextJustificationActivity) {
diff.showTextJustificationActivity = current.showTextJustificationActivity; diff.showTextJustificationActivity = current.showTextJustificationActivity;
} }
if (current.showDeletionDialog !== previous.showDeletionDialog) {
diff.showDeletionDialog = current.showDeletionDialog;
}
if (current.nativeNotificationsEnabled !== previous.nativeNotificationsEnabled) { if (current.nativeNotificationsEnabled !== previous.nativeNotificationsEnabled) {
diff.nativeNotificationsEnabled = current.nativeNotificationsEnabled; diff.nativeNotificationsEnabled = current.nativeNotificationsEnabled;
} }
+1
View File
@@ -46,6 +46,7 @@ export type DesktopSettings = {
pinnedDirectories?: string[]; pinnedDirectories?: string[];
showReasoningTraces?: boolean; showReasoningTraces?: boolean;
showTextJustificationActivity?: boolean; showTextJustificationActivity?: boolean;
showDeletionDialog?: boolean;
nativeNotificationsEnabled?: boolean; nativeNotificationsEnabled?: boolean;
notificationMode?: 'always' | 'hidden-only'; notificationMode?: 'always' | 'hidden-only';
notifyOnSubtasks?: boolean; notifyOnSubtasks?: boolean;
+1 -2
View File
@@ -1806,7 +1806,7 @@ class OpencodeService {
} }
} }
async listCommandsWithDetails(): Promise<Array<{ name: string; description?: string; agent?: string; model?: string; template?: string; subtask?: boolean }>> { async listCommandsWithDetails(): Promise<Array<{ name: string; description?: string; agent?: string; model?: string; template?: string }>> {
try { try {
const response = await this.client.command.list( const response = await this.client.command.list(
this.currentDirectory ? { directory: this.currentDirectory } : undefined this.currentDirectory ? { directory: this.currentDirectory } : undefined
@@ -1818,7 +1818,6 @@ class OpencodeService {
agent: cmd.agent as string | undefined, agent: cmd.agent as string | undefined,
model: cmd.model as string | undefined, model: cmd.model as string | undefined,
template: cmd.template as string | undefined, template: cmd.template as string | undefined,
subtask: cmd.subtask as boolean | undefined
})); }));
} catch { } catch {
return []; return [];
+6
View File
@@ -238,6 +238,9 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => {
if (typeof settings.showTextJustificationActivity === 'boolean' && settings.showTextJustificationActivity !== store.showTextJustificationActivity) { if (typeof settings.showTextJustificationActivity === 'boolean' && settings.showTextJustificationActivity !== store.showTextJustificationActivity) {
store.setShowTextJustificationActivity(settings.showTextJustificationActivity); store.setShowTextJustificationActivity(settings.showTextJustificationActivity);
} }
if (typeof settings.showDeletionDialog === 'boolean' && settings.showDeletionDialog !== store.showDeletionDialog) {
store.setShowDeletionDialog(settings.showDeletionDialog);
}
if (typeof settings.nativeNotificationsEnabled === 'boolean' && settings.nativeNotificationsEnabled !== store.nativeNotificationsEnabled) { if (typeof settings.nativeNotificationsEnabled === 'boolean' && settings.nativeNotificationsEnabled !== store.nativeNotificationsEnabled) {
store.setNativeNotificationsEnabled(settings.nativeNotificationsEnabled); store.setNativeNotificationsEnabled(settings.nativeNotificationsEnabled);
} }
@@ -426,6 +429,9 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
if (typeof candidate.showTextJustificationActivity === 'boolean') { if (typeof candidate.showTextJustificationActivity === 'boolean') {
result.showTextJustificationActivity = candidate.showTextJustificationActivity; result.showTextJustificationActivity = candidate.showTextJustificationActivity;
} }
if (typeof candidate.showDeletionDialog === 'boolean') {
result.showDeletionDialog = candidate.showDeletionDialog;
}
if (typeof candidate.nativeNotificationsEnabled === 'boolean') { if (typeof candidate.nativeNotificationsEnabled === 'boolean') {
result.nativeNotificationsEnabled = candidate.nativeNotificationsEnabled; result.nativeNotificationsEnabled = candidate.nativeNotificationsEnabled;
} }
+13 -10
View File
@@ -1,4 +1,3 @@
import type { SidebarSection } from '@/constants/sidebar';
import type { MainTab } from '@/stores/useUIStore'; import type { MainTab } from '@/stores/useUIStore';
import { import {
type RouteState, type RouteState,
@@ -17,7 +16,7 @@ export function parseRoute(searchParams?: URLSearchParams): RouteState {
return { return {
sessionId: parseSessionId(params), sessionId: parseSessionId(params),
tab: parseTab(params), tab: parseTab(params),
settingsSection: parseSettingsSection(params), settingsPath: parseSettingsPath(params),
diffFile: parseDiffFile(params), diffFile: parseDiffFile(params),
}; };
} }
@@ -68,10 +67,10 @@ function parseTab(params: URLSearchParams): MainTab | null {
} }
/** /**
* Parse settings section from URL parameters. * Parse settings target from URL parameters.
* Returns null if missing or invalid. * Returns null if missing/empty.
*/ */
function parseSettingsSection(params: URLSearchParams): SidebarSection | null { function parseSettingsPath(params: URLSearchParams): string | null {
const value = params.get(ROUTE_PARAMS.SETTINGS); const value = params.get(ROUTE_PARAMS.SETTINGS);
if (!value) { if (!value) {
return null; return null;
@@ -79,17 +78,21 @@ function parseSettingsSection(params: URLSearchParams): SidebarSection | null {
const normalized = value.toLowerCase().trim(); const normalized = value.toLowerCase().trim();
// Check if it's a valid section if (normalized.length === 0) {
if ((VALID_SETTINGS_SECTIONS as readonly string[]).includes(normalized)) { return null;
return normalized as SidebarSection;
} }
// Handle common aliases // Handle common aliases
if (normalized === 'openchamber' || normalized === 'general' || normalized === 'preferences') { if (normalized === 'openchamber' || normalized === 'general' || normalized === 'preferences') {
return 'settings'; return 'home';
} }
return null; // Keep legacy section ids as-is (mapping happens at apply time).
if ((VALID_SETTINGS_SECTIONS as readonly string[]).includes(normalized)) {
return normalized;
}
return normalized;
} }
/** /**
+3 -4
View File
@@ -1,4 +1,3 @@
import type { SidebarSection } from '@/constants/sidebar';
import type { MainTab } from '@/stores/useUIStore'; import type { MainTab } from '@/stores/useUIStore';
import { ROUTE_PARAMS } from './types'; import { ROUTE_PARAMS } from './types';
@@ -9,7 +8,7 @@ export interface AppRouteState {
sessionId: string | null; sessionId: string | null;
tab: MainTab; tab: MainTab;
isSettingsOpen: boolean; isSettingsOpen: boolean;
settingsSection: SidebarSection; settingsPath: string;
diffFile: string | null; diffFile: string | null;
} }
@@ -32,8 +31,8 @@ export function serializeRoute(state: AppRouteState): URLSearchParams {
// Settings takes precedence - if open, include settings section // Settings takes precedence - if open, include settings section
if (state.isSettingsOpen) { if (state.isSettingsOpen) {
const settingsSection = state.settingsSection === 'sessions' ? 'settings' : state.settingsSection; const settingsPath = state.settingsPath.trim().length > 0 ? state.settingsPath : 'home';
params.set(ROUTE_PARAMS.SETTINGS, settingsSection); params.set(ROUTE_PARAMS.SETTINGS, settingsPath);
// Don't include tab when settings is open (it's a full-screen overlay) // Don't include tab when settings is open (it's a full-screen overlay)
return params; return params;
} }
+2 -1
View File
@@ -11,7 +11,7 @@ export interface RouteState {
/** Main tab to display (chat, git, diff, terminal, files) */ /** Main tab to display (chat, git, diff, terminal, files) */
tab: MainTab | null; tab: MainTab | null;
/** Settings section - when non-null, settings dialog should be open */ /** Settings section - when non-null, settings dialog should be open */
settingsSection: SidebarSection | null; settingsPath: string | null;
/** File path for diff view */ /** File path for diff view */
diffFile: string | null; diffFile: string | null;
} }
@@ -40,6 +40,7 @@ export const VALID_SETTINGS_SECTIONS: readonly SidebarSection[] = [
'commands', 'commands',
'skills', 'skills',
'providers', 'providers',
'usage',
'git-identities', 'git-identities',
] as const; ] as const;
+198
View File
@@ -0,0 +1,198 @@
import type { SidebarSection } from '@/constants/sidebar';
export type SettingsPageSlug =
| 'home'
| 'projects'
| 'providers'
| 'usage'
| 'agents'
| 'commands'
| 'mcp'
| 'skills.installed'
| 'skills.catalog'
| 'git'
| 'appearance'
| 'chat'
| 'shortcuts'
| 'sessions'
| 'notifications'
| 'voice';
export type SettingsPageGroup =
| 'appearance'
| 'projects'
| 'general'
| 'opencode'
| 'git'
| 'skills'
| 'usage'
| 'advanced';
export interface SettingsRuntimeContext {
isVSCode: boolean;
isWeb: boolean;
isDesktop: boolean;
}
export interface SettingsPageMeta {
slug: SettingsPageSlug;
title: string;
group: SettingsPageGroup;
kind: 'single' | 'split';
description?: string;
keywords?: string[];
isAvailable?: (ctx: SettingsRuntimeContext) => boolean;
}
export const SETTINGS_GROUP_LABELS: Record<SettingsPageGroup, string> = {
appearance: 'Appearance',
projects: 'Projects',
general: 'General',
opencode: 'OpenCode',
git: 'Git',
skills: 'Skills',
usage: 'Usage',
advanced: 'Advanced',
};
export const SETTINGS_PAGE_METADATA: readonly SettingsPageMeta[] = [
{
slug: 'home',
title: 'Settings',
group: 'general',
kind: 'single',
description: 'Search and jump to common pages.',
keywords: ['search', 'settings'],
},
{
slug: 'projects',
title: 'Projects',
group: 'projects',
kind: 'split',
keywords: ['project', 'projects', 'worktree', 'worktrees', 'repo', 'repository', 'directory'],
},
{
slug: 'providers',
title: 'Providers',
group: 'opencode',
kind: 'split',
keywords: ['provider', 'providers', 'models', 'model', 'api key', 'api keys', 'openai', 'anthropic', 'ollama', 'credentials'],
},
{
slug: 'usage',
title: 'Usage',
group: 'usage',
kind: 'split',
keywords: ['quota', 'billing', 'tokens', 'usage', 'limits'],
},
{
slug: 'agents',
title: 'Agents',
group: 'opencode',
kind: 'split',
keywords: ['agent', 'agents', 'prompts', 'tools', 'permissions'],
},
{
slug: 'commands',
title: 'Commands',
group: 'opencode',
kind: 'split',
keywords: ['command', 'commands', 'slash', 'macros', 'automation'],
},
{
slug: 'mcp',
title: 'MCP',
group: 'opencode',
kind: 'split',
keywords: ['mcp', 'model context protocol', 'servers', 'tools', 'remote', 'stdio'],
},
{
slug: 'skills.installed',
title: 'Skills',
group: 'skills',
kind: 'split',
keywords: ['skill', 'skills', 'instructions', 'install', 'catalog'],
},
{
slug: 'skills.catalog',
title: 'Skills Catalog',
group: 'skills',
kind: 'single',
keywords: ['install', 'catalog', 'external', 'repository', 'skills catalog'],
},
{
slug: 'git',
title: 'Git',
group: 'git',
kind: 'single',
keywords: ['git', 'github', 'identity', 'identities', 'ssh', 'profiles', 'credentials', 'keys', 'commit', 'gitmoji', 'oauth', 'prs', 'issues'],
isAvailable: (ctx) => !ctx.isVSCode,
},
{
slug: 'appearance',
title: 'Appearance',
group: 'appearance',
kind: 'single',
keywords: ['theme', 'font', 'spacing', 'padding', 'corner radius', 'radius', 'input bar', 'terminal'],
},
{
slug: 'chat',
title: 'Chat',
group: 'general',
kind: 'single',
keywords: ['tools', 'diff', 'reasoning', 'dotfiles', 'draft', 'queue', 'output'],
},
{
slug: 'shortcuts',
title: 'Shortcuts',
group: 'general',
kind: 'single',
keywords: ['keyboard', 'hotkeys', 'shortcuts', 'bindings'],
},
{
slug: 'sessions',
title: 'Sessions',
group: 'general',
kind: 'single',
keywords: ['defaults', 'default agent', 'default model', 'retention', 'memory', 'limits', 'zen'],
},
{ slug: 'notifications', title: 'Notifications', group: 'general', kind: 'single', keywords: ['alerts', 'native', 'summary', 'summarization'], },
{ slug: 'voice', title: 'Voice', group: 'advanced', kind: 'single', keywords: ['tts', 'speech', 'voice'], isAvailable: (ctx) => !ctx.isVSCode },
] as const;
export const LEGACY_SIDEBAR_SECTION_TO_SETTINGS_SLUG: Record<SidebarSection, SettingsPageSlug> = {
sessions: 'sessions',
agents: 'agents',
commands: 'commands',
mcp: 'mcp',
skills: 'skills.installed',
providers: 'providers',
usage: 'usage',
'git-identities': 'git',
settings: 'home',
};
export function getSettingsPageMeta(slug: string): SettingsPageMeta | null {
const normalized = slug.trim().toLowerCase();
return (SETTINGS_PAGE_METADATA as readonly SettingsPageMeta[]).find((page) => page.slug === normalized) ?? null;
}
export function resolveSettingsSlug(value: string | null | undefined): SettingsPageSlug {
const normalized = (value ?? '').trim().toLowerCase();
if (!normalized) {
return 'home';
}
const legacy = (LEGACY_SIDEBAR_SECTION_TO_SETTINGS_SLUG as Record<string, SettingsPageSlug>)[normalized];
if (legacy) {
return legacy;
}
const direct = getSettingsPageMeta(normalized);
if (direct) {
return direct.slug;
}
return 'home';
}
@@ -9,15 +9,15 @@
}, },
"colors": { "colors": {
"primary": { "primary": {
"base": "#EC8B49", "base": "#DA702C",
"hover": "#DA702C", "hover": "#DA702C",
"active": "#F9AE77", "active": "#F9AE77",
"foreground": "#100F0F", "foreground": "#111010",
"muted": "#EC8B4980", "muted": "#DA702C80",
"emphasis": "#F9AE77" "emphasis": "#F9AE77"
}, },
"surface": { "surface": {
"background": "#100F0F", "background": "#111010",
"foreground": "#CECDC3", "foreground": "#CECDC3",
"muted": "#1C1B1A", "muted": "#1C1B1A",
"mutedForeground": "#878580", "mutedForeground": "#878580",
@@ -29,30 +29,30 @@
"interactive": { "interactive": {
"border": "#343331", "border": "#343331",
"borderHover": "#403E3C", "borderHover": "#403E3C",
"borderFocus": "#EC8B49", "borderFocus": "#DA702C",
"selection": "#f4f4f41f", "selection": "#f4f4f41f",
"selectionForeground": "#CECDC3", "selectionForeground": "#CECDC3",
"focus": "#EC8B49", "focus": "#DA702C",
"focusRing": "#EC8B4950", "focusRing": "#DA702C50",
"cursor": "#CECDC3", "cursor": "#CECDC3",
"hover": "#ffffff18", "hover": "#ffffff18",
"active": "#ffffff1f" "active": "#ffffff1f"
}, },
"status": { "status": {
"error": "#D14D41", "error": "#D14D41",
"errorForeground": "#100F0F", "errorForeground": "#111010",
"errorBackground": "#AF302920", "errorBackground": "#AF302920",
"errorBorder": "#AF302950", "errorBorder": "#AF302950",
"warning": "#DA702C", "warning": "#DA702C",
"warningForeground": "#100F0F", "warningForeground": "#111010",
"warningBackground": "#BC521520", "warningBackground": "#BC521520",
"warningBorder": "#BC521550", "warningBorder": "#BC521550",
"success": "#A0AF54", "success": "#A0AF54",
"successForeground": "#100F0F", "successForeground": "#111010",
"successBackground": "#66800B20", "successBackground": "#66800B20",
"successBorder": "#66800B50", "successBorder": "#66800B50",
"info": "#4385BE", "info": "#4385BE",
"infoForeground": "#100F0F", "infoForeground": "#111010",
"infoBackground": "#205EA620", "infoBackground": "#205EA620",
"infoBorder": "#205EA650" "infoBorder": "#205EA650"
}, },
@@ -138,9 +138,9 @@
}, },
"chat": { "chat": {
"userMessage": "#CECDC3", "userMessage": "#CECDC3",
"userMessageBackground": "#2d1d15", "userMessageBackground": "#2E1B10",
"assistantMessage": "#CECDC3", "assistantMessage": "#CECDC3",
"assistantMessageBackground": "#100F0F", "assistantMessageBackground": "#111010",
"timestamp": "#878580", "timestamp": "#878580",
"divider": "#343331" "divider": "#343331"
}, },
@@ -9,11 +9,11 @@
}, },
"colors": { "colors": {
"primary": { "primary": {
"base": "#EC8B49", "base": "#BC5215",
"hover": "#F9AE77", "hover": "#F9AE77",
"active": "#DA702C", "active": "#DA702C",
"foreground": "#FFFCF0", "foreground": "#FFFCF0",
"muted": "#EC8B4980", "muted": "#BC521580",
"emphasis": "#F9AE77" "emphasis": "#F9AE77"
}, },
"surface": { "surface": {
@@ -24,16 +24,16 @@
"elevated": "#fdf6ec", "elevated": "#fdf6ec",
"elevatedForeground": "#100F0F", "elevatedForeground": "#100F0F",
"overlay": "#100F0F20", "overlay": "#100F0F20",
"subtle": "#f9f1e4" "subtle": "#F6F0E6"
}, },
"interactive": { "interactive": {
"border": "#DAD8CE", "border": "#DAD8CE",
"borderHover": "#CECDC3", "borderHover": "#CECDC3",
"borderFocus": "#EC8B49", "borderFocus": "#BC5215",
"selection": "#76736f30", "selection": "#76736f30",
"selectionForeground": "#100F0F", "selectionForeground": "#100F0F",
"focus": "#EC8B49", "focus": "#BC5215",
"focusRing": "#EC8B4940", "focusRing": "#BC521540",
"cursor": "#100F0F", "cursor": "#100F0F",
"hover": "#76736f20", "hover": "#76736f20",
"active": "#76736f20" "active": "#76736f20"
@@ -138,7 +138,7 @@
}, },
"chat": { "chat": {
"userMessage": "#100F0F", "userMessage": "#100F0F",
"userMessageBackground": "#f9f1e4", "userMessageBackground": "#F6F0E6",
"assistantMessage": "#100F0F", "assistantMessage": "#100F0F",
"assistantMessageBackground": "#FFFCF0", "assistantMessageBackground": "#FFFCF0",
"timestamp": "#6F6E69", "timestamp": "#6F6E69",
@@ -20,7 +20,6 @@ export interface CommandConfig {
agent?: string | null; agent?: string | null;
model?: string | null; model?: string | null;
template?: string; template?: string;
subtask?: boolean;
scope?: CommandScope; scope?: CommandScope;
} }
@@ -74,7 +73,6 @@ export interface CommandDraft {
agent?: string | null; agent?: string | null;
model?: string | null; model?: string | null;
template?: string; template?: string;
subtask?: boolean;
} }
interface CommandsStore { interface CommandsStore {
@@ -204,7 +202,6 @@ export const useCommandsStore = create<CommandsStore>()(
if (config.description) commandConfig.description = config.description; if (config.description) commandConfig.description = config.description;
if (config.agent) commandConfig.agent = config.agent; if (config.agent) commandConfig.agent = config.agent;
if (config.model) commandConfig.model = config.model; if (config.model) commandConfig.model = config.model;
if (config.subtask !== undefined) commandConfig.subtask = config.subtask;
if (config.scope) commandConfig.scope = config.scope; if (config.scope) commandConfig.scope = config.scope;
console.log('[CommandsStore] Command config to save:', commandConfig); console.log('[CommandsStore] Command config to save:', commandConfig);
@@ -267,7 +264,6 @@ export const useCommandsStore = create<CommandsStore>()(
if (config.agent !== undefined) commandConfig.agent = config.agent; if (config.agent !== undefined) commandConfig.agent = config.agent;
if (config.model !== undefined) commandConfig.model = config.model; if (config.model !== undefined) commandConfig.model = config.model;
if (config.template !== undefined) commandConfig.template = config.template; if (config.template !== undefined) commandConfig.template = config.template;
if (config.subtask !== undefined) commandConfig.subtask = config.subtask;
console.log('[CommandsStore] Command config to update:', commandConfig); console.log('[CommandsStore] Command config to update:', commandConfig);
@@ -15,6 +15,7 @@ import type {
import { refreshSkillsAfterOpenCodeRestart, useSkillsStore } from '@/stores/useSkillsStore'; import { refreshSkillsAfterOpenCodeRestart, useSkillsStore } from '@/stores/useSkillsStore';
import { opencodeClient } from '@/lib/opencode/client'; import { opencodeClient } from '@/lib/opencode/client';
import { startConfigUpdate, finishConfigUpdate, updateConfigUpdateMessage } from '@/lib/configUpdate';
const FALLBACK_SOURCES: SkillsCatalogSource[] = [ const FALLBACK_SOURCES: SkillsCatalogSource[] = [
{ {
@@ -79,7 +80,7 @@ export interface SkillsCatalogState {
loadSource: (sourceId: string, options?: { refresh?: boolean }) => Promise<boolean>; loadSource: (sourceId: string, options?: { refresh?: boolean }) => Promise<boolean>;
loadMoreClawdHub: () => Promise<boolean>; loadMoreClawdHub: () => Promise<boolean>;
scanRepo: (request: SkillsRepoScanRequest) => Promise<SkillsRepoScanResponse>; scanRepo: (request: SkillsRepoScanRequest) => Promise<SkillsRepoScanResponse>;
installSkills: (request: SkillsInstallRequest) => Promise<SkillsInstallResponse>; installSkills: (request: SkillsInstallRequest, options?: { directory?: string | null }) => Promise<SkillsInstallResponse>;
} }
export const useSkillsCatalogStore = create<SkillsCatalogState>()( export const useSkillsCatalogStore = create<SkillsCatalogState>()(
@@ -348,10 +349,15 @@ export const useSkillsCatalogStore = create<SkillsCatalogState>()(
} }
}, },
installSkills: async (request) => { installSkills: async (request, options) => {
startConfigUpdate('Installing skills…');
set({ isInstalling: true, lastInstallError: null }); set({ isInstalling: true, lastInstallError: null });
let requiresReload = false;
try { try {
const currentDirectory = getCurrentDirectory(); const directoryOverride = typeof options?.directory === 'string' && options.directory.trim().length > 0
? options.directory.trim()
: null;
const currentDirectory = directoryOverride ?? getCurrentDirectory();
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : ''; const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
const response = await fetch(`/api/config/skills/install${queryParams}`, { const response = await fetch(`/api/config/skills/install${queryParams}`, {
@@ -364,21 +370,25 @@ export const useSkillsCatalogStore = create<SkillsCatalogState>()(
if (!payload) { if (!payload) {
const error = { kind: 'unknown', message: 'Failed to install skills' } as SkillsInstallError; const error = { kind: 'unknown', message: 'Failed to install skills' } as SkillsInstallError;
set({ lastInstallError: error }); set({ lastInstallError: error });
updateConfigUpdateMessage('Failed to install skills. Please retry.');
return { ok: false, error }; return { ok: false, error };
} }
if (!response.ok || !payload.ok) { if (!response.ok || !payload.ok) {
const error = payload.error || ({ kind: 'unknown', message: 'Failed to install skills' } as SkillsInstallError); const error = payload.error || ({ kind: 'unknown', message: 'Failed to install skills' } as SkillsInstallError);
set({ lastInstallError: error }); set({ lastInstallError: error });
updateConfigUpdateMessage(error.message || 'Failed to install skills. Please retry.');
return { ok: false, error }; return { ok: false, error };
} }
if (payload.requiresReload) { if (payload.requiresReload) {
requiresReload = true;
await refreshSkillsAfterOpenCodeRestart({ await refreshSkillsAfterOpenCodeRestart({
message: payload.message, message: payload.message,
delayMs: payload.reloadDelayMs, delayMs: payload.reloadDelayMs,
}); });
} else { } else {
updateConfigUpdateMessage(payload.message || 'Refreshing skills…');
void useSkillsStore.getState().loadSkills(); void useSkillsStore.getState().loadSkills();
} }
@@ -386,9 +396,13 @@ export const useSkillsCatalogStore = create<SkillsCatalogState>()(
} catch (error) { } catch (error) {
const err = { kind: 'unknown', message: error instanceof Error ? error.message : String(error) } as SkillsInstallError; const err = { kind: 'unknown', message: error instanceof Error ? error.message : String(error) } as SkillsInstallError;
set({ lastInstallError: err }); set({ lastInstallError: err });
updateConfigUpdateMessage('Failed to install skills. Please retry.');
return { ok: false, error: err }; return { ok: false, error: err };
} finally { } finally {
set({ isInstalling: false }); set({ isInstalling: false });
if (!requiresReload) {
finishConfigUpdate();
}
} }
}, },
}), }),
+88 -1
View File
@@ -161,10 +161,16 @@ interface UIStore {
isSettingsDialogOpen: boolean; isSettingsDialogOpen: boolean;
isModelSelectorOpen: boolean; isModelSelectorOpen: boolean;
sidebarSection: SidebarSection; sidebarSection: SidebarSection;
// Settings IA (new shell)
settingsPage: string;
settingsHasOpenedOnce: boolean;
settingsProjectsSelectedId: string | null;
eventStreamStatus: EventStreamStatus; eventStreamStatus: EventStreamStatus;
eventStreamHint: string | null; eventStreamHint: string | null;
showReasoningTraces: boolean; showReasoningTraces: boolean;
showTextJustificationActivity: boolean; showTextJustificationActivity: boolean;
showDeletionDialog: boolean;
autoDeleteEnabled: boolean; autoDeleteEnabled: boolean;
autoDeleteAfterDays: number; autoDeleteAfterDays: number;
autoDeleteLastRunAt: number | null; autoDeleteLastRunAt: number | null;
@@ -178,6 +184,7 @@ interface UIStore {
inputBarOffset: number; inputBarOffset: number;
favoriteModels: Array<{ providerID: string; modelID: string }>; favoriteModels: Array<{ providerID: string; modelID: string }>;
hiddenModels: Array<{ providerID: string; modelID: string }>;
recentModels: Array<{ providerID: string; modelID: string }>; recentModels: Array<{ providerID: string; modelID: string }>;
recentAgents: string[]; recentAgents: string[];
recentEfforts: Record<string, string[]>; recentEfforts: Record<string, string[]>;
@@ -257,9 +264,12 @@ interface UIStore {
setModelSelectorOpen: (open: boolean) => void; setModelSelectorOpen: (open: boolean) => void;
applyTheme: () => void; applyTheme: () => void;
setSidebarSection: (section: SidebarSection) => void; setSidebarSection: (section: SidebarSection) => void;
setSettingsPage: (slug: string) => void;
setSettingsProjectsSelectedId: (projectId: string | null) => void;
setEventStreamStatus: (status: EventStreamStatus, hint?: string | null) => void; setEventStreamStatus: (status: EventStreamStatus, hint?: string | null) => void;
setShowReasoningTraces: (value: boolean) => void; setShowReasoningTraces: (value: boolean) => void;
setShowTextJustificationActivity: (value: boolean) => void; setShowTextJustificationActivity: (value: boolean) => void;
setShowDeletionDialog: (value: boolean) => void;
setAutoDeleteEnabled: (value: boolean) => void; setAutoDeleteEnabled: (value: boolean) => void;
setAutoDeleteAfterDays: (days: number) => void; setAutoDeleteAfterDays: (days: number) => void;
setAutoDeleteLastRunAt: (timestamp: number | null) => void; setAutoDeleteLastRunAt: (timestamp: number | null) => void;
@@ -275,6 +285,10 @@ interface UIStore {
applyPadding: () => void; applyPadding: () => void;
updateProportionalSidebarWidths: () => void; updateProportionalSidebarWidths: () => void;
toggleFavoriteModel: (providerID: string, modelID: string) => void; toggleFavoriteModel: (providerID: string, modelID: string) => void;
toggleHiddenModel: (providerID: string, modelID: string) => void;
isHiddenModel: (providerID: string, modelID: string) => boolean;
hideAllModels: (providerID: string, modelIDs: string[]) => void;
showAllModels: (providerID: string) => void;
isFavoriteModel: (providerID: string, modelID: string) => boolean; isFavoriteModel: (providerID: string, modelID: string) => boolean;
addRecentModel: (providerID: string, modelID: string) => void; addRecentModel: (providerID: string, modelID: string) => void;
addRecentAgent: (agentName: string) => void; addRecentAgent: (agentName: string) => void;
@@ -346,10 +360,14 @@ export const useUIStore = create<UIStore>()(
isSettingsDialogOpen: false, isSettingsDialogOpen: false,
isModelSelectorOpen: false, isModelSelectorOpen: false,
sidebarSection: 'sessions', sidebarSection: 'sessions',
settingsPage: 'home',
settingsHasOpenedOnce: false,
settingsProjectsSelectedId: null,
eventStreamStatus: 'idle', eventStreamStatus: 'idle',
eventStreamHint: null, eventStreamHint: null,
showReasoningTraces: true, showReasoningTraces: true,
showTextJustificationActivity: false, showTextJustificationActivity: false,
showDeletionDialog: true,
autoDeleteEnabled: false, autoDeleteEnabled: false,
autoDeleteAfterDays: 30, autoDeleteAfterDays: 30,
autoDeleteLastRunAt: null, autoDeleteLastRunAt: null,
@@ -361,6 +379,7 @@ export const useUIStore = create<UIStore>()(
cornerRadius: 12, cornerRadius: 12,
inputBarOffset: 0, inputBarOffset: 0,
favoriteModels: [], favoriteModels: [],
hiddenModels: [],
recentModels: [], recentModels: [],
recentAgents: [], recentAgents: [],
recentEfforts: {}, recentEfforts: {},
@@ -785,7 +804,15 @@ export const useUIStore = create<UIStore>()(
}, },
setSettingsDialogOpen: (open) => { setSettingsDialogOpen: (open) => {
set({ isSettingsDialogOpen: open }); set((state) => {
if (!open) {
return { isSettingsDialogOpen: false };
}
if (state.settingsHasOpenedOnce) {
return { isSettingsDialogOpen: true };
}
return { isSettingsDialogOpen: true, settingsHasOpenedOnce: true };
});
}, },
setModelSelectorOpen: (open) => { setModelSelectorOpen: (open) => {
@@ -796,6 +823,14 @@ export const useUIStore = create<UIStore>()(
set({ sidebarSection: section }); set({ sidebarSection: section });
}, },
setSettingsPage: (slug) => {
set({ settingsPage: slug });
},
setSettingsProjectsSelectedId: (projectId) => {
set({ settingsProjectsSelectedId: projectId });
},
setEventStreamStatus: (status, hint) => { setEventStreamStatus: (status, hint) => {
set({ set({
eventStreamStatus: status, eventStreamStatus: status,
@@ -811,6 +846,10 @@ export const useUIStore = create<UIStore>()(
set({ showTextJustificationActivity: value }); set({ showTextJustificationActivity: value });
}, },
setShowDeletionDialog: (value) => {
set({ showDeletionDialog: value });
},
setAutoDeleteEnabled: (value) => { setAutoDeleteEnabled: (value) => {
set({ autoDeleteEnabled: value }); set({ autoDeleteEnabled: value });
}, },
@@ -965,6 +1004,49 @@ export const useUIStore = create<UIStore>()(
}); });
}, },
toggleHiddenModel: (providerID, modelID) => {
set((state) => {
const exists = state.hiddenModels.some(
(item) => item.providerID === providerID && item.modelID === modelID
);
if (exists) {
return {
hiddenModels: state.hiddenModels.filter(
(item) => !(item.providerID === providerID && item.modelID === modelID)
),
};
}
return {
hiddenModels: [{ providerID, modelID }, ...state.hiddenModels],
};
});
},
isHiddenModel: (providerID, modelID) => {
const { hiddenModels } = get();
return hiddenModels.some(
(item) => item.providerID === providerID && item.modelID === modelID
);
},
hideAllModels: (providerID, modelIDs) => {
set((state) => {
const current = state.hiddenModels.filter((item) => item.providerID !== providerID);
const additions = modelIDs
.filter((modelID) => typeof modelID === 'string' && modelID.length > 0)
.map((modelID) => ({ providerID, modelID }));
return { hiddenModels: [...additions, ...current] };
});
},
showAllModels: (providerID) => {
set((state) => ({
hiddenModels: state.hiddenModels.filter((item) => item.providerID !== providerID),
}));
},
isFavoriteModel: (providerID, modelID) => { isFavoriteModel: (providerID, modelID) => {
const { favoriteModels } = get(); const { favoriteModels } = get();
return favoriteModels.some( return favoriteModels.some(
@@ -1227,10 +1309,14 @@ export const useUIStore = create<UIStore>()(
isSessionSwitcherOpen: state.isSessionSwitcherOpen, isSessionSwitcherOpen: state.isSessionSwitcherOpen,
activeMainTab: state.activeMainTab, activeMainTab: state.activeMainTab,
sidebarSection: state.sidebarSection, sidebarSection: state.sidebarSection,
settingsPage: state.settingsPage,
settingsHasOpenedOnce: state.settingsHasOpenedOnce,
settingsProjectsSelectedId: state.settingsProjectsSelectedId,
isSessionCreateDialogOpen: state.isSessionCreateDialogOpen, isSessionCreateDialogOpen: state.isSessionCreateDialogOpen,
// Note: isSettingsDialogOpen intentionally NOT persisted // Note: isSettingsDialogOpen intentionally NOT persisted
showReasoningTraces: state.showReasoningTraces, showReasoningTraces: state.showReasoningTraces,
showTextJustificationActivity: state.showTextJustificationActivity, showTextJustificationActivity: state.showTextJustificationActivity,
showDeletionDialog: state.showDeletionDialog,
autoDeleteEnabled: state.autoDeleteEnabled, autoDeleteEnabled: state.autoDeleteEnabled,
autoDeleteAfterDays: state.autoDeleteAfterDays, autoDeleteAfterDays: state.autoDeleteAfterDays,
autoDeleteLastRunAt: state.autoDeleteLastRunAt, autoDeleteLastRunAt: state.autoDeleteLastRunAt,
@@ -1241,6 +1327,7 @@ export const useUIStore = create<UIStore>()(
padding: state.padding, padding: state.padding,
cornerRadius: state.cornerRadius, cornerRadius: state.cornerRadius,
favoriteModels: state.favoriteModels, favoriteModels: state.favoriteModels,
hiddenModels: state.hiddenModels,
recentModels: state.recentModels, recentModels: state.recentModels,
recentAgents: state.recentAgents, recentAgents: state.recentAgents,
recentEfforts: state.recentEfforts, recentEfforts: state.recentEfforts,
+1 -1
View File
@@ -229,7 +229,7 @@
}, },
"dependencies": { "dependencies": {
"@openchamber/ui": "workspace:*", "@openchamber/ui": "workspace:*",
"@opencode-ai/sdk": "^1.2.5", "@opencode-ai/sdk": "^1.2.10",
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"jsonc-parser": "^3.3.1", "jsonc-parser": "^3.3.1",
"react": "^19.1.1", "react": "^19.1.1",
+1 -1
View File
@@ -28,7 +28,7 @@
"@fontsource/ibm-plex-sans": "^5.1.1", "@fontsource/ibm-plex-sans": "^5.1.1",
"@ibm/plex": "^6.4.1", "@ibm/plex": "^6.4.1",
"@octokit/rest": "^22.0.1", "@octokit/rest": "^22.0.1",
"@opencode-ai/sdk": "^1.2.5", "@opencode-ai/sdk": "^1.2.10",
"@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
+3
View File
@@ -1444,6 +1444,9 @@ const sanitizeSettingsUpdate = (payload) => {
if (typeof candidate.showTextJustificationActivity === 'boolean') { if (typeof candidate.showTextJustificationActivity === 'boolean') {
result.showTextJustificationActivity = candidate.showTextJustificationActivity; result.showTextJustificationActivity = candidate.showTextJustificationActivity;
} }
if (typeof candidate.showDeletionDialog === 'boolean') {
result.showDeletionDialog = candidate.showDeletionDialog;
}
if (typeof candidate.nativeNotificationsEnabled === 'boolean') { if (typeof candidate.nativeNotificationsEnabled === 'boolean') {
result.nativeNotificationsEnabled = candidate.nativeNotificationsEnabled; result.nativeNotificationsEnabled = candidate.nativeNotificationsEnabled;
} }