@aiwg/cockpit 2026.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +337 -0
  2. package/bridge/package.json +13 -0
  3. package/bridge/src/public/index.html +395 -0
  4. package/bridge/src/server.mjs +1132 -0
  5. package/bridge/src/smoke.mjs +158 -0
  6. package/contrib/aiwg-core.json +15 -0
  7. package/contrib/contribution.schema.json +69 -0
  8. package/desktop/README.md +42 -0
  9. package/desktop/src-tauri/Cargo.toml +17 -0
  10. package/desktop/src-tauri/build.rs +3 -0
  11. package/desktop/src-tauri/frontend/index.html +11 -0
  12. package/desktop/src-tauri/src/main.rs +55 -0
  13. package/desktop/src-tauri/tauri.conf.json +20 -0
  14. package/package.json +49 -0
  15. package/runtime-docs/README.md +38 -0
  16. package/shell-core/runtime.mjs +45 -0
  17. package/shell-core/smoke.mjs +36 -0
  18. package/vscode/README.md +25 -0
  19. package/vscode/extension.js +59 -0
  20. package/vscode/package.json +29 -0
  21. package/web/dist/assets/index-B5anpdS1.js +67 -0
  22. package/web/dist/assets/index-CP3BF6uZ.css +32 -0
  23. package/web/dist/index.html +14 -0
  24. package/web/index.html +13 -0
  25. package/web/package.json +31 -0
  26. package/web/src/App.test.tsx +237 -0
  27. package/web/src/App.tsx +255 -0
  28. package/web/src/api.ts +20 -0
  29. package/web/src/components/Actions.tsx +60 -0
  30. package/web/src/components/Approvals.tsx +62 -0
  31. package/web/src/components/CapabilitySearch.tsx +73 -0
  32. package/web/src/components/Explore.tsx +41 -0
  33. package/web/src/components/Inventory.tsx +124 -0
  34. package/web/src/components/LaunchInstanceModal.test.tsx +97 -0
  35. package/web/src/components/LaunchInstanceModal.tsx +236 -0
  36. package/web/src/components/Library.tsx +76 -0
  37. package/web/src/components/Running.tsx +60 -0
  38. package/web/src/components/Sessions.test.tsx +125 -0
  39. package/web/src/components/Sessions.tsx +189 -0
  40. package/web/src/components/StartSessionModal.test.tsx +86 -0
  41. package/web/src/components/StartSessionModal.tsx +168 -0
  42. package/web/src/components/Welcome.tsx +474 -0
  43. package/web/src/main.tsx +11 -0
  44. package/web/src/styles.css +254 -0
  45. package/web/src/types.ts +47 -0
  46. package/web/src/useDebounce.ts +10 -0
  47. package/web/src/useSession.ts +220 -0
  48. package/web/src/util.test.ts +30 -0
  49. package/web/src/util.ts +17 -0
@@ -0,0 +1,189 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { api } from '../api';
3
+ import { fmtId, capRef } from '../util';
4
+ import { CapabilitySearch } from './CapabilitySearch';
5
+ import type { Instance, SessionInfo, CapabilityResult } from '../types';
6
+ import type { SessionApi } from '../useSession';
7
+
8
+ export function Sessions({ session, composer, setComposer, onRequestStart, refreshMs = 5_000 }: { session: SessionApi; composer: string; setComposer: (v: string) => void; onRequestStart: (instanceId?: string) => void; refreshMs?: number }) {
9
+ const [instances, setInstances] = useState<Instance[]>([]);
10
+ const [sessions, setSessions] = useState<SessionInfo[]>([]);
11
+ const [instId, setInstId] = useState('');
12
+ const [attachUrl, setAttachUrl] = useState('');
13
+ const [backendKey, setBackendKey] = useState('');
14
+ const [showPicker, setShowPicker] = useState(false);
15
+ const [endingSession, setEndingSession] = useState('');
16
+ const [sessionErr, setSessionErr] = useState('');
17
+ const inputRef = useRef<HTMLInputElement>(null);
18
+
19
+ const insertCap = (r: CapabilityResult) => {
20
+ const sep = composer && !composer.endsWith(' ') ? ' ' : '';
21
+ setComposer(composer + sep + capRef(r.type, r.name));
22
+ setShowPicker(false);
23
+ inputRef.current?.focus();
24
+ };
25
+
26
+ const refreshInventory = useCallback(() => {
27
+ return api<{ instances: Instance[] }>('/api/inventory')
28
+ .then((d) => {
29
+ const sessionable = dedupeInstances(d.instances).filter((i) => i.state === 'running' && i.session_backends?.some((b) => b.available));
30
+ setInstances(sessionable);
31
+ setInstId((currentId) => {
32
+ if (currentId && sessionable.some((i) => i.id === currentId)) return currentId;
33
+ return sessionable[0]?.id ?? '';
34
+ });
35
+ setBackendKey((currentBackend) => {
36
+ if (currentBackend && sessionable.some((i) => i.session_backends.some((b) => `${b.mode}:${b.backend}` === currentBackend && b.available))) return currentBackend;
37
+ const firstBackend = sessionable[0]?.session_backends.find((b) => b.available) ?? sessionable[0]?.session_backends[0];
38
+ return firstBackend ? `${firstBackend.mode}:${firstBackend.backend}` : '';
39
+ });
40
+ })
41
+ .catch(() => {});
42
+ }, []);
43
+
44
+ useEffect(() => {
45
+ refreshInventory();
46
+ const timer = window.setInterval(refreshInventory, refreshMs);
47
+ return () => window.clearInterval(timer);
48
+ }, [refreshInventory, refreshMs]);
49
+
50
+ const loadSessions = useCallback((id: string) => {
51
+ if (!id) return;
52
+ api<{ sessions: SessionInfo[] }>(`/api/sessions?instance=${encodeURIComponent(id)}`)
53
+ .then((d) => {
54
+ const nextSessions = d.sessions ?? [];
55
+ setSessions(nextSessions);
56
+ setAttachUrl((currentUrl) => {
57
+ if (currentUrl && nextSessions.some((s) => s.attach_url === currentUrl)) return currentUrl;
58
+ return nextSessions[0]?.attach_url ?? '';
59
+ });
60
+ })
61
+ .catch((e) => { setSessions([]); setAttachUrl(''); setSessionErr((e as Error).message); });
62
+ }, []);
63
+ useEffect(() => { loadSessions(instId); }, [instId, loadSessions]);
64
+
65
+ const send = () => { if (session.sendInput(composer)) setComposer(''); };
66
+ const attached = session.state.attached;
67
+ const requestedReplayRole = session.state.role === 'controller' ? 'controller' : 'observer';
68
+ const current = instances.find((i) => i.id === instId);
69
+ const backends = current?.session_backends ?? [];
70
+ const selectedBackend = backends.find((b) => `${b.mode}:${b.backend}` === backendKey) ?? backends.find((b) => b.available) ?? backends[0];
71
+ const selectedSession = sessions.find((s) => s.attach_url === attachUrl);
72
+ useEffect(() => {
73
+ if (!session.state.url) return;
74
+ const sessionStillListed = sessions.some((s) => s.attach_url === session.state.url);
75
+ if (sessions.length && !sessionStillListed) session.detach();
76
+ }, [session.state.url, session.detach, sessions]);
77
+ useEffect(() => {
78
+ if (!current) return;
79
+ const valid = current.session_backends.some((b) => `${b.mode}:${b.backend}` === backendKey);
80
+ if (!valid) {
81
+ const next = current.session_backends.find((b) => b.available) ?? current.session_backends[0];
82
+ setBackendKey(next ? `${next.mode}:${next.backend}` : '');
83
+ }
84
+ }, [backendKey, current]);
85
+ // Starting now routes through the shared picker (#1640/#1641) so this tab and the
86
+ // dashboard verb share one params/clobber/error path. The selects below remain for
87
+ // attaching to / observing / driving sessions that already exist.
88
+ const endSelectedSession = async () => {
89
+ if (!current || !selectedSession) return;
90
+ const label = selectedSession.session_name ?? selectedSession.id;
91
+ if (!confirm(`End session ${label}? This closes the PTY and detaches connected views.`)) return;
92
+ setEndingSession(selectedSession.id);
93
+ setSessionErr('');
94
+ try {
95
+ await api(`/api/instances/${encodeURIComponent(current.id)}/sessions/${encodeURIComponent(selectedSession.id)}`, { method: 'DELETE' });
96
+ if (session.state.url === selectedSession.attach_url) session.detach();
97
+ await loadSessions(current.id);
98
+ } catch (e) {
99
+ setSessionErr((e as Error).message);
100
+ } finally {
101
+ setEndingSession('');
102
+ }
103
+ };
104
+
105
+ return (
106
+ <>
107
+ <p className="hint">
108
+ <strong>Workspace.</strong> Attach to a single session on one instance to observe or drive it. Start a
109
+ new session above, or pick a live task from the <strong>Running</strong> fleet board and open it here.
110
+ </p>
111
+ <div className="controls">
112
+ <label htmlFor="sel-instance">Instance</label>
113
+ <select id="sel-instance" value={instId} onChange={(e) => setInstId(e.target.value)}>
114
+ {instances.map((i) => <option key={i.id} value={i.id}>{i.launch_context?.name ?? fmtId(i.id)} · {i.loadout}</option>)}
115
+ {!instances.length && <option value="">— no session-capable running instances —</option>}
116
+ </select>
117
+ <label htmlFor="sel-backend">Mode</label>
118
+ <select id="sel-backend" value={backendKey} onChange={(e) => setBackendKey(e.target.value)}>
119
+ {backends.length
120
+ ? backends.map((b) => <option key={`${b.mode}:${b.backend}`} value={`${b.mode}:${b.backend}`} disabled={!b.available}>{b.mode} · {b.backend}{b.available ? '' : ` — ${b.reason ?? 'unsupported'}`}</option>)
121
+ : <option value="">— not advertised —</option>}
122
+ </select>
123
+ <button onClick={() => onRequestStart(instId)}>Start…</button>
124
+ <label htmlFor="sel-session">Session</label>
125
+ <select id="sel-session" value={attachUrl} onChange={(e) => setAttachUrl(e.target.value)}>
126
+ {sessions.length
127
+ ? sessions.map((s) => <option key={s.id} value={s.attach_url}>{s.id} · {s.mode ?? s.session_class ?? 'managed'}/{s.backend ?? s.session_backend ?? 'tmux'} · {current?.launch_context?.name ?? fmtId(instId)}</option>)
128
+ : <option value="">— no sessions —</option>}
129
+ </select>
130
+ <button disabled={!attachUrl || (attached && session.state.role === 'observer')} onClick={() => session.attach(attachUrl, false, 'observer')}>Observe</button>
131
+ <button disabled={!attachUrl || selectedBackend?.drive === false || (attached && session.state.role === 'controller')} onClick={() => session.attach(attachUrl, false, 'controller')}>
132
+ {attached && session.state.role === 'observer' ? 'Take Control' : 'Drive'}
133
+ </button>
134
+ <button disabled={!attached || selectedBackend?.keyframe === false} onClick={session.requestKeyframe}>Keyframe</button>
135
+ <button disabled={!attached} onClick={() => session.replay(attachUrl, requestedReplayRole)}>Reattach + replay</button>
136
+ <button
137
+ disabled={!selectedSession || endingSession === selectedSession?.id}
138
+ onClick={endSelectedSession}
139
+ title="Terminate the selected PTY session on the agent"
140
+ >
141
+ {endingSession === selectedSession?.id ? 'Ending…' : 'End Session'}
142
+ </button>
143
+ <button disabled={!attached} onClick={session.detach}>Detach</button>
144
+ {session.state.role && <span className={`badge ${session.state.role}`}>{session.state.role}</span>}
145
+ </div>
146
+ {sessionErr && <p className="err">Session action failed: {sessionErr}</p>}
147
+ {current && (
148
+ <p className="hint">
149
+ {current.runtime_posture.label} · {current.transport.label} ({current.transport.mode}) · attach starts as observe unless control is explicitly granted.
150
+ {attached && session.state.role === 'observer' ? ' Click Take Control to re-attach with write access.' : ''}
151
+ {selectedBackend && !selectedBackend.available ? ` ${selectedBackend.reason ?? 'Selected backend is unavailable.'}` : ''}
152
+ </p>
153
+ )}
154
+ <div className="terminal" ref={session.openTerminal} role="log" aria-label="Session output" />
155
+ {showPicker && (
156
+ <div className="picker">
157
+ <p className="hint" style={{ marginTop: 0 }}>Pick a capability to insert into the command — then Send to inject it. (Lookup is UI; the agent runs it.)</p>
158
+ <CapabilitySearch compact autoFocus onPick={insertCap} />
159
+ </div>
160
+ )}
161
+ <div className="inputrow">
162
+ <button aria-label="Insert a capability" title="Insert a capability (search)" onClick={() => setShowPicker((v) => !v)}>+</button>
163
+ <input
164
+ ref={inputRef}
165
+ value={composer}
166
+ onChange={(e) => setComposer(e.target.value)}
167
+ onKeyDown={(e) => { if (e.key === 'Enter') send(); }}
168
+ placeholder={session.isController ? 'Type to drive the session…' : 'Observing — input is read-only'}
169
+ disabled={!session.isController}
170
+ aria-label="Session input"
171
+ />
172
+ <button disabled={!session.isController} onClick={send}>Send</button>
173
+ </div>
174
+ <p className="hint">
175
+ Pick or start a session, then Attach. Cockpit requests observe-first access; drive/control is explicit and denial reasons stay visible through the session stream.
176
+ Actions inject their command right here.
177
+ </p>
178
+ </>
179
+ );
180
+ }
181
+
182
+ function dedupeInstances(instances: Instance[]) {
183
+ const seen = new Set<string>();
184
+ return instances.filter((instance) => {
185
+ if (seen.has(instance.id)) return false;
186
+ seen.add(instance.id);
187
+ return true;
188
+ });
189
+ }
@@ -0,0 +1,86 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { render, screen, cleanup, waitFor, fireEvent } from '@testing-library/react';
3
+ import { StartSessionModal } from './StartSessionModal';
4
+ import type { SessionApi } from '../useSession';
5
+
6
+ // Minimal routed fetch mock: inventory on open, POST session on start.
7
+ const INSTANCE = {
8
+ id: 'inst-aaaaaaaa-1111', runtime: 'container', loadout: 'agentic-dev', state: 'running', tenant: 'default',
9
+ card_url: '', runtime_posture: { kind: 'container', isolation: 'shared-kernel', label: 'container' },
10
+ host_daemon: { status: 'unknown' }, transport: { mode: 'mtls', trust: 'secure', label: 'mTLS', source: 't' },
11
+ launch_context: { loadout: 'agentic-dev' },
12
+ session_backends: [{ mode: 'managed', backend: 'tmux', available: true, drive: true }],
13
+ };
14
+ function mockFetch(postImpl?: () => Response | Promise<Response>) {
15
+ return vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
16
+ const url = String(input);
17
+ const ok = (body: unknown) => new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } });
18
+ if (url.includes('/api/inventory')) return ok({ instances: [INSTANCE] });
19
+ if (url.includes('/sessions') && init?.method === 'POST') {
20
+ return postImpl ? postImpl() : ok({ id: 'sess-x', attach_url: 'ws://x/agents/i/sessions/sess-x/attach' });
21
+ }
22
+ return new Response('{}', { status: 404 });
23
+ }) as unknown as typeof fetch;
24
+ }
25
+
26
+ function stubSession(attached = false): SessionApi {
27
+ return {
28
+ state: { attached, role: null, url: null },
29
+ attach: vi.fn(), detach: vi.fn(), replay: vi.fn(), requestKeyframe: vi.fn(),
30
+ sendInput: vi.fn(), openTerminal: vi.fn(), isController: false,
31
+ } as unknown as SessionApi;
32
+ }
33
+
34
+ beforeEach(() => { (window as unknown as { __COCKPIT_TOKEN__: string }).__COCKPIT_TOKEN__ = 't'; });
35
+ afterEach(() => { cleanup(); vi.restoreAllMocks(); });
36
+
37
+ describe('StartSessionModal (#1640/#1641)', () => {
38
+ it('renders nothing when closed', () => {
39
+ globalThis.fetch = mockFetch();
40
+ const { container } = render(<StartSessionModal open={false} onClose={() => {}} session={stubSession()} onStarted={() => {}} />);
41
+ expect(container.querySelector('[role="dialog"]')).toBeNull();
42
+ });
43
+
44
+ it('surfaces the selected instance loadout when opened', async () => {
45
+ globalThis.fetch = mockFetch();
46
+ render(<StartSessionModal open onClose={() => {}} session={stubSession()} onStarted={() => {}} />);
47
+ expect(await screen.findByRole('dialog', { name: /start a session/i })).toBeTruthy();
48
+ expect(screen.getByText('Instance loadout')).toBeTruthy();
49
+ expect(screen.getByText('agentic-dev')).toBeTruthy();
50
+ expect(screen.queryByRole('option', { name: /Security Audit/ })).toBeNull();
51
+ });
52
+
53
+ it('explains that attached sessions stay running when starting another session', async () => {
54
+ globalThis.fetch = mockFetch();
55
+ render(<StartSessionModal open onClose={() => {}} session={stubSession(true)} onStarted={() => {}} />);
56
+ expect(await screen.findByText(/existing sessions keep running/i)).toBeTruthy();
57
+ expect(screen.getByRole('button', { name: /start another session/i })).toBeTruthy();
58
+ });
59
+
60
+ it('starts: POSTs with explicit params, attaches, then closes', async () => {
61
+ globalThis.fetch = mockFetch();
62
+ const session = stubSession();
63
+ const onStarted = vi.fn(), onClose = vi.fn();
64
+ render(<StartSessionModal open onClose={onClose} session={session} onStarted={onStarted} />);
65
+ const startBtn = await screen.findByRole('button', { name: /start session/i });
66
+ await waitFor(() => expect((startBtn as HTMLButtonElement).disabled).toBe(false));
67
+ fireEvent.click(startBtn);
68
+ await waitFor(() => expect(session.attach).toHaveBeenCalledWith(expect.stringContaining('/attach'), false, 'observer'));
69
+ const postCall = (globalThis.fetch as unknown as ReturnType<typeof vi.fn>).mock.calls.find((c) => String(c[0]).includes('/sessions') && c[1]?.method === 'POST');
70
+ expect(String(postCall?.[0])).toMatch(/mode=managed&backend=tmux/);
71
+ expect(String(postCall?.[0])).not.toContain('loadout=');
72
+ expect(onStarted).toHaveBeenCalled();
73
+ expect(onClose).toHaveBeenCalled();
74
+ });
75
+
76
+ it('shows the failure inline instead of an alert()', async () => {
77
+ globalThis.fetch = mockFetch(() => new Response('{"error":"boom"}', { status: 500 }));
78
+ const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
79
+ render(<StartSessionModal open onClose={() => {}} session={stubSession()} onStarted={() => {}} />);
80
+ const startBtn = await screen.findByRole('button', { name: /start session/i });
81
+ await waitFor(() => expect((startBtn as HTMLButtonElement).disabled).toBe(false));
82
+ fireEvent.click(startBtn);
83
+ await waitFor(() => expect(screen.getByText(/→ 500/)).toBeTruthy());
84
+ expect(alertSpy).not.toHaveBeenCalled();
85
+ });
86
+ });
@@ -0,0 +1,168 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { api } from '../api';
3
+ import { fmtId } from '../util';
4
+ import type { Instance } from '../types';
5
+ import type { SessionApi } from '../useSession';
6
+
7
+ // The single home for starting a session (#1640/#1641). Both the dashboard "Start a
8
+ // session" verb and the Sessions-tab Start button open this picker, so neither launches
9
+ // blind with defaults. Operator picks instance · runtime · backend · posture,
10
+ // confirms, and only then do we POST with explicit params and attach. Failures render
11
+ // inline here — never an alert() that reads as "nothing happened".
12
+ interface Props {
13
+ open: boolean;
14
+ onClose: () => void;
15
+ session: SessionApi;
16
+ onStarted: () => void; // switch to the Sessions workspace after attach
17
+ initialInstanceId?: string; // pre-select when opened from a specific instance/board
18
+ }
19
+
20
+ export function StartSessionModal({ open, onClose, session, onStarted, initialInstanceId }: Props) {
21
+ const [instances, setInstances] = useState<Instance[]>([]);
22
+ const [instId, setInstId] = useState('');
23
+ const [loadoutId, setLoadoutId] = useState('');
24
+ const [backendKey, setBackendKey] = useState('');
25
+ const [posture, setPosture] = useState<'observer' | 'controller'>('observer');
26
+ const [busy, setBusy] = useState(false);
27
+ const [err, setErr] = useState('');
28
+
29
+ // Load instances when the picker opens (not on mount, so a
30
+ // closed modal never fetches — keeps the app shell test/inert).
31
+ useEffect(() => {
32
+ if (!open) return;
33
+ setErr('');
34
+ let cancelled = false;
35
+ (async () => {
36
+ try {
37
+ const inv = await api<{ instances: Instance[] }>('/api/inventory');
38
+ if (cancelled) return;
39
+ const sessionable = dedupeInstances(inv.instances).filter((i) => i.state === 'running');
40
+ setInstances(sessionable);
41
+ const pick = sessionable.find((i) => i.id === initialInstanceId)
42
+ ?? sessionable.find((i) => i.session_backends?.some((b) => b.available))
43
+ ?? sessionable[0];
44
+ if (pick) {
45
+ setInstId(pick.id);
46
+ setLoadoutId(pick.launch_context?.loadout ?? pick.loadout ?? '');
47
+ const first = pick.session_backends?.find((b) => b.available) ?? pick.session_backends?.[0];
48
+ setBackendKey(first ? `${first.mode}:${first.backend}` : '');
49
+ } else {
50
+ setErr('No stack connected — start an executor first.');
51
+ }
52
+ } catch (e) {
53
+ if (!cancelled) setErr((e as Error).message);
54
+ }
55
+ })();
56
+ return () => { cancelled = true; };
57
+ }, [open, initialInstanceId]);
58
+
59
+ const current = instances.find((i) => i.id === instId);
60
+ const backends = current?.session_backends ?? [];
61
+ const selectedBackend = backends.find((b) => `${b.mode}:${b.backend}` === backendKey)
62
+ ?? backends.find((b) => b.available) ?? backends[0];
63
+
64
+ // When the chosen instance changes, default loadout + backend to that instance.
65
+ useEffect(() => {
66
+ if (!current) return;
67
+ setLoadoutId(current.launch_context?.loadout ?? current.loadout ?? '');
68
+ const first = current.session_backends?.find((b) => b.available) ?? current.session_backends?.[0];
69
+ setBackendKey(first ? `${first.mode}:${first.backend}` : '');
70
+ }, [instId]); // eslint-disable-line react-hooks/exhaustive-deps
71
+
72
+ if (!open) return null;
73
+
74
+ const replacing = session.state.attached;
75
+ const canStart = !busy && !!current && !!selectedBackend?.available;
76
+
77
+ const start = async () => {
78
+ if (!current || !selectedBackend) return;
79
+ setBusy(true); setErr('');
80
+ try {
81
+ const qs = new URLSearchParams({ mode: selectedBackend.mode, backend: selectedBackend.backend });
82
+ const s = await api<{ id: string; attach_url: string }>(
83
+ `/api/instances/${encodeURIComponent(current.id)}/sessions?${qs}`, { method: 'POST' },
84
+ );
85
+ session.attach(s.attach_url, false, posture);
86
+ onStarted();
87
+ onClose();
88
+ } catch (e) {
89
+ setErr((e as Error).message); // inline — the operator sees exactly why
90
+ setBusy(false);
91
+ }
92
+ };
93
+
94
+ return (
95
+ <div className="modal-backdrop" role="presentation" onClick={onClose}>
96
+ <div
97
+ className="modal"
98
+ role="dialog"
99
+ aria-modal="true"
100
+ aria-labelledby="start-session-title"
101
+ onClick={(e) => e.stopPropagation()}
102
+ onKeyDown={(e) => { if (e.key === 'Escape') onClose(); }}
103
+ >
104
+ <h2 id="start-session-title">Start a session</h2>
105
+
106
+ {err && <p className="err">{err}</p>}
107
+ {replacing && (
108
+ <p className="hint warn">You have an attached view. Starting creates another managed session and switches Cockpit to it; existing sessions keep running.</p>
109
+ )}
110
+
111
+ <div className="form-grid">
112
+ <label htmlFor="ss-instance">Instance</label>
113
+ <select id="ss-instance" value={instId} onChange={(e) => setInstId(e.target.value)}>
114
+ {instances.map((i) => (
115
+ <option key={i.id} value={i.id}>{fmtId(i.id)} · {i.runtime} · {i.state}</option>
116
+ ))}
117
+ </select>
118
+
119
+ <label>Runtime</label>
120
+ <span className="ro">{current ? `${current.runtime_posture.label} (${current.runtime})` : '—'}</span>
121
+
122
+ <label>Instance loadout</label>
123
+ <span className="ro">{loadoutId || 'unknown'}{current?.launch_context?.image_ref ? ` · ${current.launch_context.image_ref}` : ''}</span>
124
+
125
+ <label htmlFor="ss-backend">Backend</label>
126
+ <select id="ss-backend" value={backendKey} onChange={(e) => setBackendKey(e.target.value)}>
127
+ {backends.length
128
+ ? backends.map((b) => (
129
+ <option key={`${b.mode}:${b.backend}`} value={`${b.mode}:${b.backend}`} disabled={!b.available}>
130
+ {b.mode} · {b.backend}{b.available ? '' : ` — ${b.reason ?? 'unsupported'}`}
131
+ </option>))
132
+ : <option value="">— not advertised —</option>}
133
+ </select>
134
+
135
+ <label htmlFor="ss-posture">Posture</label>
136
+ <select
137
+ id="ss-posture"
138
+ value={posture}
139
+ onChange={(e) => setPosture(e.target.value as 'observer' | 'controller')}
140
+ >
141
+ <option value="observer">Observe (read-only)</option>
142
+ <option value="controller" disabled={selectedBackend?.drive === false}>Drive (control)</option>
143
+ </select>
144
+ </div>
145
+
146
+ {selectedBackend && !selectedBackend.available && (
147
+ <p className="hint">{selectedBackend.reason ?? 'Selected backend is unavailable on this instance.'}</p>
148
+ )}
149
+
150
+ <div className="modal-actions">
151
+ <button onClick={onClose} disabled={busy}>Cancel</button>
152
+ <button className="cta" onClick={start} disabled={!canStart}>
153
+ {busy ? 'Starting…' : replacing ? 'Start another session' : 'Start session'}
154
+ </button>
155
+ </div>
156
+ </div>
157
+ </div>
158
+ );
159
+ }
160
+
161
+ function dedupeInstances(instances: Instance[]) {
162
+ const seen = new Set<string>();
163
+ return instances.filter((instance) => {
164
+ if (seen.has(instance.id)) return false;
165
+ seen.add(instance.id);
166
+ return true;
167
+ });
168
+ }