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
11 changes: 11 additions & 0 deletions .changeset/devices-settings-section.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@gemstack/the-framework": minor
---

Manage saved devices from the settings page.

Adding and removing a device already worked, but only from the composer's "Run on" menu, and the composer only exists on a project launcher. From the Overview or the settings page there was no way to manage the roster at all. The settings page now has a **Devices** section listing each saved device with its origin and online/offline status, an Add device button, and a remove per row. The "Run on" picker still lists devices, because choosing a run target is a per-run act; which devices exist is configuration.

Removing a device from settings clears the run target when that device was the one selected, the same guard the composer already applied, so a run can never point at a device that is no longer saved.

The section states that devices are saved in the browser rather than on the server: unlike every other setting on the page, a device carries a token, so it stays in this browser's storage and never reaches the daemon.
73 changes: 73 additions & 0 deletions packages/framework-dashboard/components/DevicesSettings.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { afterEach, describe, expect, test, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'

// The device health poll (#1072) reaches the daemon over Telefunc; hoisted so the factory below can
// close over it (a plain const is initialised after vi.mock hoists).
const checkDevices = vi.hoisted(() => vi.fn())
vi.mock('../server/devices.telefunc.js', () => ({ checkDevices }))

import { DevicesSettings } from './DevicesSettings.js'
import { addProfile, listProfiles } from '../lib/profiles.js'
import { selectRemoteDevice, getSelectedRemoteDeviceId } from '../lib/remote-target.js'

const STUDIO = 'http://192.168.1.5:4200'
const BOX = 'http://box.tail.ts:4200'

afterEach(() => {
cleanup()
localStorage.clear()
selectRemoteDevice(null) // module-level state, so it outlives a test unless reset
checkDevices.mockReset()
})

describe('DevicesSettings (#1052/#1072)', () => {
test('says so when there are no devices, rather than showing an empty list', () => {
checkDevices.mockResolvedValue({})
render(<DevicesSettings />)
expect(screen.getByText(/No devices saved/)).toBeTruthy()
})

test('lists each saved device with its origin', () => {
checkDevices.mockResolvedValue({})
addProfile({ url: STUDIO, token: 'aaa', label: 'Studio' })
render(<DevicesSettings />)
expect(screen.getByText('Studio')).toBeTruthy()
expect(screen.getByText(STUDIO)).toBeTruthy()
})

test('removing a device drops it from storage', () => {
checkDevices.mockResolvedValue({})
addProfile({ url: STUDIO, token: 'aaa', label: 'Studio' })
render(<DevicesSettings />)

fireEvent.click(screen.getByLabelText('Remove Studio'))

expect(listProfiles()).toEqual([])
})

test('removing the device a run is targeting clears the run target (#1072)', () => {
// The composer applies this guard on its own remove; managing the roster from settings has to
// apply it too, or the next run points at a device that is no longer in the list.
checkDevices.mockResolvedValue({})
const studio = addProfile({ url: STUDIO, token: 'aaa', label: 'Studio' })
selectRemoteDevice(studio.id)
render(<DevicesSettings />)

fireEvent.click(screen.getByLabelText('Remove Studio'))

expect(getSelectedRemoteDeviceId()).toBe(null)
})

test('removing some other device leaves the run target alone', () => {
checkDevices.mockResolvedValue({})
const studio = addProfile({ url: STUDIO, token: 'aaa', label: 'Studio' })
addProfile({ url: BOX, token: 'bbb', label: 'Box' })
selectRemoteDevice(studio.id)
render(<DevicesSettings />)

fireEvent.click(screen.getByLabelText('Remove Box'))

expect(getSelectedRemoteDeviceId()).toBe(studio.id)
expect(listProfiles().map(p => p.label)).toEqual(['Studio'])
})
})
101 changes: 101 additions & 0 deletions packages/framework-dashboard/components/DevicesSettings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { useState } from 'react'
import { Trash2 } from 'lucide-react'
import { useConnectionProfiles, removeProfile, type ConnectionProfile } from '../lib/profiles.js'
import { useDeviceStatus } from '../lib/use-device-status.js'
import { useSelectedRemoteDeviceId, selectRemoteDevice } from '../lib/remote-target.js'
import { AddDeviceDialog } from './AddDeviceDialog.js'
import { Button } from './ui/button.js'
import { Card, CardContent, CardHeader, CardTitle } from './ui/card.js'
import { cn } from '../lib/utils.js'

// Saved devices, as a settings section (#1052/#1072).
//
// Adding and removing a device already worked, but only from the composer's "Run on" menu, and the
// composer exists on a project launcher and nowhere else: from the Overview or the settings page
// there was no way to manage the roster at all. The picker keeps listing devices, because choosing
// a run target is a per-run act; which devices exist is configuration, so it belongs here.
//
// Unlike everything else on the settings page these are NOT preferences. A device carries a token,
// so it lives in this browser's localStorage and never reaches the daemon (see profiles.ts). The
// section says so, because the reasonable assumption for a settings row is that it follows you to
// the next browser, and this one does not.

export function DevicesSettings() {
const profiles = useConnectionProfiles()
const status = useDeviceStatus(profiles)
const selectedDeviceId = useSelectedRemoteDeviceId()
const [adding, setAdding] = useState(false)

// The same guard the composer applies (#1072): a device that is removed must not stay the run
// target, or the next run points at something that is no longer in the list.
const remove = (profile: ConnectionProfile) => {
if (selectedDeviceId === profile.id) selectRemoteDevice(null)
removeProfile(profile.id)
}

return (
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-3">
<div>
<CardTitle>Devices</CardTitle>
<p className="text-sm text-muted-foreground">
Other machines running The Framework that you can run a session on. Saved in this browser, not on the
server, because each one is reached with its own token.
</p>
</div>
<Button size="sm" variant="outline" className="shrink-0 whitespace-nowrap" onClick={() => setAdding(true)}>
Add device
</Button>
</CardHeader>
<CardContent>
{profiles.length === 0 ? (
<p className="py-2 text-sm text-muted-foreground">
No devices saved. Add one with the URL another machine prints when it starts.
</p>
) : (
<ul className="divide-y divide-border">
{profiles.map(profile => (
<li key={profile.id} className="flex items-center justify-between gap-4 py-3 first:pt-0 last:pb-0">
<div className="min-w-0">
<p className="truncate text-sm">{profile.label}</p>
<p className="truncate text-xs text-muted-foreground">{profile.url}</p>
</div>
<div className="flex shrink-0 items-center gap-3">
<DeviceStatusBadge state={status[profile.id]} />
<Button
size="sm"
variant="ghost"
onClick={() => remove(profile)}
title={`Remove ${profile.label}`}
aria-label={`Remove ${profile.label}`}
>
<Trash2 className="h-4 w-4" aria-hidden />
</Button>
</div>
</li>
))}
</ul>
)}
</CardContent>

{adding && <AddDeviceDialog onClose={() => setAdding(false)} onAdded={() => setAdding(false)} />}
</Card>
)
}

/** Online / offline, or neither while the first ping is still out. */
function DeviceStatusBadge({ state }: { state: 'online' | 'offline' | undefined }) {
const label = state === 'online' ? 'Online' : state === 'offline' ? 'Offline' : 'Checking…'
return (
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span
aria-hidden
className={cn(
'h-1.5 w-1.5 rounded-full',
state === 'online' ? 'bg-[var(--color-primary)]' : 'bg-muted-foreground/40',
)}
/>
{label}
</span>
)
}
4 changes: 4 additions & 0 deletions packages/framework-dashboard/components/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { AGENTS, AGENT_LABELS } from '@gemstack/the-framework/client'
import { useDetectedEditors } from '../lib/editors.js'
import { usePreferences, updatePreferences, themePreference, type ThemePreference } from '../lib/preferences.js'
import { OnboardingChecklist } from './OnboardingChecklist.js'
import { DevicesSettings } from './DevicesSettings.js'
import { Card, CardContent, CardHeader, CardTitle } from './ui/card.js'
import { Checkbox } from './ui/checkbox.js'
import { ScrollArea } from './ui/scroll-area.js'
Expand Down Expand Up @@ -87,6 +88,9 @@ export function SettingsPage({ onSelectProject }: { onSelectProject?: ((id: stri
/>
</Section>

{/* Beside "Run on", since a saved device is the other thing a session can run on. */}
<DevicesSettings />

<Section title="Run options">
<ToggleRow
label="Transparent"
Expand Down
Loading