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
16 changes: 8 additions & 8 deletions docs/full-local.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,28 +122,28 @@ docker compose exec web druks doctor --sandbox

This creates and deletes a real sandbox container.

## 4. Log in a browser session
## 4. Connect a browser profile

Create the browser session:
Installed apps declare the browser profiles they use. To save a login:

1. Open **Settings → Browser sessions**.
2. Create a stable session name.
1. Open **Settings → Connections → Browser**.
2. Find the profile for the site.
3. Choose **Log in**. Druks opens a headed browser in a disposable browser sandbox.
4. Authenticate on the site.
5. Choose **Save**. Druks closes the browser and stores its encrypted profile.
It marks the session as ready.
It marks the profile as ready.

When a site expires the login, the session becomes stale. Choose **Reconnect**.
When a site expires the login, the profile becomes stale. Choose **Reconnect**.
Druks creates a new login window from the saved state. Authenticate again. Then
save the replacement profile.

**Cancel** deletes the disposable sandbox without
a change to the saved state. A web-process restart also deletes open login
windows. After Druks returns, open the window again.

To examine the complete path, save the session. Then run an app workflow that
To examine the complete path, save the profile. Then run an app workflow that
borrows it. Make sure that its browser opens the authenticated site. A saved
login window always stores `profile_dir`. This rule also applies to a session
login window always stores `profile_dir`. This rule also applies to a profile
that came from Playwright `storage_state`.

## 5. Exercise an app
Expand Down
4 changes: 4 additions & 0 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ a failed request keeps the operator on that page with its draft. Resource
actions, such as connecting a provider or minting an API token, apply at once.
Back to Druks restores the previous work URL and keeps the work page mounted.

Connections groups Services, Accounts, Browser, and Revoked. Its `tab` query
parameter selects the active tab. The Browser profiles page manages saved browser state
and login windows at `/settings/connections?tab=browser`.

App settings use `/apps/<name>/settings` in the work context. A gear beside the
app name in the header and the central App settings index link to this same route.
Options and Agents appear only when the app declares those controls. Both
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'

import type { BrowserSession } from '../api/types'
import { api } from '../api/client'
import { BrowserSessionsPane } from './BrowserSessionsPane'
import { BrowserProfilesPane } from './BrowserProfilesPane'

function browserSession(overrides: Partial<BrowserSession> = {}): BrowserSession {
return {
Expand Down Expand Up @@ -43,7 +43,7 @@ function renderPane() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
render(
<QueryClientProvider client={queryClient}>
<BrowserSessionsPane />
<BrowserProfilesPane />
</QueryClientProvider>,
)
}
Expand All @@ -55,15 +55,15 @@ afterEach(() => {
vi.restoreAllMocks()
})

describe('BrowserSessionsPane', () => {
describe('BrowserProfilesPane', () => {
it('shows a failed read without a false empty state and retries', async () => {
vi.spyOn(api, 'browserSessions').mockRejectedValueOnce(new Error('unavailable')).mockResolvedValueOnce([])
renderPane()
expect(screen.getByRole('status').textContent).toBe('Loading browser sessions…')
expect(await screen.findByRole('alert')).toHaveProperty('textContent', 'Could not load browser sessions. Try again')
expect(screen.queryByText('No installed app declares a browser session.')).toBeNull()
expect(screen.getByRole('status').textContent).toBe('Loading browser profiles…')
expect(await screen.findByRole('alert')).toHaveProperty('textContent', 'Could not load browser profiles. Try again')
expect(screen.queryByText('No installed app declares a browser profile.')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Try again' }))
expect(await screen.findByText('No installed app declares a browser session.')).toBeTruthy()
expect(await screen.findByText('No installed app declares a browser profile.')).toBeTruthy()
})
it('lists status, base-aware login actions, and refresh timestamps', async () => {
vi.stubEnv('BASE_URL', '/druks/')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const LOGIN_ACTION_LABELS: Record<Exclude<BrowserSessionStatus, 'anonymous'>, st
stale: 'Reconnect',
}

export function BrowserSessionsPane() {
export function BrowserProfilesPane() {
const queryClient = useQueryClient()
const query = useQuery({
queryKey: ['browserSessions'],
Expand Down Expand Up @@ -57,16 +57,16 @@ export function BrowserSessionsPane() {
return (
<div className="set-pane mcp-pane browser-sessions-pane">
<header className="mcp-pane-head">
<h2 className="mcp-pane-title">Browser</h2>
<h2 className="mcp-pane-title">Browser profiles</h2>
<p className="mcp-pane-sub">
Sign-ins your apps declare, kept as encrypted browser state.
Saved browser state for the sites your apps use.
</p>
</header>

{query.isPending && <p role="status">Loading browser sessions…</p>}
{query.isPending && <p role="status">Loading browser profiles…</p>}
{query.isError && (
<p className="mcp-error" role="alert">
Could not load browser sessions.{' '}
Could not load browser profiles.{' '}
<button className="set-btn ghost" onClick={() => void query.refetch()}>
Try again
</button>
Expand All @@ -80,10 +80,10 @@ export function BrowserSessionsPane() {

<section className="mcp-section">
<h3 className="mcp-h">
Sessions <span className="gl-count">{sessions.length}</span>
Profiles <span className="gl-count">{sessions.length}</span>
</h3>
{query.isSuccess && sessions.length === 0 && (
<p className="mcp-help">No installed app declares a browser session.</p>
<p className="mcp-help">No installed app declares a browser profile.</p>
)}
{sessions.length > 0 && (
<div className="browser-session-list">
Expand Down
27 changes: 22 additions & 5 deletions frontend/src/components/SettingsPages.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -449,14 +449,31 @@ afterEach(() => {
})

describe('SettingsPages app fields', () => {
it('opens browser sessions from settings navigation', async () => {
it('opens browser profiles through Connections and settings search', async () => {
stubFetch()
renderSettings()

fireEvent.click(await screen.findByRole('link', { name: 'Browser sessions' }))
expect(screen.queryByRole('link', { name: 'Browser sessions' })).toBeNull()
fireEvent.click(await screen.findByRole('link', { name: 'Connections' }))
fireEvent.click(screen.getByRole('link', { name: 'Browser' }))

expect(await screen.findByRole('heading', { name: 'Browser' })).toBeTruthy()
expect(await screen.findByText('No installed app declares a browser session.')).toBeTruthy()
expect(await screen.findByRole('heading', { name: 'Browser profiles' })).toBeTruthy()
expect(await screen.findByText('No installed app declares a browser profile.')).toBeTruthy()
expect(window.location.search).toBe('?tab=browser')

fireEvent.click(screen.getByRole('link', { name: 'Accounts' }))
expect(screen.queryByRole('heading', { name: 'Browser profiles' })).toBeNull()
fireEvent.change(screen.getByLabelText('Search settings'), {
target: { value: 'browser sessions' },
})
fireEvent.click(
within(screen.getByLabelText('Settings search results')).getByRole('link', {
name: /Browser profiles/,
}),
)
expect(screen.getByRole('heading', { name: 'Browser profiles' })).toBeTruthy()
expect(screen.getByRole('link', { name: 'Browser' }).getAttribute('aria-current')).toBe('page')
expect(window.location.search).toBe('?tab=browser')
})

it('spells an underscored app name out in the index and its options group', async () => {
Expand Down Expand Up @@ -1339,7 +1356,7 @@ describe('settings resource read failures', () => {
.mockResolvedValue([])
renderSettings(`/settings/${section}`)
if (method === 'listConnections') {
fireEvent.click(await screen.findByRole('button', { name: 'Accounts' }))
fireEvent.click(await screen.findByRole('link', { name: 'Accounts' }))
}
const alert = await screen.findByRole('alert')
expect(alert.textContent).toContain(`Could not load ${label}.`)
Expand Down
53 changes: 29 additions & 24 deletions frontend/src/components/SettingsPages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { absTime } from '../lib/format'
import { harnessColors } from '../lib/harnessColors'
import { Page } from './Page'
import { Sidebar } from './Sidebar'
import { BrowserSessionsPane } from './BrowserSessionsPane'
import { BrowserProfilesPane } from './BrowserProfilesPane'
import {
AgentAccessPane,
AgentsPane,
Expand Down Expand Up @@ -44,13 +44,19 @@ const SECTIONS = [
{ id: 'connections', label: 'Connections', group: 'Tools & access' },
{ id: 'mcp', label: 'MCP servers', group: 'Tools & access' },
{ id: 'skills', label: 'Skills', group: 'Tools & access' },
{ id: 'browser-sessions', label: 'Browser sessions', group: 'Tools & access' },
{ id: 'general', label: 'General', group: 'Installation' },
{ id: 'personal', label: 'Preferences', group: 'Personal' },
{ id: 'api-tokens', label: 'API tokens', group: 'Personal' },
{ id: 'apps', label: 'App settings', group: 'Apps' },
]

const CONNECTION_TABS = [
{ id: 'services', label: 'Services' },
{ id: 'accounts', label: 'Accounts' },
{ id: 'browser', label: 'Browser' },
{ id: 'revoked', label: 'Revoked' },
]

function withField(
current: Record<string, unknown> | undefined,
field: string,
Expand All @@ -76,7 +82,10 @@ export function SettingsPages({
active?: boolean
}) {
const [location, navigate] = useLocation()
const fieldTarget = new URLSearchParams(useSearch()).get('field')
const searchParams = new URLSearchParams(useSearch())
const fieldTarget = searchParams.get('field')
const connectionsTab =
CONNECTION_TABS.find((tab) => tab.id === searchParams.get('tab'))?.id ?? 'services'
const content = useRef<HTMLElement>(null)
const section = appName ? `apps/${appName}` : location.slice('/settings/'.length) || 'providers'
const formPath = `${import.meta.env.BASE_URL.replace(/\/$/, '')}${appName ? `/apps/${appName}/settings` : '/settings'}`
Expand Down Expand Up @@ -130,7 +139,6 @@ export function SettingsPages({
const [errors, setErrors] = useState<Record<string, string>>({})
const [saving, setSaving] = useState(false)
const [search, setSearch] = useState('')
const [connectionsTab, setConnectionsTab] = useState('services')
const [visited, setVisited] = useState([section])
if (!visited.includes(section)) setVisited([...visited, section])
const tick = useTicker()
Expand Down Expand Up @@ -398,7 +406,11 @@ export function SettingsPages({
const searchResults = SECTIONS.map((entry) => ({
label: entry.label, owner: entry.group, kind: 'Section', terms: '',
path: `/settings/${entry.id}`,
})).concat(Object.values(SETTINGS_FIELDS).map((field) => ({
})).concat([{
label: 'Browser profiles', owner: 'Connections', kind: 'Section',
terms: 'browser sessions sign-ins saved logins',
path: '/settings/connections?tab=browser',
}], Object.values(SETTINGS_FIELDS).map((field) => ({
label: field.label, owner: SECTIONS.find((entry) => entry.id === field.section)!.label,
kind: 'Field', terms: field.terms,
path: `/settings/${field.section}?field=${field.field}`,
Expand Down Expand Up @@ -717,39 +729,32 @@ export function SettingsPages({
{page === 'connections' && (
<>
<nav className="settings-tabs" aria-label="Connections">
<button
onClick={() => setConnectionsTab('services')}
aria-current={connectionsTab === 'services' ? 'page' : undefined}
>
Services
</button>
<button
onClick={() => setConnectionsTab('accounts')}
aria-current={connectionsTab === 'accounts' ? 'page' : undefined}
>
Accounts
</button>
<button
onClick={() => setConnectionsTab('revoked')}
aria-current={connectionsTab === 'revoked' ? 'page' : undefined}
>
Revoked
</button>
{CONNECTION_TABS.map((tab) => (
<Link
key={tab.id}
href={`/settings/connections?tab=${tab.id}`}
aria-current={connectionsTab === tab.id ? 'page' : undefined}
>
{tab.label}
</Link>
))}
</nav>
<div hidden={connectionsTab !== 'services'}>
<ServicesPane />
</div>
<div hidden={connectionsTab !== 'accounts'}>
<ConnectionsPane />
</div>
<div hidden={connectionsTab !== 'browser'}>
<BrowserProfilesPane />
</div>
<div hidden={connectionsTab !== 'revoked'}>
<ConnectionsPane revokedOnly />
</div>
</>
)}
{page === 'mcp' && <McpServersPane />}
{page === 'skills' && <SkillsPane />}
{page === 'browser-sessions' && <BrowserSessionsPane />}
{page === 'api-tokens' && <AgentAccessPane />}
{page === 'apps' && !search.trim() && (
<div className="settings-app-index">
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/pages/LoginWindowPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ export function LoginWindowPage({ name }: Props) {
<Page className="page-login-window" scroll="internal">
<header className="login-window-head">
<div>
<span className="login-window-kicker mono">browser session</span>
<span className="login-window-kicker mono">browser profile</span>
<h1>{name}</h1>
<p className={`login-window-connection is-${connection}`}>
{CONNECTION_COPY[connection]}
Expand Down