Skip to content

Commit aa349a4

Browse files
authored
feat(dashboard): manage saved devices from the settings page (#1097)
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. Adds a Devices section to the settings page listing each saved device with its origin and online/offline status, an Add device button (the existing dialog), 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 from settings applies the same guard the composer already did: a device that was the selected run target clears the selection, so a run can never point at a device that is no longer saved. The presentation is new but the state is not: it reads the existing useConnectionProfiles / useDeviceStatus hooks and profiles.ts, so there is one device store, not two. The section says devices are saved in this browser because, unlike every other setting on the page, a device carries a token and so never reaches the daemon.
1 parent fa743d9 commit aa349a4

4 files changed

Lines changed: 189 additions & 0 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@gemstack/the-framework": minor
3+
---
4+
5+
Manage saved devices from the settings page.
6+
7+
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.
8+
9+
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.
10+
11+
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.
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { afterEach, describe, expect, test, vi } from 'vitest'
2+
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
3+
4+
// The device health poll (#1072) reaches the daemon over Telefunc; hoisted so the factory below can
5+
// close over it (a plain const is initialised after vi.mock hoists).
6+
const checkDevices = vi.hoisted(() => vi.fn())
7+
vi.mock('../server/devices.telefunc.js', () => ({ checkDevices }))
8+
9+
import { DevicesSettings } from './DevicesSettings.js'
10+
import { addProfile, listProfiles } from '../lib/profiles.js'
11+
import { selectRemoteDevice, getSelectedRemoteDeviceId } from '../lib/remote-target.js'
12+
13+
const STUDIO = 'http://192.168.1.5:4200'
14+
const BOX = 'http://box.tail.ts:4200'
15+
16+
afterEach(() => {
17+
cleanup()
18+
localStorage.clear()
19+
selectRemoteDevice(null) // module-level state, so it outlives a test unless reset
20+
checkDevices.mockReset()
21+
})
22+
23+
describe('DevicesSettings (#1052/#1072)', () => {
24+
test('says so when there are no devices, rather than showing an empty list', () => {
25+
checkDevices.mockResolvedValue({})
26+
render(<DevicesSettings />)
27+
expect(screen.getByText(/No devices saved/)).toBeTruthy()
28+
})
29+
30+
test('lists each saved device with its origin', () => {
31+
checkDevices.mockResolvedValue({})
32+
addProfile({ url: STUDIO, token: 'aaa', label: 'Studio' })
33+
render(<DevicesSettings />)
34+
expect(screen.getByText('Studio')).toBeTruthy()
35+
expect(screen.getByText(STUDIO)).toBeTruthy()
36+
})
37+
38+
test('removing a device drops it from storage', () => {
39+
checkDevices.mockResolvedValue({})
40+
addProfile({ url: STUDIO, token: 'aaa', label: 'Studio' })
41+
render(<DevicesSettings />)
42+
43+
fireEvent.click(screen.getByLabelText('Remove Studio'))
44+
45+
expect(listProfiles()).toEqual([])
46+
})
47+
48+
test('removing the device a run is targeting clears the run target (#1072)', () => {
49+
// The composer applies this guard on its own remove; managing the roster from settings has to
50+
// apply it too, or the next run points at a device that is no longer in the list.
51+
checkDevices.mockResolvedValue({})
52+
const studio = addProfile({ url: STUDIO, token: 'aaa', label: 'Studio' })
53+
selectRemoteDevice(studio.id)
54+
render(<DevicesSettings />)
55+
56+
fireEvent.click(screen.getByLabelText('Remove Studio'))
57+
58+
expect(getSelectedRemoteDeviceId()).toBe(null)
59+
})
60+
61+
test('removing some other device leaves the run target alone', () => {
62+
checkDevices.mockResolvedValue({})
63+
const studio = addProfile({ url: STUDIO, token: 'aaa', label: 'Studio' })
64+
addProfile({ url: BOX, token: 'bbb', label: 'Box' })
65+
selectRemoteDevice(studio.id)
66+
render(<DevicesSettings />)
67+
68+
fireEvent.click(screen.getByLabelText('Remove Box'))
69+
70+
expect(getSelectedRemoteDeviceId()).toBe(studio.id)
71+
expect(listProfiles().map(p => p.label)).toEqual(['Studio'])
72+
})
73+
})
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { useState } from 'react'
2+
import { Trash2 } from 'lucide-react'
3+
import { useConnectionProfiles, removeProfile, type ConnectionProfile } from '../lib/profiles.js'
4+
import { useDeviceStatus } from '../lib/use-device-status.js'
5+
import { useSelectedRemoteDeviceId, selectRemoteDevice } from '../lib/remote-target.js'
6+
import { AddDeviceDialog } from './AddDeviceDialog.js'
7+
import { Button } from './ui/button.js'
8+
import { Card, CardContent, CardHeader, CardTitle } from './ui/card.js'
9+
import { cn } from '../lib/utils.js'
10+
11+
// Saved devices, as a settings section (#1052/#1072).
12+
//
13+
// Adding and removing a device already worked, but only from the composer's "Run on" menu, and the
14+
// composer exists on a project launcher and nowhere else: from the Overview or the settings page
15+
// there was no way to manage the roster at all. The picker keeps listing devices, because choosing
16+
// a run target is a per-run act; which devices exist is configuration, so it belongs here.
17+
//
18+
// Unlike everything else on the settings page these are NOT preferences. A device carries a token,
19+
// so it lives in this browser's localStorage and never reaches the daemon (see profiles.ts). The
20+
// section says so, because the reasonable assumption for a settings row is that it follows you to
21+
// the next browser, and this one does not.
22+
23+
export function DevicesSettings() {
24+
const profiles = useConnectionProfiles()
25+
const status = useDeviceStatus(profiles)
26+
const selectedDeviceId = useSelectedRemoteDeviceId()
27+
const [adding, setAdding] = useState(false)
28+
29+
// The same guard the composer applies (#1072): a device that is removed must not stay the run
30+
// target, or the next run points at something that is no longer in the list.
31+
const remove = (profile: ConnectionProfile) => {
32+
if (selectedDeviceId === profile.id) selectRemoteDevice(null)
33+
removeProfile(profile.id)
34+
}
35+
36+
return (
37+
<Card>
38+
<CardHeader className="flex flex-row items-start justify-between gap-3">
39+
<div>
40+
<CardTitle>Devices</CardTitle>
41+
<p className="text-sm text-muted-foreground">
42+
Other machines running The Framework that you can run a session on. Saved in this browser, not on the
43+
server, because each one is reached with its own token.
44+
</p>
45+
</div>
46+
<Button size="sm" variant="outline" className="shrink-0 whitespace-nowrap" onClick={() => setAdding(true)}>
47+
Add device
48+
</Button>
49+
</CardHeader>
50+
<CardContent>
51+
{profiles.length === 0 ? (
52+
<p className="py-2 text-sm text-muted-foreground">
53+
No devices saved. Add one with the URL another machine prints when it starts.
54+
</p>
55+
) : (
56+
<ul className="divide-y divide-border">
57+
{profiles.map(profile => (
58+
<li key={profile.id} className="flex items-center justify-between gap-4 py-3 first:pt-0 last:pb-0">
59+
<div className="min-w-0">
60+
<p className="truncate text-sm">{profile.label}</p>
61+
<p className="truncate text-xs text-muted-foreground">{profile.url}</p>
62+
</div>
63+
<div className="flex shrink-0 items-center gap-3">
64+
<DeviceStatusBadge state={status[profile.id]} />
65+
<Button
66+
size="sm"
67+
variant="ghost"
68+
onClick={() => remove(profile)}
69+
title={`Remove ${profile.label}`}
70+
aria-label={`Remove ${profile.label}`}
71+
>
72+
<Trash2 className="h-4 w-4" aria-hidden />
73+
</Button>
74+
</div>
75+
</li>
76+
))}
77+
</ul>
78+
)}
79+
</CardContent>
80+
81+
{adding && <AddDeviceDialog onClose={() => setAdding(false)} onAdded={() => setAdding(false)} />}
82+
</Card>
83+
)
84+
}
85+
86+
/** Online / offline, or neither while the first ping is still out. */
87+
function DeviceStatusBadge({ state }: { state: 'online' | 'offline' | undefined }) {
88+
const label = state === 'online' ? 'Online' : state === 'offline' ? 'Offline' : 'Checking…'
89+
return (
90+
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
91+
<span
92+
aria-hidden
93+
className={cn(
94+
'h-1.5 w-1.5 rounded-full',
95+
state === 'online' ? 'bg-[var(--color-primary)]' : 'bg-muted-foreground/40',
96+
)}
97+
/>
98+
{label}
99+
</span>
100+
)
101+
}

packages/framework-dashboard/components/SettingsPage.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { AGENTS, AGENT_LABELS } from '@gemstack/the-framework/client'
33
import { useDetectedEditors } from '../lib/editors.js'
44
import { usePreferences, updatePreferences, themePreference, type ThemePreference } from '../lib/preferences.js'
55
import { OnboardingChecklist } from './OnboardingChecklist.js'
6+
import { DevicesSettings } from './DevicesSettings.js'
67
import { Card, CardContent, CardHeader, CardTitle } from './ui/card.js'
78
import { Checkbox } from './ui/checkbox.js'
89
import { ScrollArea } from './ui/scroll-area.js'
@@ -87,6 +88,9 @@ export function SettingsPage({ onSelectProject }: { onSelectProject?: ((id: stri
8788
/>
8889
</Section>
8990

91+
{/* Beside "Run on", since a saved device is the other thing a session can run on. */}
92+
<DevicesSettings />
93+
9094
<Section title="Run options">
9195
<ToggleRow
9296
label="Transparent"

0 commit comments

Comments
 (0)