From 8a101e104de8a9734eaa1bec72f7f2e584c43537 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Thu, 29 Oct 2026 13:10:20 +0300 Subject: [PATCH 1/3] feat(studio): routed Settings screen - sectioned cards, theme and density, key status pills #/settings now renders a ScreenHeader (title, description, no run pill) over one left-aligned column of cards at the page gutter: Appearance (theme Light/Dark/System and table density Compact/Comfortable), Provider keys (Set / From environment / Not set pills), Settings profile, Deploy target, Collector (status, proxy address, start hint) and Keyboard shortcuts (from SHORTCUTS). The drawer's Settings tab keeps its three agent cards. theme.ts gains getThemeChoice/setThemeChoice (System clears forge.theme and data-theme; a forge:theme event keeps the control in sync with the palette toggle) and a forge.density key applied as , which DataTable rows honour over each table's own density. DaemonStatus exposes useCollectorStatus for the Collector card. Co-Authored-By: Claude Opus 5.5 --- apps/agent-forge/e2e/docShots.spec.ts | 7 + .../src/components/SettingsAppSections.tsx | 136 +++++++++++++++++ .../src/components/SettingsCard.tsx | 58 +++++++ .../src/components/SettingsPanel.tsx | 122 ++++++++------- .../__tests__/SettingsPanel.test.tsx | 128 ++++++++++++++++ apps/agent-forge/src/components/drawer.css | 143 +++++++++++++++++- apps/agent-forge/src/main.tsx | 3 +- apps/agent-forge/src/screens/routes.tsx | 7 +- apps/agent-forge/src/shell/DaemonStatus.tsx | 26 +++- .../src/shell/__tests__/theme.test.ts | 93 ++++++++++++ apps/agent-forge/src/shell/theme.ts | 80 ++++++++-- apps/agent-forge/src/ui/dataTable.css | 7 + docs/agent-forge.md | 18 ++- 13 files changed, 738 insertions(+), 90 deletions(-) create mode 100644 apps/agent-forge/src/components/SettingsAppSections.tsx create mode 100644 apps/agent-forge/src/components/SettingsCard.tsx create mode 100644 apps/agent-forge/src/components/__tests__/SettingsPanel.test.tsx create mode 100644 apps/agent-forge/src/shell/__tests__/theme.test.ts diff --git a/apps/agent-forge/e2e/docShots.spec.ts b/apps/agent-forge/e2e/docShots.spec.ts index dfd5bfa2..8b0eeb5f 100644 --- a/apps/agent-forge/e2e/docShots.spec.ts +++ b/apps/agent-forge/e2e/docShots.spec.ts @@ -883,6 +883,13 @@ test("agents x: the routed settings screen shares the drawer's settings cards", await open(page, '/settings'); await expect(page.getByLabel('Deploy adapter')).toBeVisible(); await shoot(page, 'x-agents-settings-screen'); + // Every card at once (the column scrolls inside .screen-main), then the narrow stack. + await page.setViewportSize({ width: 1280, height: 1800 }); + await shoot(page, 'x-settings-screen-full'); + await page.setViewportSize({ width: 375, height: 800 }); + await shoot(page, 'x-settings-screen-375'); + await page.setViewportSize({ width: 375, height: 2600 }); + await shoot(page, 'x-settings-screen-375-full'); }); // --- Evaluations states (x- shots: review-only, never used in the docs) ----- diff --git a/apps/agent-forge/src/components/SettingsAppSections.tsx b/apps/agent-forge/src/components/SettingsAppSections.tsx new file mode 100644 index 00000000..136f7471 --- /dev/null +++ b/apps/agent-forge/src/components/SettingsAppSections.tsx @@ -0,0 +1,136 @@ +import { useEffect, useState } from 'react'; +import { Activity, Keyboard, Monitor, Moon, Palette, Sun, type LucideIcon } from 'lucide-react'; +import { useCollectorStatus, type CollectorState } from '../shell/DaemonStatus'; +import { SHORTCUTS } from '../shell/shortcuts'; +import { + THEME_EVENT, + getDensity, + getThemeChoice, + setDensity, + setThemeChoice, + type Density, + type ThemeChoice, +} from '../shell/theme'; +import { SettingRow, SettingsCard, TonePill } from './SettingsCard'; +import '../ui/filterBar.css'; + +/* + * The app-level Settings sections: only the routed `#/settings` screen shows + * them (``); the builder drawer's Settings + * tab keeps to the agent-relevant cards. + */ + +const THEMES: { id: ThemeChoice; label: string; Icon: LucideIcon }[] = [ + { id: 'light', label: 'Light', Icon: Sun }, + { id: 'dark', label: 'Dark', Icon: Moon }, + { id: 'system', label: 'System', Icon: Monitor }, +]; + +const DENSITIES: { id: Density; label: string }[] = [ + { id: 'compact', label: 'Compact' }, + { id: 'comfortable', label: 'Comfortable' }, +]; + +/** The theme choice, kept in sync with a change made elsewhere (the palette's "Toggle theme"). */ +function useThemeChoice(): [ThemeChoice, (choice: ThemeChoice) => void] { + const [choice, setChoice] = useState(getThemeChoice); + useEffect(() => { + const sync = () => setChoice(getThemeChoice()); + window.addEventListener(THEME_EVENT, sync); + return () => window.removeEventListener(THEME_EVENT, sync); + }, []); + return [choice, setThemeChoice]; +} + +function ThemeControl() { + const [choice, choose] = useThemeChoice(); + return ( +
+ {THEMES.map(({ id, label, Icon }) => ( + + ))} +
+ ); +} + +function DensityControl({ value, onChange }: { value: Density | null; onChange: (density: Density) => void }) { + return ( +
+ {DENSITIES.map(({ id, label }) => ( + + ))} +
+ ); +} + +/** Theme (Light / Dark / System) and table density, both remembered in this browser. */ +export function AppearanceSection() { + const [density, setDensityState] = useState(getDensity); + const pick = (next: Density) => { + setDensity(next); + setDensityState(next); + }; + return ( + + + + + + + + + ); +} + +const COLLECTOR_PILLS: Record = { + up: { tone: 'ok', label: 'Running' }, + down: { tone: 'error', label: 'Unreachable' }, + checking: { tone: 'neutral', label: 'Checking…' }, +}; + +/** The observe daemon the Studio reads traces from: its status, its address, and how to start it. */ +export function CollectorSection() { + const { state, target } = useCollectorStatus(); + const pill = COLLECTOR_PILLS[state]; + return ( + + Start it with lousho observe start. : undefined}> + + Collector status: + + + + + + {target ?? '—'} + + + + ); +} + +/** Every Studio shortcut, from the same list as the `?` sheet. */ +export function ShortcutsSection() { + return ( + +
+ {SHORTCUTS.map((s) => ( +
+
{s.description}
+
+ {s.keys} +
+
+ ))} +
+
+ ); +} diff --git a/apps/agent-forge/src/components/SettingsCard.tsx b/apps/agent-forge/src/components/SettingsCard.tsx new file mode 100644 index 00000000..adc0e01f --- /dev/null +++ b/apps/agent-forge/src/components/SettingsCard.tsx @@ -0,0 +1,58 @@ +import type { ReactNode } from 'react'; +import type { LucideIcon } from 'lucide-react'; +import '../ui/primitives.css'; + +/** One settings section drawn as a card: an icon, title and one-line description over its fields. */ +export function SettingsCard({ + Icon, + title, + description, + children, +}: { + Icon: LucideIcon; + title: string; + description: string; + children: ReactNode; +}) { + return ( +
+
+ + + {title} + {description} + +
+ {children} +
+ ); +} + +/** + * A label-left / control-right row inside a settings card: the setting's name + * and a muted hint on the left, its control on the right. Wraps to a stack + * when the card is narrow. + */ +export function SettingRow({ label, hint, children }: { label: string; hint?: ReactNode; children: ReactNode }) { + return ( +
+
+ {label} + {hint != null && {hint}} +
+
{children}
+
+ ); +} + +/** A sentence-case status pill in one of the kit's tones (the `.status-pill` look from ui/primitives.css). */ +export function TonePill({ tone, label, title }: { tone: 'ok' | 'error' | 'running' | 'neutral'; label: string; title?: string }) { + return ( + + + ); +} diff --git a/apps/agent-forge/src/components/SettingsPanel.tsx b/apps/agent-forge/src/components/SettingsPanel.tsx index d82ba98d..3ec4113b 100644 --- a/apps/agent-forge/src/components/SettingsPanel.tsx +++ b/apps/agent-forge/src/components/SettingsPanel.tsx @@ -1,43 +1,17 @@ -import { useEffect, useState, type ReactNode } from 'react'; -import { KeyRound, Rocket, SlidersHorizontal, type LucideIcon } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { KeyRound, Rocket, SlidersHorizontal } from 'lucide-react'; import { useAppState } from '../state/AppState'; import { runtimeClient } from '../runtime/runtimeClient'; import type { DeployResult, ProviderKeyStatus, SettingsFile, SettingsProfile } from '../../shared/wireTypes'; import { errorMessage } from './errorMessage'; +import { AppearanceSection, CollectorSection, ShortcutsSection } from './SettingsAppSections'; +import { SettingsCard, TonePill } from './SettingsCard'; type KeyedProviderId = 'openai' | 'anthropic'; -/** One settings section drawn as a card: an icon, title and one-line description over its fields. */ -function SettingsCard({ - Icon, - title, - description, - children, -}: { - Icon: LucideIcon; - title: string; - description: string; - children: ReactNode; -}) { - return ( -
-
- - - {title} - {description} - -
- {children} -
- ); -} - -const KEYED_PROVIDERS: { id: KeyedProviderId; label: string }[] = [ - { id: 'openai', label: 'OpenAI API key' }, - { id: 'anthropic', label: 'Anthropic API key' }, +const KEYED_PROVIDERS: { id: KeyedProviderId; label: string; envVar: string }[] = [ + { id: 'openai', label: 'OpenAI API key', envVar: 'OPENAI_API_KEY' }, + { id: 'anthropic', label: 'Anthropic API key', envVar: 'ANTHROPIC_API_KEY' }, ]; const PROVIDER_TYPES = ['mock', 'openai', 'anthropic', 'ollama', 'openrouter']; @@ -157,6 +131,7 @@ function useDeploy(deployAdapter: string) { interface ProviderKeyFieldProps { label: string; + envVar: string; status: ProviderKeyStatus | undefined; draft: string | undefined; onDraftChange: (value: string) => void; @@ -165,33 +140,47 @@ interface ProviderKeyFieldProps { } function keyPlaceholder(status: ProviderKeyStatus | undefined): string { - return status?.hasKey ? status.masked ?? 'set' : 'not set'; + return status?.hasKey ? 'Replace the stored key' : 'Paste a key'; } -function keyStatusHint(status: ProviderKeyStatus | undefined): string { - return status?.hasKey ? `stored: ${status.masked}` : 'not set (runs use the mock provider)'; +/** "Set" (a stored key, with its masked form), "From environment" (only the env var), or "Not set". */ +function KeyStatus({ status, envVar }: { status: ProviderKeyStatus | undefined; envVar: string }) { + if (status?.hasKey) + return ( + + {status.masked && {status.masked}} + + + ); + if (status?.envKey) + return ; + return ; } -function ProviderKeyField({ label, status, draft, onDraftChange, onSave, onRemove }: ProviderKeyFieldProps) { +function ProviderKeyField({ label, envVar, status, draft, onDraftChange, onSave, onRemove }: ProviderKeyFieldProps) { return ( -
- - onDraftChange(e.target.value)} - /> -
- - - {keyStatusHint(status)} +
+
+ + +
+
+ onDraftChange(e.target.value)} + /> + + + +
); @@ -209,10 +198,11 @@ function ProviderKeysSection() { const { providers, keyDrafts, providerError, setDraft, saveKey, removeKey } = useProviderKeys(); return ( - {KEYED_PROVIDERS.map(({ id, label }) => ( + {KEYED_PROVIDERS.map(({ id, label, envVar }) => ( p.provider === id)} draft={keyDrafts[id]} onDraftChange={(value) => setDraft(id, value)} @@ -390,28 +380,36 @@ function DeploySection({ deployAdapter, onAdapterChange }: DeploySectionProps) { } /** - * Real Settings tab content, replacing the static placeholder - * ("Provider keys and deploy target settings are wired up later."). + * The Settings content: the builder drawer's Settings tab, and (as + * `variant="screen"`) the routed `#/settings` screen. * - * Three sections: + * Every variant has the agent-relevant sections: * - Provider keys: add/replace/remove a stored key per provider, with * the input always rendered blank (never pre-filled with a real key - * the server only ever returns a masked status, see runtimeClient.ts's - * `ProviderKeyStatus`) and the current masked value shown as a hint. + * `ProviderKeyStatus`) and a status pill: Set (with the masked value), + * From environment, or Not set. * - Settings profile: pick the active local/staging/prod profile, * edit its provider type / deploy adapter / OTel toggle / hook timeout. * - Deploy (R2): pick an adapter and run `lousho build` against this * agent's saved spec, with a simple stdout/stderr log panel. + * + * The screen adds the app-level ones: Appearance (theme, table density), + * Collector and Keyboard shortcuts, in one left-aligned column. */ -export function SettingsPanel() { +export function SettingsPanel({ variant = 'drawer' }: { variant?: 'drawer' | 'screen' }) { const [deployAdapter, setDeployAdapter] = useState('node-server'); const profiles = useSettingsProfiles(setDeployAdapter, deployAdapter); + const screen = variant === 'screen'; return ( -
+
+ {screen && } + {screen && } + {screen && }
); } diff --git a/apps/agent-forge/src/components/__tests__/SettingsPanel.test.tsx b/apps/agent-forge/src/components/__tests__/SettingsPanel.test.tsx new file mode 100644 index 00000000..67df8dc5 --- /dev/null +++ b/apps/agent-forge/src/components/__tests__/SettingsPanel.test.tsx @@ -0,0 +1,128 @@ +import { act } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { render } from '../../ui/__tests__/render'; +import { installMemoryStorage } from '../../ui/__tests__/memoryStorage'; +import { SettingsPanel } from '../SettingsPanel'; +import { toggleTheme } from '../../shell/theme'; +import type { ProviderKeyStatus } from '../../../shared/wireTypes'; + +const keys = vi.hoisted(() => ({ list: [] as ProviderKeyStatus[] })); + +vi.mock('../../state/AppState', () => ({ + useAppState: () => ({ agentId: 'a1', refreshActiveProfile: async () => undefined }), +})); +vi.mock('../../runtime/runtimeClient', () => ({ + runtimeClient: { + listProviderKeys: async () => keys.list, + listSettingsProfiles: async () => ({ activeProfileId: 'local', profiles: [] }), + listDeployAdapters: async () => ['node-server'], + }, +})); +vi.mock('../../shell/DaemonStatus', () => ({ + useCollectorStatus: () => ({ state: 'down', target: undefined }), +})); + +async function mount(variant?: 'drawer' | 'screen') { + const host = render(); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + return host; +} + +const sectionNames = (host: HTMLElement) => + [...host.querySelectorAll('section.settings-card')].map((s) => s.getAttribute('aria-label')); + +const keyPill = (host: HTMLElement, label: string) => + host.querySelector(`input[aria-label="${label}"]`)?.closest('.settings-key')?.querySelector('.status-pill')?.textContent; + +const button = (scope: Element, text: string) => + [...scope.querySelectorAll('button')].find((b) => b.textContent === text) as HTMLButtonElement; + +beforeEach(() => { + keys.list = []; + installMemoryStorage(); +}); +afterEach(() => { + document.documentElement.removeAttribute('data-theme'); + document.documentElement.removeAttribute('data-density'); + window.localStorage.clear(); +}); + +describe('SettingsPanel', () => { + it('the drawer keeps to the agent-relevant cards', async () => { + const host = await mount(); + expect(sectionNames(host)).toEqual(['Provider keys', 'Settings profile', 'Deploy target']); + }); + + it('the routed screen adds Appearance, Collector and Keyboard shortcuts', async () => { + const host = await mount('screen'); + expect(sectionNames(host)).toEqual([ + 'Appearance', + 'Provider keys', + 'Settings profile', + 'Deploy target', + 'Collector', + 'Keyboard shortcuts', + ]); + expect(host.querySelector('.settings-panel-screen')).not.toBeNull(); + }); + + it('shows Set (with the masked value), From environment or Not set per key, never pre-filling the input', async () => { + keys.list = [ + { provider: 'openai', hasKey: true, masked: '••••3f2a', envKey: true }, + { provider: 'anthropic', hasKey: false, masked: null, envKey: true }, + ]; + let host = await mount(); + expect(keyPill(host, 'OpenAI API key')).toBe('Set'); + expect(host.querySelector('.settings-key-masked')?.textContent).toBe('••••3f2a'); + expect(keyPill(host, 'Anthropic API key')).toBe('From environment'); + expect((host.querySelector('input[aria-label="OpenAI API key"]') as HTMLInputElement).value).toBe(''); + + keys.list = [{ provider: 'openai', hasKey: false, masked: null, envKey: false }]; + host = await mount(); + expect(keyPill(host, 'OpenAI API key')).toBe('Not set'); + expect(keyPill(host, 'Anthropic API key')).toBe('Not set'); + }); + + it('the theme control sets the choice and follows the palette toggle', async () => { + const host = await mount('screen'); + const theme = host.querySelector('[role="group"][aria-label="Theme"]')!; + const pressed = () => theme.querySelector('[aria-pressed="true"]')?.textContent; + expect(pressed()).toBe('System'); + act(() => button(theme, 'Dark').click()); + expect(document.documentElement.getAttribute('data-theme')).toBe('dark'); + expect(pressed()).toBe('Dark'); + act(() => toggleTheme()); + expect(pressed()).toBe('Light'); + }); + + it('density starts unpicked with a hint, then remembers the pick', async () => { + const host = await mount('screen'); + const group = host.querySelector('[role="group"][aria-label="Table density"]')!; + expect(group.querySelector('[aria-pressed="true"]')).toBeNull(); + expect(host.textContent).toContain("Tables use each screen's default until you pick one."); + const comfy = button(group, 'Comfortable'); + act(() => comfy.click()); + expect(document.documentElement.getAttribute('data-density')).toBe('comfortable'); + expect(window.localStorage.getItem('forge.density')).toBe('comfortable'); + expect(comfy.getAttribute('aria-pressed')).toBe('true'); + }); + + it('the collector card says how to start a daemon that is down, and lists every shortcut', async () => { + const host = await mount('screen'); + const collector = host.querySelector('section[aria-label="Collector"]')!; + expect(collector.querySelector('[role="status"]')?.textContent).toContain('Unreachable'); + expect(collector.textContent).toContain('lousho observe start'); + const shortcuts = host.querySelector('section[aria-label="Keyboard shortcuts"]')!; + expect([...shortcuts.querySelectorAll('kbd')].map((k) => k.textContent)).toEqual([ + 'Ctrl/Cmd+K', + '?', + 'j', + 'k', + 'Enter', + 'Esc', + ]); + }); +}); diff --git a/apps/agent-forge/src/components/drawer.css b/apps/agent-forge/src/components/drawer.css index 1b83fd64..65ed91b9 100644 --- a/apps/agent-forge/src/components/drawer.css +++ b/apps/agent-forge/src/components/drawer.css @@ -515,13 +515,41 @@ font-size: 12px; color: var(--text-muted); } -.settings-key-actions { +.settings-key-head { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2); + margin-bottom: 6px; +} +.settings-key-head label { + margin-bottom: 0; +} +.settings-key-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-2); +} +.settings-key-row .input { + flex: 1 1 200px; + width: auto; + min-width: 0; +} +.settings-key-actions { + display: inline-flex; + align-items: center; + gap: var(--space-1); } .settings-key-status { + display: inline-flex; + align-items: center; + gap: var(--space-2); + min-width: 0; +} +.settings-key-masked { + font-family: var(--font-mono); + font-size: 11px; color: var(--text-muted); } .settings-actions { @@ -529,6 +557,119 @@ gap: var(--space-2); margin-top: var(--space-2); } +/* The routed #/settings screen: one left-aligned column of cards at the page gutter. */ +.settings-panel-screen { + display: flex; + flex-direction: column; + align-items: stretch; + height: auto; + overflow: visible; + padding: var(--space-5) var(--page-gutter) var(--space-8); +} +.settings-panel-screen > .settings-card { + width: 100%; + max-width: 760px; + box-sizing: border-box; +} +/* Head over a full-width hairline, then the fields. */ +.settings-panel-screen .settings-card-head { + align-items: center; + margin: 0 calc(-1 * var(--space-5)) var(--space-4); + padding: 0 var(--space-5) var(--space-4); + border-bottom: 1px solid var(--border); +} +.settings-panel-screen .settings-card-icon { + width: 32px; + height: 32px; + border: 1px solid var(--border); +} +.settings-panel-screen .field > .input, +.settings-panel-screen .field > .select, +.settings-panel-screen .toggle-row, +.settings-panel-screen .settings-key-row { + max-width: 480px; +} +.setting-row { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: var(--space-2) var(--space-4); + padding: var(--space-3) 0; + border-top: 1px solid var(--border); +} +.settings-card-head + .setting-row { + border-top: 0; + padding-top: 0; +} +.setting-row:last-child { + padding-bottom: 0; +} +.setting-row-text { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1 1 220px; + min-width: 0; +} +.setting-row-label { + font-size: 13px; + font-weight: 500; +} +.setting-row-hint { + font-size: 12px; + color: var(--text-muted); +} +.setting-row-hint code { + font-family: var(--font-mono); + font-size: 11.5px; + padding: 1px 4px; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text); +} +.setting-row-control { + display: flex; + align-items: center; + min-width: 0; + max-width: 100%; +} +.setting-mono { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-muted); +} +.settings-shortcuts { + margin: 0; +} +.settings-shortcut { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + padding: var(--space-2) 0; + border-top: 1px solid var(--border); + font-size: 13px; +} +.settings-shortcut:first-child { + border-top: 0; + padding-top: 0; +} +.settings-shortcut:last-child { + padding-bottom: 0; +} +.settings-shortcut dt { + min-width: 0; + color: var(--text); +} +.settings-shortcut dd { + margin: 0; + flex: none; +} .deploy-result { margin-top: var(--space-3); } diff --git a/apps/agent-forge/src/main.tsx b/apps/agent-forge/src/main.tsx index afc61453..94bfe3c2 100644 --- a/apps/agent-forge/src/main.tsx +++ b/apps/agent-forge/src/main.tsx @@ -3,9 +3,10 @@ import { createRoot } from 'react-dom/client'; import './fonts.css'; import './theme.css'; import { App } from './App'; -import { applyStoredTheme } from './shell/theme'; +import { applyStoredDensity, applyStoredTheme } from './shell/theme'; applyStoredTheme(); +applyStoredDensity(); const container = document.getElementById('root'); if (!container) throw new Error('main.tsx: #root element not found'); diff --git a/apps/agent-forge/src/screens/routes.tsx b/apps/agent-forge/src/screens/routes.tsx index 6882ff67..c71bfa91 100644 --- a/apps/agent-forge/src/screens/routes.tsx +++ b/apps/agent-forge/src/screens/routes.tsx @@ -1,7 +1,7 @@ import { useEffect, type ReactNode } from 'react'; import { matchRoute, navigate } from '../router/router'; import { useRoute } from '../router/useRoute'; -import { ScreenHeaderHost } from '../shell/ScreenHeader'; +import { ScreenHeader, ScreenHeaderHost } from '../shell/ScreenHeader'; import { SettingsPanel } from '../components/SettingsPanel'; import { TracesScreen } from './traces/TracesScreen'; import { ThreadsScreen } from './threads/ThreadsScreen'; @@ -74,6 +74,8 @@ const SCREENS: Partial ReactNode>> = { hub: HubScreen, }; +const SETTINGS_DESCRIPTION = 'Appearance, provider keys, profiles and the collector for this Studio.'; + /** Renders the screen the hash route names; leaving Agents with unsaved changes asks first. */ export function Screens() { const screen = useLeaveAgentsGuard(useScreen()); @@ -85,8 +87,9 @@ export function Screens() { else content = (
+

Settings

- +
); return {content}; diff --git a/apps/agent-forge/src/shell/DaemonStatus.tsx b/apps/agent-forge/src/shell/DaemonStatus.tsx index a0f86dc5..f6932977 100644 --- a/apps/agent-forge/src/shell/DaemonStatus.tsx +++ b/apps/agent-forge/src/shell/DaemonStatus.tsx @@ -2,22 +2,22 @@ import { useEffect, useState } from 'react'; import { observeClient } from '../observe/observeClient'; import { useDaemonHealth } from '../observe/useObserve'; -type CollectorState = 'up' | 'down' | 'checking'; - const LABELS: Record = { up: 'Collector running', down: 'Collector unreachable', checking: 'Checking collector…', }; +/** The collector as the shell sees it: the probe's verdict, and the proxy target once it answered. */ +export type CollectorState = 'up' | 'down' | 'checking'; + /** - * The nav rail's collector card: whether the observe daemon behind the - * `/observe` proxy answers, and where. "checking" only until the first - * health probe settles; the probe keeps polling, so it flips by itself when - * `lousho observe start` runs or the daemon dies. On the collapsed rail only - * the dot shows; the text stays as accessible text and the card's tooltip. + * Whether the observe daemon behind the `/observe` proxy answers, and where. + * "checking" only until the first health probe settles; the probe keeps + * polling, so it flips by itself when `lousho observe start` runs or the + * daemon dies. Shared by the nav card and the Settings screen's Collector card. */ -export function DaemonStatus() { +export function useCollectorStatus(): { state: CollectorState; target: string | undefined } { const health = useDaemonHealth(); const up = health.data !== undefined; const state: CollectorState = up ? 'up' : health.down ? 'down' : 'checking'; @@ -35,6 +35,16 @@ export function DaemonStatus() { }; }, [up]); + return { state, target }; +} + +/** + * The nav rail's collector card (see {@link useCollectorStatus}). On the + * collapsed rail only the dot shows; the text stays as accessible text and + * the card's tooltip. + */ +export function DaemonStatus() { + const { state, target } = useCollectorStatus(); const label = LABELS[state]; return (
diff --git a/apps/agent-forge/src/shell/__tests__/theme.test.ts b/apps/agent-forge/src/shell/__tests__/theme.test.ts new file mode 100644 index 00000000..392be44c --- /dev/null +++ b/apps/agent-forge/src/shell/__tests__/theme.test.ts @@ -0,0 +1,93 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { installMemoryStorage } from '../../ui/__tests__/memoryStorage'; +import { + THEME_EVENT, + applyStoredDensity, + applyStoredTheme, + getDensity, + getThemeChoice, + setDensity, + setThemeChoice, + toggleTheme, +} from '../theme'; + +const root = document.documentElement; + +beforeEach(() => { + installMemoryStorage(); +}); +afterEach(() => { + root.removeAttribute('data-theme'); + root.removeAttribute('data-density'); + window.localStorage.clear(); +}); + +describe('theme choice', () => { + it('reads "system" when nothing is forced', () => { + expect(getThemeChoice()).toBe('system'); + }); + + it('forces light or dark on and remembers it', () => { + setThemeChoice('dark'); + expect(root.getAttribute('data-theme')).toBe('dark'); + expect(window.localStorage.getItem('forge.theme')).toBe('dark'); + expect(getThemeChoice()).toBe('dark'); + setThemeChoice('light'); + expect(getThemeChoice()).toBe('light'); + }); + + it('"system" removes both the attribute and the stored key', () => { + setThemeChoice('dark'); + setThemeChoice('system'); + expect(root.hasAttribute('data-theme')).toBe(false); + expect(window.localStorage.getItem('forge.theme')).toBeNull(); + expect(getThemeChoice()).toBe('system'); + }); + + it('announces every change, the palette toggle included', () => { + const seen = vi.fn(); + window.addEventListener(THEME_EVENT, seen); + setThemeChoice('dark'); + toggleTheme(); + window.removeEventListener(THEME_EVENT, seen); + expect(seen).toHaveBeenCalledTimes(2); + expect(getThemeChoice()).toBe('light'); + expect(window.localStorage.getItem('forge.theme')).toBe('light'); + }); + + it('applyStoredTheme re-applies a remembered choice and ignores garbage', () => { + window.localStorage.setItem('forge.theme', 'dark'); + applyStoredTheme(); + expect(getThemeChoice()).toBe('dark'); + root.removeAttribute('data-theme'); + window.localStorage.setItem('forge.theme', 'sepia'); + applyStoredTheme(); + expect(getThemeChoice()).toBe('system'); + }); +}); + +describe('table density', () => { + it('is unset by default, so each table keeps its own density', () => { + expect(getDensity()).toBeNull(); + }); + + it('sets and remembers it; null clears both', () => { + setDensity('comfortable'); + expect(root.getAttribute('data-density')).toBe('comfortable'); + expect(window.localStorage.getItem('forge.density')).toBe('comfortable'); + expect(getDensity()).toBe('comfortable'); + setDensity(null); + expect(root.hasAttribute('data-density')).toBe(false); + expect(window.localStorage.getItem('forge.density')).toBeNull(); + }); + + it('applyStoredDensity re-applies a remembered density and ignores garbage', () => { + window.localStorage.setItem('forge.density', 'compact'); + applyStoredDensity(); + expect(getDensity()).toBe('compact'); + root.removeAttribute('data-density'); + window.localStorage.setItem('forge.density', 'roomy'); + applyStoredDensity(); + expect(getDensity()).toBeNull(); + }); +}); diff --git a/apps/agent-forge/src/shell/theme.ts b/apps/agent-forge/src/shell/theme.ts index b7821d2e..1ab053d8 100644 --- a/apps/agent-forge/src/shell/theme.ts +++ b/apps/agent-forge/src/shell/theme.ts @@ -1,9 +1,20 @@ /** localStorage key holding the user's explicit theme choice. */ const THEME_KEY = 'forge.theme'; +/** localStorage key holding the user's table density choice; absent = each screen's default. */ +const DENSITY_KEY = 'forge.density'; +/** Window event fired after the theme changes from here, so a mounted Settings control can re-read it. */ +export const THEME_EVENT = 'forge:theme'; type Theme = 'light' | 'dark'; +/** The Settings screen's theme choice: a forced scheme, or `system` (follow the OS). */ +export type ThemeChoice = Theme | 'system'; + +/** Table row density: `compact` 32px rows, `comfortable` 40px rows. */ +export type Density = 'compact' | 'comfortable'; + const isTheme = (v: unknown): v is Theme => v === 'light' || v === 'dark'; +const isDensity = (v: unknown): v is Density => v === 'compact' || v === 'comfortable'; /** The scheme currently in effect: the forced ``, else the OS preference. */ function effectiveTheme(): Theme { @@ -12,23 +23,70 @@ function effectiveTheme(): Theme { return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; } -/** Force the opposite of the current effective scheme and remember the choice. */ -export function toggleTheme(): void { - const next: Theme = effectiveTheme() === 'dark' ? 'light' : 'dark'; - document.documentElement.setAttribute('data-theme', next); +/** Write (or with `null` remove) one remembered preference; blocked storage still applies for this page load. */ +function store(key: string, value: string | null): void { try { - window.localStorage.setItem(THEME_KEY, next); + if (value === null) window.localStorage.removeItem(key); + else window.localStorage.setItem(key, value); } catch { - // storage blocked: the toggle still applies for this page load + // storage blocked: the change still applies for this page load } } -/** Re-apply a theme remembered by {@link toggleTheme}; call once at startup. */ -export function applyStoredTheme(): void { +function read(key: string): string | null { try { - const stored = window.localStorage.getItem(THEME_KEY); - if (isTheme(stored)) document.documentElement.setAttribute('data-theme', stored); + return window.localStorage.getItem(key); } catch { - // storage blocked: fall back to the OS preference + return null; // storage blocked: nothing remembered } } + +/** Force the opposite of the current effective scheme and remember the choice. */ +export function toggleTheme(): void { + setThemeChoice(effectiveTheme() === 'dark' ? 'light' : 'dark'); +} + +/** Re-apply a theme remembered by {@link toggleTheme}; call once at startup. */ +export function applyStoredTheme(): void { + const stored = read(THEME_KEY); + if (isTheme(stored)) document.documentElement.setAttribute('data-theme', stored); +} + +/** The current theme choice: the forced `` scheme, else `system`. */ +export function getThemeChoice(): ThemeChoice { + const attr = document.documentElement.getAttribute('data-theme'); + return isTheme(attr) ? attr : 'system'; +} + +/** + * Apply and remember a theme choice: `light`/`dark` force `` + * and store it; `system` removes both, so the OS preference applies. + * Fires {@link THEME_EVENT} on window. + */ +export function setThemeChoice(choice: ThemeChoice): void { + const root = document.documentElement; + if (choice === 'system') root.removeAttribute('data-theme'); + else root.setAttribute('data-theme', choice); + store(THEME_KEY, choice === 'system' ? null : choice); + window.dispatchEvent(new Event(THEME_EVENT)); +} + +/** The stored table density, or `null` when none is picked (each screen keeps its default). */ +export function getDensity(): Density | null { + const attr = document.documentElement.getAttribute('data-density'); + return isDensity(attr) ? attr : null; +} + +/** Apply and remember a table density as ``; `null` clears both. */ +export function setDensity(density: Density | null): void { + const root = document.documentElement; + if (density === null) root.removeAttribute('data-density'); + else root.setAttribute('data-density', density); + store(DENSITY_KEY, density); +} + +/** Re-apply a density remembered by {@link setDensity}; call once at startup. */ +export function applyStoredDensity(): void { + const stored = read(DENSITY_KEY); + if (isDensity(stored)) document.documentElement.setAttribute('data-density', stored); +} diff --git a/apps/agent-forge/src/ui/dataTable.css b/apps/agent-forge/src/ui/dataTable.css index 6c8018d8..dc30d0d6 100644 --- a/apps/agent-forge/src/ui/dataTable.css +++ b/apps/agent-forge/src/ui/dataTable.css @@ -54,6 +54,13 @@ .dt-comfortable td { height: 40px; } +/* A density picked in Settings (``, shell/theme.ts) overrides each table's own. */ +html[data-density='compact'] .data-table td { + height: 32px; +} +html[data-density='comfortable'] .data-table td { + height: 40px; +} /* the first column names the row. */ .data-table td:first-child { font-weight: 500; diff --git a/docs/agent-forge.md b/docs/agent-forge.md index 25478150..dd57958f 100644 --- a/docs/agent-forge.md +++ b/docs/agent-forge.md @@ -144,7 +144,7 @@ The left navigation lists every screen, grouped: | Evaluate | **Evaluations** | Tabs for Scores, Datasets, Evaluators and Annotation queues. | | Assets | **Prompts** | The prompt registry: versions, labels, diffs. | | Assets | **Context Hub** | Shared key-values agents publish to the daemon. | -| | **Settings** | Provider keys, settings profiles and deploy. | +| | **Settings** | Appearance, provider keys, settings profiles, deploy, collector and shortcuts. | Off the Agents screen a page header names the screen and keeps the live run status pill. Under Settings, a small card shows the collector status (see @@ -485,8 +485,14 @@ its TTL). ### Settings -`#/settings` shows the same settings as the builder drawer's **Settings** -tab; see [Settings, secrets and deploy wiring](#settings-secrets-and-deploy-wiring). +`#/settings` is one column of cards: **Appearance** (theme Light, Dark or +System, and table density Compact or Comfortable, both remembered in this +browser; until you pick a density each table keeps its screen's default), +**Provider keys**, **Settings profile**, **Deploy target**, **Collector** +(whether the observe daemon answers, its address, and `lousho observe start` +when it is down) and **Keyboard shortcuts** (the same list as the `?` sheet). +The builder drawer's **Settings** tab shows only the provider keys, profile +and deploy cards; see [Settings, secrets and deploy wiring](#settings-secrets-and-deploy-wiring). ## First-agent walkthrough @@ -747,9 +753,11 @@ daemon bound beyond loopback. A daemon that is down does not slow or fail the ru ## Settings, secrets and deploy wiring The Settings screen (`#/settings`, the last screen in the nav) and the builder -drawer's **Settings** tab show the same three parts: provider API keys +drawer's **Settings** tab share three parts: provider API keys (OpenAI and Anthropic, stored encrypted under `.lousho/` and never shown -again), named settings profiles (provider, deploy adapter, hook timeout, +again; each key shows **Set** with its masked last characters, **From +environment** when only `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` is set, or +**Not set**), named settings profiles (provider, deploy adapter, hook timeout, OpenTelemetry toggle; kept in `.lousho/settings.json`, which holds no secrets), and a **Deploy** section that picks an adapter (`node-server`, `docker` or `cloudflare-worker`) and runs `lousho build` for the selected From 452e5df21891969eda5060e2ea553d7635737c3f Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Thu, 29 Oct 2026 13:15:59 +0300 Subject: [PATCH 2/3] feat(studio): Settings fix round - static info pill, density reset, key card spacing - "From environment" uses a new static .status-pill.pill-info (no pulse) instead of pill-running. - A picked table density offers "Reset to screen defaults". - No trailing field margin under the last provider key row. - docs: the Settings page header leaves out the run status pill. - Tests: split the key-status case, cover pill-info and the density reset; docShots x-settings-screen-states shows a stored key, an env key, a picked density and the keyboard focus ring on a theme segment. Co-Authored-By: Claude Opus 5.5 --- apps/agent-forge/e2e/docShots.spec.ts | 21 +++++++++++++++++++ .../src/components/SettingsAppSections.tsx | 15 +++++++++++-- .../src/components/SettingsCard.tsx | 2 +- .../src/components/SettingsPanel.tsx | 2 +- .../__tests__/SettingsPanel.test.tsx | 17 ++++++++++++--- apps/agent-forge/src/components/drawer.css | 19 +++++++++++++++++ apps/agent-forge/src/ui/primitives.css | 5 +++++ docs/agent-forge.md | 2 +- 8 files changed, 75 insertions(+), 8 deletions(-) diff --git a/apps/agent-forge/e2e/docShots.spec.ts b/apps/agent-forge/e2e/docShots.spec.ts index 8b0eeb5f..fc2f88bf 100644 --- a/apps/agent-forge/e2e/docShots.spec.ts +++ b/apps/agent-forge/e2e/docShots.spec.ts @@ -892,6 +892,27 @@ test("agents x: the routed settings screen shares the drawer's settings cards", await shoot(page, 'x-settings-screen-375-full'); }); +test('settings x: a stored key, an env key, a picked density and a focused theme segment', async ({ page }) => { + // Answered in the browser: no real key is stored or sent anywhere. + await page.route('**/settings/providers', (route) => + route.request().method() === 'GET' + ? json(route, [ + { provider: 'openai', hasKey: true, masked: '', envKey: false }, + { provider: 'anthropic', hasKey: false, masked: null, envKey: true }, + ]) + : route.fallback(), + ); + await open(page, '/settings'); + await expect(page.getByText('From environment')).toBeVisible(); + await page.getByRole('group', { name: 'Table density' }).getByRole('button', { name: 'Comfortable' }).click(); + await expect(page.getByRole('button', { name: 'Reset to screen defaults' })).toBeVisible(); + // Programmatic focus after the click still counts as keyboard focus once Tab moves it. + await page.getByRole('group', { name: 'Theme' }).getByRole('button', { name: 'Light' }).focus(); + await page.keyboard.press('Tab'); + await expect(page.getByRole('group', { name: 'Theme' }).getByRole('button', { name: 'Dark' })).toBeFocused(); + await shoot(page, 'x-settings-screen-states'); +}); + // --- Evaluations states (x- shots: review-only, never used in the docs) ----- const EVAL_RATES = [ diff --git a/apps/agent-forge/src/components/SettingsAppSections.tsx b/apps/agent-forge/src/components/SettingsAppSections.tsx index 136f7471..5244b978 100644 --- a/apps/agent-forge/src/components/SettingsAppSections.tsx +++ b/apps/agent-forge/src/components/SettingsAppSections.tsx @@ -68,10 +68,21 @@ function DensityControl({ value, onChange }: { value: Density | null; onChange: ); } +function DensityResetHint({ onReset }: { onReset: () => void }) { + return ( + <> + Row height for every table.{' '} + + + ); +} + /** Theme (Light / Dark / System) and table density, both remembered in this browser. */ export function AppearanceSection() { const [density, setDensityState] = useState(getDensity); - const pick = (next: Density) => { + const pick = (next: Density | null) => { setDensity(next); setDensityState(next); }; @@ -82,7 +93,7 @@ export function AppearanceSection() { pick(null)} /> : "Tables use each screen's default until you pick one."} > diff --git a/apps/agent-forge/src/components/SettingsCard.tsx b/apps/agent-forge/src/components/SettingsCard.tsx index adc0e01f..ff796404 100644 --- a/apps/agent-forge/src/components/SettingsCard.tsx +++ b/apps/agent-forge/src/components/SettingsCard.tsx @@ -48,7 +48,7 @@ export function SettingRow({ label, hint, children }: { label: string; hint?: Re } /** A sentence-case status pill in one of the kit's tones (the `.status-pill` look from ui/primitives.css). */ -export function TonePill({ tone, label, title }: { tone: 'ok' | 'error' | 'running' | 'neutral'; label: string; title?: string }) { +export function TonePill({ tone, label, title }: { tone: 'ok' | 'error' | 'info' | 'neutral'; label: string; title?: string }) { return ( ); if (status?.envKey) - return ; + return ; return ; } diff --git a/apps/agent-forge/src/components/__tests__/SettingsPanel.test.tsx b/apps/agent-forge/src/components/__tests__/SettingsPanel.test.tsx index 67df8dc5..d3baddf7 100644 --- a/apps/agent-forge/src/components/__tests__/SettingsPanel.test.tsx +++ b/apps/agent-forge/src/components/__tests__/SettingsPanel.test.tsx @@ -69,19 +69,23 @@ describe('SettingsPanel', () => { expect(host.querySelector('.settings-panel-screen')).not.toBeNull(); }); - it('shows Set (with the masked value), From environment or Not set per key, never pre-filling the input', async () => { + it('shows Set (with the masked value) or From environment (a static info pill), never pre-filling the input', async () => { keys.list = [ { provider: 'openai', hasKey: true, masked: '••••3f2a', envKey: true }, { provider: 'anthropic', hasKey: false, masked: null, envKey: true }, ]; - let host = await mount(); + const host = await mount(); expect(keyPill(host, 'OpenAI API key')).toBe('Set'); expect(host.querySelector('.settings-key-masked')?.textContent).toBe('••••3f2a'); expect(keyPill(host, 'Anthropic API key')).toBe('From environment'); + const envPill = host.querySelector('input[aria-label="Anthropic API key"]')?.closest('.settings-key')?.querySelector('.status-pill'); + expect(envPill?.classList.contains('pill-info')).toBe(true); expect((host.querySelector('input[aria-label="OpenAI API key"]') as HTMLInputElement).value).toBe(''); + }); + it('shows Not set when there is neither a stored key nor the env var', async () => { keys.list = [{ provider: 'openai', hasKey: false, masked: null, envKey: false }]; - host = await mount(); + const host = await mount(); expect(keyPill(host, 'OpenAI API key')).toBe('Not set'); expect(keyPill(host, 'Anthropic API key')).toBe('Not set'); }); @@ -108,6 +112,13 @@ describe('SettingsPanel', () => { expect(document.documentElement.getAttribute('data-density')).toBe('comfortable'); expect(window.localStorage.getItem('forge.density')).toBe('comfortable'); expect(comfy.getAttribute('aria-pressed')).toBe('true'); + expect(host.textContent).not.toContain("until you pick one"); + + act(() => button(host, 'Reset to screen defaults').click()); + expect(document.documentElement.hasAttribute('data-density')).toBe(false); + expect(window.localStorage.getItem('forge.density')).toBeNull(); + expect(group.querySelector('[aria-pressed="true"]')).toBeNull(); + expect(host.textContent).toContain("Tables use each screen's default until you pick one."); }); it('the collector card says how to start a daemon that is down, and lists every shortcut', async () => { diff --git a/apps/agent-forge/src/components/drawer.css b/apps/agent-forge/src/components/drawer.css index 65ed91b9..f38f5e43 100644 --- a/apps/agent-forge/src/components/drawer.css +++ b/apps/agent-forge/src/components/drawer.css @@ -515,6 +515,9 @@ font-size: 12px; color: var(--text-muted); } +.settings-card > .settings-key:last-of-type { + margin-bottom: 0; +} .settings-key-head { display: flex; align-items: center; @@ -629,6 +632,22 @@ border-radius: var(--radius-sm); color: var(--text); } +.setting-link { + padding: 0; + background: none; + border: 0; + border-radius: var(--radius-sm); + font: inherit; + color: var(--accent); + cursor: pointer; +} +.setting-link:hover { + text-decoration: underline; +} +.setting-link:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} .setting-row-control { display: flex; align-items: center; diff --git a/apps/agent-forge/src/ui/primitives.css b/apps/agent-forge/src/ui/primitives.css index e9103af6..f80c9306 100644 --- a/apps/agent-forge/src/ui/primitives.css +++ b/apps/agent-forge/src/ui/primitives.css @@ -25,6 +25,11 @@ background: var(--surface-2); color: var(--text-muted); } +/* A static informational state (e.g. a key read from the environment): the running colours, no pulse. */ +.status-pill.pill-info { + background: var(--info-soft); + color: var(--info); +} /* LLM / agent-flavoured statuses. */ .status-pill.pill-violet { background: var(--violet-soft); diff --git a/docs/agent-forge.md b/docs/agent-forge.md index dd57958f..f5120af3 100644 --- a/docs/agent-forge.md +++ b/docs/agent-forge.md @@ -147,7 +147,7 @@ The left navigation lists every screen, grouped: | | **Settings** | Appearance, provider keys, settings profiles, deploy, collector and shortcuts. | Off the Agents screen a page header names the screen and keeps the live run -status pill. Under Settings, a small card shows the collector status (see +status pill (Settings leaves it out). Under Settings, a small card shows the collector status (see [The collector](#the-collector-observe-daemon)). The button beside the **Agent Forge** name at the top of the nav (**Collapse navigation**) shrinks it to a rail of icons; hover an icon for its name. The choice is From 6224f2461ac96d0971020eaf0696ac53470b89cd Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Thu, 29 Oct 2026 13:48:35 +0300 Subject: [PATCH 3/3] docs(studio): regenerate llms-full.txt for the Settings docs Co-Authored-By: Claude Opus 5.5 --- llms-full.txt | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/llms-full.txt b/llms-full.txt index e21c3b8c..70342052 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -2047,10 +2047,10 @@ The left navigation lists every screen, grouped: | Evaluate | **Evaluations** | Tabs for Scores, Datasets, Evaluators and Annotation queues. | | Assets | **Prompts** | The prompt registry: versions, labels, diffs. | | Assets | **Context Hub** | Shared key-values agents publish to the daemon. | -| | **Settings** | Provider keys, settings profiles and deploy. | +| | **Settings** | Appearance, provider keys, settings profiles, deploy, collector and shortcuts. | Off the Agents screen a page header names the screen and keeps the live run -status pill. Under Settings, a small card shows the collector status (see +status pill (Settings leaves it out). Under Settings, a small card shows the collector status (see [The collector](#the-collector-observe-daemon)). The button beside the **Agent Forge** name at the top of the nav (**Collapse navigation**) shrinks it to a rail of icons; hover an icon for its name. The choice is @@ -2366,8 +2366,14 @@ its TTL). ### Settings -`#/settings` shows the same settings as the builder drawer's **Settings** -tab; see [Settings, secrets and deploy wiring](#settings-secrets-and-deploy-wiring). +`#/settings` is one column of cards: **Appearance** (theme Light, Dark or +System, and table density Compact or Comfortable, both remembered in this +browser; until you pick a density each table keeps its screen's default), +**Provider keys**, **Settings profile**, **Deploy target**, **Collector** +(whether the observe daemon answers, its address, and `lousho observe start` +when it is down) and **Keyboard shortcuts** (the same list as the `?` sheet). +The builder drawer's **Settings** tab shows only the provider keys, profile +and deploy cards; see [Settings, secrets and deploy wiring](#settings-secrets-and-deploy-wiring). ## First-agent walkthrough @@ -2628,9 +2634,11 @@ daemon bound beyond loopback. A daemon that is down does not slow or fail the ru ## Settings, secrets and deploy wiring The Settings screen (`#/settings`, the last screen in the nav) and the builder -drawer's **Settings** tab show the same three parts: provider API keys +drawer's **Settings** tab share three parts: provider API keys (OpenAI and Anthropic, stored encrypted under `.lousho/` and never shown -again), named settings profiles (provider, deploy adapter, hook timeout, +again; each key shows **Set** with its masked last characters, **From +environment** when only `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` is set, or +**Not set**), named settings profiles (provider, deploy adapter, hook timeout, OpenTelemetry toggle; kept in `.lousho/settings.json`, which holds no secrets), and a **Deploy** section that picks an adapter (`node-server`, `docker` or `cloudflare-worker`) and runs `lousho build` for the selected