From 9140e6862813762c9b32e14639328301192f82a9 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 12 Jun 2026 01:05:12 -0700 Subject: [PATCH 1/2] docs: plan settings ui refactor --- .../plans/2026-06-12-settings-ui-refactor.md | 1250 +++++++++++++++++ 1 file changed, 1250 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-12-settings-ui-refactor.md diff --git a/docs/superpowers/plans/2026-06-12-settings-ui-refactor.md b/docs/superpowers/plans/2026-06-12-settings-ui-refactor.md new file mode 100644 index 00000000..a8c25818 --- /dev/null +++ b/docs/superpowers/plans/2026-06-12-settings-ui-refactor.md @@ -0,0 +1,1250 @@ +# Settings UI Refactor Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship the new Settings information architecture: Coding Agents, Panes, Naming, Network, Workspace, Appearance, and Advanced, with concise coding-agent enablement and no user-facing Extensions or global Fresh-agent Settings section. + +**Architecture:** Keep the existing settings schema where possible, split the current large settings sections into focused React components, and wire section tabs from `SettingsView`. CLI agent switches continue to update `codingCli.enabledProviders`; Fresh-agent switches use the existing `freshAgent.enabled` gate plus per-session IDs in `extensions.disabled`, and the pane picker is updated to honor those per-session IDs. + +**Tech Stack:** React 18, Redux Toolkit, TypeScript, Vitest + Testing Library, Playwright for screenshot inspection, existing Freshell settings APIs. + +--- + +## File Structure + +- Modify `src/components/SettingsView.tsx`: replace the old tab list and content switch with `Appearance`, `Coding Agents`, `Panes`, `Workspace`, `Naming`, `Network`, and `Advanced`; remove the visible Manage Extensions card. +- Create `src/components/settings/CodingAgentsSettings.tsx`: render one compact row per coding-agent surface with monochrome SVG icon, name, and switch. +- Create `src/components/settings/PanesSettings.tsx`: own pane behavior, tab completion, notifications, and editor settings, including the existing custom editor command conditional input. +- Create `src/components/settings/NamingSettings.tsx`: replace `AISettings` in the shell with the auto-naming focused label and copy. +- Create `src/components/settings/NetworkSettings.tsx`: move remote access, firewall repair, device link, and confirmation modal logic out of `SafetySettings`. +- Create `src/components/settings/RuntimeSettings.tsx`: move auto-kill and default working directory validation into Advanced as "Runtime". +- Create `src/components/settings/DevicesSettings.tsx`: move known-device rename/delete rows into Advanced. +- Modify `src/components/settings/AdvancedSettings.tsx`: keep terminal internals/debugging and render Runtime and Devices below it. +- Modify `src/components/settings/WorkspaceSettings.tsx`: leave only Sidebar and Keyboard shortcuts. +- Modify `src/components/panes/PanePicker.tsx`: hide Fresh-agent picker entries when their session type is in `extensions.disabled`. +- Modify `src/components/settings/settings-controls.tsx`: make rows and controls give dropdowns/segmented controls sufficient right-side breathing room and keep switches vertically aligned. +- Modify tests under `test/unit/client/components/SettingsView*.test.tsx` and `test/unit/client/components/panes/PanePicker.test.tsx`: update expectations to the new tabs and moved sections. +- Modify `test/e2e/network-setup.test.tsx`: update jsdom e2e navigation from removed `Safety` tab to `Network`. +- Modify `test/e2e/settings-devices-flow.test.tsx`: update jsdom e2e navigation/assertions now that `Network` and `Devices` live on separate tabs. +- Modify `test/unit/client/components/component-edge-cases.test.tsx`: update default-working-directory tests from removed `Safety` tab to `Advanced`. +- Modify `test/e2e-browser/specs/settings.spec.ts`: update Playwright settings smoke coverage from old `AI`/`Safety` tabs to new `Naming`/`Network` tabs. +- Modify `docs/index.html`: keep the static mock aligned with the shipped UI. + +--- + +### Task 1: Lock the New Settings Shell With Failing Tests + +**Files:** +- Modify: `test/unit/client/components/settings-view-test-utils.tsx` +- Modify: `test/unit/client/components/SettingsView.core.test.tsx` + +- [ ] **Step 1: Update the settings tab helper type** + +Replace the old `SettingsTab` union in `test/unit/client/components/settings-view-test-utils.tsx` with: + +```ts +export type SettingsTab = + | 'Appearance' + | 'Coding Agents' + | 'Panes' + | 'Workspace' + | 'Naming' + | 'Network' + | 'Advanced' +``` + +- [ ] **Step 2: Add failing shell tests** + +In `test/unit/client/components/SettingsView.core.test.tsx`, update `renders tab buttons for all sections` so it expects only these labels: + +```ts +it('renders tab buttons for the shipped settings sections', () => { + const store = createSettingsViewStore() + renderSettingsView(store) + + const tabs = screen.getAllByRole('tab').map((tab) => tab.textContent?.trim()) + expect(tabs).toEqual([ + 'Appearance', + 'Coding Agents', + 'Panes', + 'Workspace', + 'Naming', + 'Network', + 'Advanced', + ]) + expect(screen.queryByRole('button', { name: /manage extensions/i })).not.toBeInTheDocument() +}) +``` + +Update old tab-switching tests so they click and assert the new section homes: + +```ts +it('switches to Coding Agents tab on click', () => { + const store = createSettingsViewStore() + renderSettingsView(store) + + fireEvent.click(screen.getByRole('tab', { name: 'Coding Agents' })) + + expect(screen.getByRole('heading', { name: 'Coding Agents' })).toBeInTheDocument() +}) + +it('switches to Panes tab on click', () => { + const store = createSettingsViewStore() + renderSettingsView(store) + + fireEvent.click(screen.getByRole('tab', { name: 'Panes' })) + + expect(screen.getByRole('heading', { name: 'Pane behavior' })).toBeInTheDocument() + expect(screen.getByText('Editor pane')).toBeInTheDocument() +}) + +it('switches to Naming tab on click', () => { + const store = createSettingsViewStore() + renderSettingsView(store) + + fireEvent.click(screen.getByRole('tab', { name: 'Naming' })) + + expect(screen.getByRole('heading', { name: 'Naming' })).toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'AI' })).not.toBeInTheDocument() +}) + +it('switches to Network tab on click', () => { + const store = createSettingsViewStore() + renderSettingsView(store) + + fireEvent.click(screen.getByRole('tab', { name: 'Network' })) + + expect(screen.getByRole('heading', { name: 'Network' })).toBeInTheDocument() + expect(screen.getByText(/remote access/i)).toBeInTheDocument() +}) + +it('switches to Advanced tab on click', () => { + const store = createSettingsViewStore() + renderSettingsView(store) + + fireEvent.click(screen.getByRole('tab', { name: 'Advanced' })) + + expect(screen.getByRole('heading', { name: 'Advanced' })).toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'Safety' })).not.toBeInTheDocument() +}) +``` + +Leave the existing Workspace tab switch test focused on the Sidebar and terminal preview removal during Task 1. Do not assert `Notifications` absence until Task 4, because the Notifications section still lives in Workspace until `PanesSettings` is implemented. + +```ts +it('switches to Workspace tab on click', () => { + const store = createSettingsViewStore() + renderSettingsView(store) + + fireEvent.click(screen.getByRole('tab', { name: 'Workspace' })) + + expect(screen.getByRole('heading', { name: 'Sidebar' })).toBeInTheDocument() + expect(screen.getByText('Session list and navigation')).toBeInTheDocument() + expect(screen.queryByTestId('terminal-preview')).not.toBeInTheDocument() +}) +``` + +Leave the existing current-value test that reads auto-kill on `Safety` until Task 5 moves Runtime into Advanced. Do not change it in Task 1, because Advanced does not contain Runtime until later. + +```ts +it('displays safety settings values', () => { + const store = createSettingsViewStore({ settings: { safety: { autoKillIdleMinutes: 120 } } }) + renderSettingsView(store) + switchSettingsTab('Safety') + + expect(screen.getByText('120')).toBeInTheDocument() +}) +``` + +- [ ] **Step 3: Run the failing shell tests** + +Run: + +```bash +npm run test:vitest -- test/unit/client/components/SettingsView.core.test.tsx --run +``` + +Expected: fails because the production `SettingsView` still exposes the old tab labels and Manage Extensions card. + +--- + +### Task 2: Implement the Settings Shell and Shared Row Polish + +**Files:** +- Modify: `src/components/SettingsView.tsx` +- Modify: `src/components/settings/settings-controls.tsx` +- Create: `src/components/settings/CodingAgentsSettings.tsx` +- Create: `src/components/settings/PanesSettings.tsx` +- Create: `src/components/settings/NamingSettings.tsx` +- Create: `src/components/settings/NetworkSettings.tsx` + +- [ ] **Step 1: Add temporary section stubs before rewiring** + +Create `src/components/settings/CodingAgentsSettings.tsx`: + +```tsx +import type { SettingsSectionProps } from './settings-types' +import { SettingsSection } from './settings-controls' + +export default function CodingAgentsSettings(_props: SettingsSectionProps) { + return Coming soon +} +``` + +Create `src/components/settings/PanesSettings.tsx`: + +```tsx +import type { SettingsSectionProps } from './settings-types' +import { SettingsSection } from './settings-controls' + +export default function PanesSettings(_props: SettingsSectionProps) { + return ( + <> + Coming soon + Coming soon + + ) +} +``` + +Create `src/components/settings/NamingSettings.tsx`: + +```tsx +import type { SettingsSectionProps } from './settings-types' +import { SettingsSection } from './settings-controls' + +export default function NamingSettings(_props: SettingsSectionProps) { + return Coming soon +} +``` + +Create `src/components/settings/NetworkSettings.tsx`: + +```tsx +import type { AppView } from '@/components/Sidebar' +import type { SettingsSectionProps } from './settings-types' +import { SettingsSection } from './settings-controls' + +export interface NetworkSettingsProps extends SettingsSectionProps { + onNavigate?: (view: AppView) => void + onFirewallTerminal?: (cmd: { tabId: string; command: string }) => void + onSharePanel?: () => void +} + +export default function NetworkSettings(_props: NetworkSettingsProps) { + return Remote access +} +``` + +These stubs are intentionally short-lived. They prevent Vite import-resolution failures while later tasks fill each section with real content. + +- [ ] **Step 2: Replace the section list and imports in `SettingsView`** + +Use these imports: + +```ts +import AppearanceSettings from '@/components/settings/AppearanceSettings' +import WorkspaceSettings from '@/components/settings/WorkspaceSettings' +import AdvancedSettings from '@/components/settings/AdvancedSettings' +import CodingAgentsSettings from '@/components/settings/CodingAgentsSettings' +import PanesSettings from '@/components/settings/PanesSettings' +import NamingSettings from '@/components/settings/NamingSettings' +import NetworkSettings from '@/components/settings/NetworkSettings' +``` + +Remove these imports when deleting the Manage Extensions card: + +```ts +import { Puzzle, ChevronRight } from 'lucide-react' +import { useEnsureExtensionsRegistry } from '@/hooks/useEnsureExtensionsRegistry' +``` + +Also remove the now-dead hook call from the component body: + +```ts +useEnsureExtensionsRegistry() +``` + +Use this section list: + +```ts +const sections = [ + { id: 'appearance', label: 'Appearance' }, + { id: 'coding-agents', label: 'Coding Agents' }, + { id: 'panes', label: 'Panes' }, + { id: 'workspace', label: 'Workspace' }, + { id: 'naming', label: 'Naming' }, + { id: 'network', label: 'Network' }, + { id: 'advanced', label: 'Advanced' }, +] as const +``` + +Remove the visible Manage Extensions button/card from `SettingsView`. + +- [ ] **Step 3: Replace the tab panel switch** + +Render the new panels: + +```tsx +
+ {activeSection === 'appearance' && } + {activeSection === 'coding-agents' && } + {activeSection === 'panes' && } + {activeSection === 'workspace' && } + {activeSection === 'naming' && } + {activeSection === 'network' && ( + + )} + {activeSection === 'advanced' && } +
+``` + +- [ ] **Step 4: Polish shared controls** + +In `SettingsRow`, keep rows stable and give controls right padding: + +```tsx +
+ {description ? ( +
+ {label} + {description} +
+ ) : ( + {label} + )} +
{children}
+
+``` + +In `Toggle`, add `inline-flex shrink-0 items-center` to the button class so switch thumbs align vertically with row text: + +```ts +'relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors' +``` + +In `SegmentedControl`, add a minimum width and keep wrapping stable: + +```ts +'flex w-full min-w-0 flex-wrap rounded-md bg-muted p-0.5 md:w-auto md:min-w-[12rem]' +``` + +- [ ] **Step 5: Run shell tests again** + +Run: + +```bash +npm run test:vitest -- test/unit/client/components/SettingsView.core.test.tsx --run +``` + +Expected: shell tests pass because all imported section components now exist and render the headings the shell tests need. + +--- + +### Task 3: Add Coding Agents Settings and Pane Picker Filtering + +**Files:** +- Modify: `src/components/settings/CodingAgentsSettings.tsx` +- Modify: `src/components/panes/PanePicker.tsx` +- Modify: `test/unit/client/components/SettingsView.agent-chat.test.tsx` +- Modify: `test/unit/client/components/panes/PanePicker.test.tsx` + +- [ ] **Step 1: Replace Fresh-agent settings tests with concise Coding Agents tests** + +Replace `SettingsView.agent-chat.test.tsx` with tests that assert: + +```ts +describe('SettingsView coding agents settings', () => { + it('renders compact rows for CLI and Fresh coding agents', () => { + const store = createSettingsViewStore() + renderSettingsView(store) + switchSettingsTab('Coding Agents') + + expect(screen.getByRole('heading', { name: 'Coding Agents' })).toBeInTheDocument() + for (const name of [ + 'Claude CLI', + 'Freshclaude', + 'Codex CLI', + 'Freshcodex', + 'OpenCode', + 'Freshopencode', + 'Gemini', + 'Kimi', + ]) { + expect(screen.getByRole('switch', { name })).toBeInTheDocument() + } + expect(screen.queryByText('Show thinking')).not.toBeInTheDocument() + expect(screen.queryByText('Show tools')).not.toBeInTheDocument() + expect(screen.queryByText('Show timecodes & model')).not.toBeInTheDocument() + expect(screen.queryByText('Font size')).not.toBeInTheDocument() + }) + + it('toggles CLI agents through codingCli.enabledProviders', async () => { + const store = createSettingsViewStore({ + settings: { codingCli: { enabledProviders: ['claude', 'codex'] } }, + }) + renderSettingsView(store) + switchSettingsTab('Coding Agents') + + fireEvent.click(screen.getByRole('switch', { name: 'Codex CLI' })) + + expect(store.getState().settings.settings.codingCli.enabledProviders).toEqual(['claude']) + await act(async () => { + await Promise.resolve() + }) + expect(api.patch).toHaveBeenCalledWith('/api/settings', { + codingCli: { enabledProviders: ['claude'] }, + }) + }) + + it('toggles one Fresh agent independently through extensions.disabled', async () => { + const store = createSettingsViewStore({ + settings: { + freshAgent: { enabled: true }, + agentChat: { enabled: true }, + extensions: { disabled: ['freshcodex'] }, + }, + }) + renderSettingsView(store) + switchSettingsTab('Coding Agents') + + expect(screen.getByRole('switch', { name: 'Freshcodex' })).not.toBeChecked() + fireEvent.click(screen.getByRole('switch', { name: 'Freshcodex' })) + + expect(store.getState().settings.settings.extensions.disabled).not.toContain('freshcodex') + await act(async () => { + await Promise.resolve() + }) + expect(api.patch).toHaveBeenCalledWith('/api/settings', { + freshAgent: { enabled: true }, + agentChat: { enabled: true }, + extensions: { disabled: [] }, + }) + }) + + it('turns on only the selected Fresh agent from the default all-off state', async () => { + const store = createSettingsViewStore() + renderSettingsView(store) + switchSettingsTab('Coding Agents') + + fireEvent.click(screen.getByRole('switch', { name: 'Freshcodex' })) + + expect(store.getState().settings.settings.freshAgent.enabled).toBe(true) + expect(store.getState().settings.settings.extensions.disabled).toEqual( + expect.arrayContaining(['freshclaude', 'freshopencode']), + ) + expect(store.getState().settings.settings.extensions.disabled).not.toContain('freshcodex') + await act(async () => { + await Promise.resolve() + }) + expect(api.patch).toHaveBeenCalledWith('/api/settings', { + freshAgent: { enabled: true }, + agentChat: { enabled: true }, + extensions: { disabled: ['freshclaude', 'freshopencode'] }, + }) + }) +}) +``` + +- [ ] **Step 2: Implement `CodingAgentsSettings`** + +Create a compact component with this behavior: + +```tsx +import { useMemo } from 'react' +import { ClaudeIcon, CodexIcon, FreshclaudeIcon, GeminiIcon, KimiIcon, OpencodeIcon } from '@/components/icons/provider-icons' +import type { CodingCliProviderName } from '@/lib/coding-cli-types' +import type { SettingsSectionProps } from './settings-types' +import { SettingsSection, Toggle } from './settings-controls' + +type AgentRow = + | { kind: 'cli'; id: CodingCliProviderName; label: string; icon: React.ComponentType<{ className?: string }> } + | { kind: 'fresh'; id: 'freshclaude' | 'freshcodex' | 'freshopencode'; label: string; icon: React.ComponentType<{ className?: string }> } + +const AGENT_ROWS: AgentRow[] = [ + { kind: 'cli', id: 'claude', label: 'Claude CLI', icon: ClaudeIcon }, + { kind: 'fresh', id: 'freshclaude', label: 'Freshclaude', icon: FreshclaudeIcon }, + { kind: 'cli', id: 'codex', label: 'Codex CLI', icon: CodexIcon }, + { kind: 'fresh', id: 'freshcodex', label: 'Freshcodex', icon: CodexIcon }, + { kind: 'cli', id: 'opencode', label: 'OpenCode', icon: OpencodeIcon }, + { kind: 'fresh', id: 'freshopencode', label: 'Freshopencode', icon: OpencodeIcon }, + { kind: 'cli', id: 'gemini', label: 'Gemini', icon: GeminiIcon }, + { kind: 'cli', id: 'kimi', label: 'Kimi', icon: KimiIcon }, +] + +export default function CodingAgentsSettings({ settings, applyServerSetting }: SettingsSectionProps) { + const enabledProviders = settings.codingCli?.enabledProviders ?? [] + const disabledItems = settings.extensions?.disabled ?? [] + const freshEnabled = settings.freshAgent?.enabled ?? settings.agentChat?.enabled ?? false + const freshIds = useMemo( + () => AGENT_ROWS.filter((row): row is Extract => row.kind === 'fresh').map((row) => row.id), + [], + ) + + const setCliEnabled = (id: CodingCliProviderName, enabled: boolean) => { + const next = enabled + ? Array.from(new Set([...enabledProviders, id])) + : enabledProviders.filter((provider) => provider !== id) + applyServerSetting({ codingCli: { enabledProviders: next } }) + } + + const setFreshEnabled = (id: Extract['id'], enabled: boolean) => { + const disabledSet = new Set(disabledItems) + if (enabled && !freshEnabled) { + for (const freshId of freshIds) { + if (freshId !== id) disabledSet.add(freshId) + } + } + if (enabled) { + disabledSet.delete(id) + } else { + disabledSet.add(id) + } + const nextDisabled = Array.from(disabledSet) + const anyFreshEnabled = freshIds.some((freshId) => !disabledSet.has(freshId)) + applyServerSetting({ + freshAgent: { enabled: anyFreshEnabled }, + agentChat: { enabled: anyFreshEnabled }, + extensions: { disabled: nextDisabled }, + }) + } + + return ( + +
+ {AGENT_ROWS.map((row) => { + const Icon = row.icon + const checked = row.kind === 'cli' + ? enabledProviders.includes(row.id) + : freshEnabled && !disabledItems.includes(row.id) + return ( +
+
+ + {row.label} +
+ { + if (row.kind === 'cli') setCliEnabled(row.id, next) + else setFreshEnabled(row.id, next) + }} + /> +
+ ) + })} +
+
+ ) +} +``` + +- [ ] **Step 3: Update PanePicker Fresh filtering** + +In `PanePicker`, add a helper: + +```ts +function isFreshAgentSessionDisabled(sessionType: string, disabledItems: readonly string[]): boolean { + return disabledItems.includes(sessionType) +} +``` + +Apply it to both Fresh-agent sources: + +```ts +.filter((config) => !isFreshAgentSessionDisabled(config.name, disabledExtensions)) +``` + +and: + +```ts +.filter((entry) => !isFreshAgentSessionDisabled(entry.sessionType, disabledExtensions)) +``` + +Keep the existing runtime provider filter: + +```ts +.filter((entry) => availableClis[entry.runtimeProvider] && enabledProviders.includes(entry.runtimeProvider) && !disabledExtensions.includes(entry.runtimeProvider)) +``` + +- [ ] **Step 4: Add PanePicker regression tests** + +Add tests to `PanePicker.test.tsx` that preload `extensions.disabled` through the test store and verify: + +```ts +it('hides a Fresh-agent picker entry disabled by session type without hiding its CLI', () => { + renderPicker({ + availableClis: { claude: true, codex: true }, + enabledProviders: ['claude', 'codex'], + extensions: defaultCliExtensions, + freshClientsEnabled: true, + disabledExtensions: ['freshcodex'], + }) + + expect(screen.getByRole('button', { name: 'Freshclaude' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Codex CLI' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Freshcodex' })).not.toBeInTheDocument() +}) +``` + +If `createStore` does not yet accept `disabledExtensions`, add it to the helper and preload: + +```ts +settings: { + settings: { + ... + extensions: { disabled: overrides?.disabledExtensions ?? [] }, + }, + loaded: true, + lastSavedAt: null, +}, +``` + +- [ ] **Step 5: Run focused tests** + +Run: + +```bash +npm run test:vitest -- test/unit/client/components/SettingsView.agent-chat.test.tsx test/unit/client/components/panes/PanePicker.test.tsx --run +``` + +Expected: pass. + +--- + +### Task 4: Move Panes, Editor, Notifications, Workspace, and Naming + +**Files:** +- Modify: `src/components/settings/PanesSettings.tsx` +- Modify: `src/components/settings/NamingSettings.tsx` +- Modify: `src/components/settings/WorkspaceSettings.tsx` +- Delete: `src/components/settings/AISettings.tsx` +- Create: `test/unit/client/components/SettingsView.naming.test.tsx` +- Modify: `test/unit/client/components/SettingsView.panes.test.tsx` +- Modify: `test/unit/client/components/SettingsView.editor.test.tsx` +- Modify: `test/unit/client/components/SettingsView.behavior.test.tsx` + +- [ ] **Step 1: Create `PanesSettings`** + +Move the current `WorkspaceSettings` Panes section, Notifications section, and Editor section into `PanesSettings`. The component must accept `SettingsSectionProps`, keep the existing `Default new pane`, `Snap distance`, `Icons on tabs`, `Multi-row tabs`, `Tab completion indicator`, `Dismiss attention on`, `Sound on completion`, `External editor`, and conditional `Custom command` rows, and retain the existing `applyLocalSetting`, `applyServerSetting`, and `scheduleServerTextSettingSave` calls. + +The headings should be: + +```tsx + +... + +``` + +- [ ] **Step 2: Trim `WorkspaceSettings`** + +Remove Panes, Notifications, Fresh agent, and Editor sections from `WorkspaceSettings`. Leave only: + +```tsx + +... + + + +... + +``` + +Remove now-unused imports from `WorkspaceSettings`: + +```ts +SessionOpenMode +TabAttentionStyle +AttentionDismiss +FRESH_AGENT_FONT_SCALE_OPTIONS +FRESH_AGENT_FONT_SCALE_DEFAULT +SegmentedControl +RangeSlider +``` + +- [ ] **Step 3: Create `NamingSettings`** + +Create a replacement for `AISettings` with naming-focused labels: + +```tsx +import type { SettingsSectionProps } from './settings-types' +import { SettingsSection, SettingsRow, Toggle } from './settings-controls' + +export default function NamingSettings({ + settings, + applyServerSetting, + scheduleServerTextSettingSave, +}: SettingsSectionProps) { + return ( + + + { + const key = e.target.value || undefined + scheduleServerTextSettingSave('ai.geminiApiKey', { ai: { geminiApiKey: key } }) + }} + className="h-10 w-full rounded-md border-0 bg-muted px-3 text-sm focus:outline-none focus:ring-1 focus:ring-border md:h-8" + /> + + + { + applyServerSetting({ sidebar: { autoGenerateTitles: checked } }) + }} + aria-label="Auto-generate session titles" + /> + + + + +
+
First chat must start with match
+
+
+ + + +
+

Keyboard shortcuts

+

Navigation and terminal

+
+
New tabAlt T
+
Close tabAlt W
+
Reopen closed tabAlt H
+
Previous tabCtrl Shift [
+
Next tabCtrl Shift ]
+
Copy selectionCtrl Shift C
+
PasteCtrl V
+
SearchCtrl F
+
+
+ + + + + + + + + @@ -745,16 +1265,62 @@

Task Board

document.addEventListener('keydown', (event) => { if (event.key !== 'Escape') return + if (document.getElementById('settings-view')?.classList.contains('active')) return if (!sidebarVisible) return sidebarVisible = !sidebarVisible; updateSidebarVisibility(); }); // ========== Sidebar Nav ========== +const settingsView = document.getElementById('settings-view'); +const tabbar = document.querySelector('.tabbar'); +const settingsNavButton = document.querySelector('.sb-nav-btn[title="Settings"]'); +const primaryNavButton = document.querySelector('.sb-nav-btn[title="Coding Agents"]'); + +function restoreActiveTabPanel() { + const activeTab = document.querySelector('.tab.active[data-tab]') || document.querySelector('.tab[data-tab]'); + if (!activeTab) return; + const id = activeTab.dataset.tab; + document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active')); + document.getElementById('panel-' + id)?.classList.add('active'); +} + +function hideSettingsView() { + if (!settingsView) return; + settingsView.classList.remove('active'); + settingsView.setAttribute('aria-hidden', 'true'); + if (tabbar) tabbar.style.display = ''; + restoreActiveTabPanel(); + if (window.innerWidth <= 768) { + sidebarVisible = false; + updateSidebarVisibility(); + } +} + +function showSettingsView() { + if (!settingsView) return; + cancelAnimations(); + hideSnark(); + document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active')); + if (tabbar) tabbar.style.display = 'none'; + settingsView.classList.add('active'); + settingsView.setAttribute('aria-hidden', 'false'); + if (window.innerWidth <= 768) { + sidebarVisible = false; + updateSidebarVisibility(); + } + lucide.createIcons(); +} + document.querySelectorAll('.sb-nav-btn').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.sb-nav-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); + if (btn === settingsNavButton) { + showSettingsView(); + return; + } + hideSettingsView(); }); }); @@ -765,6 +1331,9 @@

Task Board

allTabs.forEach(tab => { tab.addEventListener('click', () => { + hideSettingsView(); + document.querySelectorAll('.sb-nav-btn').forEach(b => b.classList.remove('active')); + primaryNavButton?.classList.add('active'); const id = tab.dataset.tab; allTabs.forEach(t => t.classList.remove('active')); tab.classList.add('active'); @@ -864,8 +1433,8 @@

Task Board

{ html: ' Session history & AI summaries', delay: 50 }, { html: ' Remote tab copies keep layout and reconnect safely on this machine', delay: 50 }, { html: ' Remote access with setup wizard & QR code', delay: 50 }, - { html: ' Extension system for custom pane types', delay: 50 }, - { html: ' AI-powered session titles', delay: 50 }, + { html: ' Pane type system for custom panes', delay: 50 }, + { html: ' Automatic session titles', delay: 50 }, { html: ' Progressive sidebar search', delay: 50 }, { html: ' Keyboard shortcuts dialog (Alt+T, Alt+W, Alt+H)', delay: 50 }, { html: ' Personalized sidebar per device', delay: 50 }, @@ -1076,6 +1645,7 @@

Task Board

// Trigger: keypress anywhere (someone trying to type) document.addEventListener('keydown', (e) => { + if (settingsView?.classList.contains('active')) return; if (e.ctrlKey || e.metaKey || e.altKey) return; if (e.key === 'Escape') { hideSnark(); return; } if (e.key === 'Tab') return; // let tab switching work @@ -1110,6 +1680,87 @@

Task Board

if (e.target.closest(sel)) { showSnark(); return; } } }); + +// ========== Settings Preview Interactions ========== +function activateSettingsTab(id) { + const tab = document.querySelector(`.settings-tab[data-settings-tab="${id}"]`); + if (!tab) return; + document.querySelectorAll('.settings-tab').forEach(item => { + const selected = item === tab; + item.classList.toggle('active', selected); + item.setAttribute('aria-selected', selected ? 'true' : 'false'); + }); + document.querySelectorAll('.settings-panel').forEach(panel => { + const selected = panel.id === 'settings-panel-' + id; + panel.classList.toggle('active', selected); + panel.setAttribute('aria-hidden', selected ? 'false' : 'true'); + }); +} + +document.querySelectorAll('.settings-tab').forEach(tab => { + tab.addEventListener('click', () => { + activateSettingsTab(tab.dataset.settingsTab); + }); +}); + +document.querySelectorAll('[data-segmented]').forEach(group => { + group.querySelectorAll('.settings-segment').forEach(button => { + button.addEventListener('click', () => { + group.querySelectorAll('.settings-segment').forEach(item => item.classList.remove('active')); + button.classList.add('active'); + }); + }); +}); + +document.querySelectorAll('.settings-switch').forEach(button => { + button.addEventListener('click', () => { + const next = !button.classList.contains('on'); + button.classList.toggle('on', next); + button.setAttribute('aria-checked', next ? 'true' : 'false'); + }); +}); + +function formatSettingsRange(input) { + const value = Number(input.value); + switch (input.dataset.format) { + case 'percent': + return Math.round(value * 100) + '%'; + case 'px-percent': + return `${value}px (${Math.round(value / 16 * 100)}%)`; + case 'fixed': + return value.toFixed(2); + case 'snap': + return value === 0 ? 'Off' : value + '%'; + case 'locale': + return value.toLocaleString(); + default: + return String(value); + } +} + +document.querySelectorAll('.settings-range input').forEach(input => { + const label = input.closest('.settings-range')?.querySelector('span'); + const preview = document.querySelector('.settings-terminal-preview'); + const update = () => { + if (label) label.textContent = formatSettingsRange(input); + if (preview && input.dataset.format === 'px-percent') { + preview.style.fontSize = input.value + 'px'; + } + if (preview && input.dataset.format === 'fixed') { + preview.style.lineHeight = input.value; + } + }; + input.addEventListener('input', update); + update(); +}); + +document.addEventListener('keydown', (event) => { + if (event.key !== 'Escape') return; + if (!settingsView?.classList.contains('active')) return; + hideSettingsView(); + document.querySelectorAll('.sb-nav-btn').forEach(b => b.classList.remove('active')); + primaryNavButton?.classList.add('active'); +}); diff --git a/server/fresh-agent/adapters/codex/normalize.ts b/server/fresh-agent/adapters/codex/normalize.ts index 9fe5179a..72983e55 100644 --- a/server/fresh-agent/adapters/codex/normalize.ts +++ b/server/fresh-agent/adapters/codex/normalize.ts @@ -500,6 +500,12 @@ export function classifyCodexItemRole(item: Record): CodexDispl } } +function readCodexRawItems(rawTurn: Record): Record[] { + return Array.isArray(rawTurn.items) + ? rawTurn.items.filter((item): item is Record => !!item && typeof item === 'object' && !Array.isArray(item)) + : [] +} + function readCodexTurnError(rawTurn: Record): string | undefined { const error = rawTurn.error if (!error) return undefined diff --git a/src/components/ExtensionsView.tsx b/src/components/ExtensionsView.tsx index 6fa71ae6..414f6683 100644 --- a/src/components/ExtensionsView.tsx +++ b/src/components/ExtensionsView.tsx @@ -20,6 +20,11 @@ interface ExtensionsViewProps { onNavigate: (view: AppView) => void } +interface ExtensionsManagerProps { + className?: string + includeCli?: boolean +} + function categoryIcon(category: 'cli' | 'server' | 'client') { switch (category) { case 'server': return @@ -260,7 +265,7 @@ function ExtensionCard({ item, expanded, onToggleExpand, onToggleEnabled, onConf ) } -export default function ExtensionsView({ onNavigate }: ExtensionsViewProps) { +export function ExtensionsManager({ className, includeCli = true }: ExtensionsManagerProps = {}) { useEnsureExtensionsRegistry() const dispatch = useAppDispatch() @@ -385,16 +390,65 @@ export default function ExtensionsView({ onNavigate }: ExtensionsViewProps) { } }, [dispatch, scheduleCwdValidation, scheduleTextSave]) + const visibleItems = useMemo( + () => includeCli ? items : items.filter((item) => item.kind !== 'cli'), + [includeCli, items], + ) + const groups = useMemo(() => { - const cli = items.filter((i) => i.kind === 'cli') - const server = items.filter((i) => i.kind === 'server') - const client = items.filter((i) => i.kind === 'client') + const cli = visibleItems.filter((i) => i.kind === 'cli') + const server = visibleItems.filter((i) => i.kind === 'server') + const client = visibleItems.filter((i) => i.kind === 'client') return [ { kind: 'cli' as const, items: cli }, { kind: 'server' as const, items: server }, { kind: 'client' as const, items: client }, ].filter((g) => g.items.length > 0) - }, [items]) + }, [visibleItems]) + + return ( +
+ {visibleItems.length === 0 ? ( +
+ +

No extensions installed

+ {includeCli && ( +

+ Drop a directory with a freshell.json into ~/.freshell/extensions/ and restart. +

+ )} +
+ ) : ( + groups.map((group) => ( +
+

+ {groupLabel(group.kind)} +

+
+ {group.items.map((item) => ( + toggleExpand(item.id)} + onToggleEnabled={handleToggleEnabled} + onConfigChange={handleConfigChange} + cwdDrafts={cwdDrafts} + cwdErrors={cwdErrors} + /> + ))} +
+
+ )) + )} +
+ ) +} + +export default function ExtensionsView({ onNavigate }: ExtensionsViewProps) { + useEnsureExtensionsRegistry() + + const items = useAppSelector(selectManagedItems) return (
@@ -419,38 +473,8 @@ export default function ExtensionsView({ onNavigate }: ExtensionsViewProps) { {/* Content */}
-
- {items.length === 0 ? ( -
- -

No extensions installed

-

- Drop a directory with a freshell.json into ~/.freshell/extensions/ and restart. -

-
- ) : ( - groups.map((group) => ( -
-

- {groupLabel(group.kind)} -

-
- {group.items.map((item) => ( - toggleExpand(item.id)} - onToggleEnabled={handleToggleEnabled} - onConfigChange={handleConfigChange} - cwdDrafts={cwdDrafts} - cwdErrors={cwdErrors} - /> - ))} -
-
- )) - )} +
+
diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx index 90f324b5..abd65873 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -17,30 +17,30 @@ import type { ServerSettingsPatch, } from '@/store/types' import type { AppView } from '@/components/Sidebar' -import { useEnsureExtensionsRegistry } from '@/hooks/useEnsureExtensionsRegistry' -import { Puzzle, ChevronRight } from 'lucide-react' import { cn } from '@/lib/utils' import AppearanceSettings from '@/components/settings/AppearanceSettings' import WorkspaceSettings from '@/components/settings/WorkspaceSettings' -import SafetySettings from '@/components/settings/SafetySettings' import AdvancedSettings from '@/components/settings/AdvancedSettings' -import AISettings from '@/components/settings/AISettings' +import CodingAgentsSettings from '@/components/settings/CodingAgentsSettings' +import PanesSettings from '@/components/settings/PanesSettings' +import NamingSettings from '@/components/settings/NamingSettings' +import NetworkSettings from '@/components/settings/NetworkSettings' const SERVER_TEXT_SETTINGS_DEBOUNCE_MS = 500 const sections = [ { id: 'appearance', label: 'Appearance' }, + { id: 'coding-agents', label: 'Coding Agents' }, + { id: 'panes', label: 'Panes' }, { id: 'workspace', label: 'Workspace' }, - { id: 'ai', label: 'AI' }, - { id: 'safety', label: 'Safety' }, + { id: 'naming', label: 'Naming' }, + { id: 'network', label: 'Network' }, { id: 'advanced', label: 'Advanced' }, ] as const type SectionId = typeof sections[number]['id'] export default function SettingsView({ onNavigate, onFirewallTerminal, onSharePanel }: { onNavigate?: (view: AppView) => void; onFirewallTerminal?: (cmd: { tabId: string; command: string }) => void; onSharePanel?: () => void } = {}) { - useEnsureExtensionsRegistry() - const dispatch = useAppDispatch() const rawSettings = useAppSelector((s) => s.settings.settings) const settings = useMemo( @@ -111,26 +111,9 @@ export default function SettingsView({ onNavigate, onFirewallTerminal, onSharePa {/* Content */}
-
- - {/* Manage Extensions — prominent, always visible */} - - +
{/* Tabs */} -
+
{sections.map((section) => ( - - + + + + + Slash commands + + + + + + Send message +
{visibleText}

+ const plain =

{visibleText}

if (!markdown) return plain return (
}) : isUser ? ( -

{stripSystemReminders(turn.summary)}

+

{stripSystemReminders(turn.summary)}

) : ( // Summary-only agent turns went through the plain-text path and // showed literal backticks (live-test finding) — render markdown. diff --git a/src/components/panes/PanePicker.tsx b/src/components/panes/PanePicker.tsx index 5b99ac50..ff8ab299 100644 --- a/src/components/panes/PanePicker.tsx +++ b/src/components/panes/PanePicker.tsx @@ -85,6 +85,10 @@ function isWindowsLike(platform: string | null): boolean { return platform === 'win32' || platform === 'wsl' } +function isFreshAgentSessionDisabled(sessionType: string, disabledItems: readonly string[]): boolean { + return disabledItems.includes(sessionType) +} + interface PanePickerProps { onSelect: (type: PanePickerType) => void onCancel: () => void @@ -119,6 +123,7 @@ export default function PanePicker({ onSelect, onCancel, isOnlyPane, tabId, pane // Fresh-agent provider options: only show if underlying CLI is available, enabled, and not hidden by feature flag const visibleFreshAgentConfigs = freshClientsEnabled ? getVisibleFreshAgentConfigs(featureFlags) : [] const freshAgentProviderOptions: PickerOption[] = visibleFreshAgentConfigs + .filter((config) => !isFreshAgentSessionDisabled(config.name, disabledExtensions)) .filter((config) => availableClis[config.codingCliProvider] && enabledProviders.includes(config.codingCliProvider) && !disabledExtensions.includes(config.codingCliProvider)) .map((config) => ({ type: config.name as PanePickerType, @@ -131,6 +136,7 @@ export default function PanePicker({ onSelect, onCancel, isOnlyPane, tabId, pane ? FRESH_AGENT_REGISTRY .filter((entry) => !visibleFreshAgentConfigs.some((config) => config.name === entry.sessionType)) .filter((entry) => !entry.disabled) + .filter((entry) => !isFreshAgentSessionDisabled(entry.sessionType, disabledExtensions)) .filter((entry) => !entry.hidden || featureFlags[entry.featureFlag ?? entry.sessionType] === true) .filter((entry) => availableClis[entry.runtimeProvider] && enabledProviders.includes(entry.runtimeProvider) && !disabledExtensions.includes(entry.runtimeProvider)) .map((entry) => ({ diff --git a/src/components/settings/AISettings.tsx b/src/components/settings/AISettings.tsx deleted file mode 100644 index b7228790..00000000 --- a/src/components/settings/AISettings.tsx +++ /dev/null @@ -1,61 +0,0 @@ -// AI feature settings — Gemini API key, session title generation, and prompt customization. - -import type { SettingsSectionProps } from './settings-types' -import { SettingsSection, SettingsRow, Toggle } from './settings-controls' - -export default function AISettings({ - settings, - applyServerSetting, - scheduleServerTextSettingSave, -}: SettingsSectionProps) { - return ( - <> - - - { - const key = e.target.value || undefined - scheduleServerTextSettingSave('ai.geminiApiKey', { ai: { geminiApiKey: key } }) - }} - className="h-10 w-full px-3 text-sm bg-muted border-0 rounded-md focus:outline-none focus:ring-1 focus:ring-border md:h-8" - /> - - - - { - applyServerSetting({ sidebar: { autoGenerateTitles: checked } }) - }} - aria-label="Auto-generate session titles" - /> - - - -