Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions apps/agent-forge/e2e/docShots.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -883,6 +883,34 @@ 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');
});

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: '<your-key>', 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) -----
Expand Down
147 changes: 147 additions & 0 deletions apps/agent-forge/src/components/SettingsAppSections.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
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 (`<SettingsPanel variant="screen" />`); 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<ThemeChoice>(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 (
<div className="segmented" role="group" aria-label="Theme">
{THEMES.map(({ id, label, Icon }) => (
<button key={id} type="button" className="seg-btn" aria-pressed={choice === id} onClick={() => choose(id)}>
<Icon size={14} strokeWidth={1.75} aria-hidden="true" />
{label}
</button>
))}
</div>
);
}

function DensityControl({ value, onChange }: { value: Density | null; onChange: (density: Density) => void }) {
return (
<div className="segmented" role="group" aria-label="Table density">
{DENSITIES.map(({ id, label }) => (
<button key={id} type="button" className="seg-btn" aria-pressed={value === id} onClick={() => onChange(id)}>
{label}
</button>
))}
</div>
);
}

function DensityResetHint({ onReset }: { onReset: () => void }) {
return (
<>
Row height for every table.{' '}
<button type="button" className="setting-link" onClick={onReset}>
Reset to screen defaults
</button>
</>
);
}

/** Theme (Light / Dark / System) and table density, both remembered in this browser. */
export function AppearanceSection() {
const [density, setDensityState] = useState<Density | null>(getDensity);
const pick = (next: Density | null) => {
setDensity(next);
setDensityState(next);
};
return (
<SettingsCard Icon={Palette} title="Appearance" description="How the Studio looks in this browser.">
<SettingRow label="Theme" hint="System follows your operating system.">
<ThemeControl />
</SettingRow>
<SettingRow
label="Table density"
hint={density ? <DensityResetHint onReset={() => pick(null)} /> : "Tables use each screen's default until you pick one."}
>
<DensityControl value={density} onChange={pick} />
</SettingRow>
</SettingsCard>
);
}

const COLLECTOR_PILLS: Record<CollectorState, { tone: 'ok' | 'error' | 'neutral'; label: string }> = {
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 (
<SettingsCard Icon={Activity} title="Collector" description="The observe daemon that stores traces, scores and sessions.">
<SettingRow label="Status" hint={state === 'down' ? <>Start it with <code>lousho observe start</code>.</> : undefined}>
<span role="status">
<span className="visually-hidden">Collector status: </span>
<TonePill tone={pill.tone} label={pill.label} />
</span>
</SettingRow>
<SettingRow label="Address" hint="Where the Studio's /observe proxy forwards.">
<span className="setting-mono" title={target}>
{target ?? '—'}
</span>
</SettingRow>
</SettingsCard>
);
}

/** Every Studio shortcut, from the same list as the `?` sheet. */
export function ShortcutsSection() {
return (
<SettingsCard Icon={Keyboard} title="Keyboard shortcuts" description="Press ? anywhere outside a text field to see these.">
<dl className="settings-shortcuts">
{SHORTCUTS.map((s) => (
<div key={s.id} className="settings-shortcut">
<dt>{s.description}</dt>
<dd>
<kbd>{s.keys}</kbd>
</dd>
</div>
))}
</dl>
</SettingsCard>
);
}
58 changes: 58 additions & 0 deletions apps/agent-forge/src/components/SettingsCard.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section className="settings-card" aria-label={title}>
<div className="settings-card-head">
<span className="settings-card-icon" aria-hidden="true">
<Icon size={16} strokeWidth={1.75} />
</span>
<span>
<span className="settings-card-title">{title}</span>
<span className="settings-card-desc">{description}</span>
</span>
</div>
{children}
</section>
);
}

/**
* 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 (
<div className="setting-row">
<div className="setting-row-text">
<span className="setting-row-label">{label}</span>
{hint != null && <span className="setting-row-hint">{hint}</span>}
</div>
<div className="setting-row-control">{children}</div>
</div>
);
}

/** 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' | 'info' | 'neutral'; label: string; title?: string }) {
return (
<span className={`status-pill pill-${tone}`} title={title}>
<span className="dot" aria-hidden="true" />
<span className="status-pill-label">{label}</span>
</span>
);
}
Loading
Loading