feat(v2): add frontend remote machine integration

remote-bridge.ts adapter for machine management IPC. machines.svelte.ts
store for remote machine state. Layout store extended with
remoteMachineId on Pane interface. agent-bridge.ts and pty-bridge.ts
route to remote commands when remoteMachineId is set. SettingsDialog
gains Remote Machines section. Sidebar auto-groups remote panes by
machine label.
This commit is contained in:
Hibryda 2026-03-06 19:05:53 +01:00
parent 0b39133d66
commit 5503340e87
7 changed files with 481 additions and 5 deletions

View file

@ -11,13 +11,21 @@ export interface AgentQueryOptions {
max_turns?: number;
max_budget_usd?: number;
resume_session_id?: string;
remote_machine_id?: string;
}
export async function queryAgent(options: AgentQueryOptions): Promise<void> {
if (options.remote_machine_id) {
const { remote_machine_id: machineId, ...agentOptions } = options;
return invoke('remote_agent_query', { machineId, options: agentOptions });
}
return invoke('agent_query', { options });
}
export async function stopAgent(sessionId: string): Promise<void> {
export async function stopAgent(sessionId: string, remoteMachineId?: string): Promise<void> {
if (remoteMachineId) {
return invoke('remote_agent_stop', { machineId: remoteMachineId, sessionId });
}
return invoke('agent_stop', { sessionId });
}

View file

@ -7,21 +7,35 @@ export interface PtyOptions {
args?: string[];
cols?: number;
rows?: number;
remote_machine_id?: string;
}
export async function spawnPty(options: PtyOptions): Promise<string> {
if (options.remote_machine_id) {
const { remote_machine_id: machineId, ...ptyOptions } = options;
return invoke<string>('remote_pty_spawn', { machineId, options: ptyOptions });
}
return invoke<string>('pty_spawn', { options });
}
export async function writePty(id: string, data: string): Promise<void> {
export async function writePty(id: string, data: string, remoteMachineId?: string): Promise<void> {
if (remoteMachineId) {
return invoke('remote_pty_write', { machineId: remoteMachineId, id, data });
}
return invoke('pty_write', { id, data });
}
export async function resizePty(id: string, cols: number, rows: number): Promise<void> {
export async function resizePty(id: string, cols: number, rows: number, remoteMachineId?: string): Promise<void> {
if (remoteMachineId) {
return invoke('remote_pty_resize', { machineId: remoteMachineId, id, cols, rows });
}
return invoke('pty_resize', { id, cols, rows });
}
export async function killPty(id: string): Promise<void> {
export async function killPty(id: string, remoteMachineId?: string): Promise<void> {
if (remoteMachineId) {
return invoke('remote_pty_kill', { machineId: remoteMachineId, id });
}
return invoke('pty_kill', { id });
}

View file

@ -0,0 +1,122 @@
// Remote Machine Bridge — Tauri IPC adapter for multi-machine management
import { invoke } from '@tauri-apps/api/core';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
export interface RemoteMachineConfig {
label: string;
url: string;
token: string;
auto_connect: boolean;
}
export interface RemoteMachineInfo {
id: string;
label: string;
url: string;
status: string;
auto_connect: boolean;
}
// --- Machine management ---
export async function listRemoteMachines(): Promise<RemoteMachineInfo[]> {
return invoke('remote_list');
}
export async function addRemoteMachine(config: RemoteMachineConfig): Promise<string> {
return invoke('remote_add', { config });
}
export async function removeRemoteMachine(machineId: string): Promise<void> {
return invoke('remote_remove', { machineId });
}
export async function connectRemoteMachine(machineId: string): Promise<void> {
return invoke('remote_connect', { machineId });
}
export async function disconnectRemoteMachine(machineId: string): Promise<void> {
return invoke('remote_disconnect', { machineId });
}
// --- Remote event listeners ---
export interface RemoteSidecarMessage {
machineId: string;
sessionId?: string;
event?: Record<string, unknown>;
}
export interface RemotePtyData {
machineId: string;
sessionId?: string;
data?: string;
}
export interface RemotePtyExit {
machineId: string;
sessionId?: string;
}
export interface RemoteMachineEvent {
machineId: string;
payload?: unknown;
error?: unknown;
}
export async function onRemoteSidecarMessage(
callback: (msg: RemoteSidecarMessage) => void,
): Promise<UnlistenFn> {
return listen<RemoteSidecarMessage>('remote-sidecar-message', (event) => {
callback(event.payload);
});
}
export async function onRemotePtyData(
callback: (msg: RemotePtyData) => void,
): Promise<UnlistenFn> {
return listen<RemotePtyData>('remote-pty-data', (event) => {
callback(event.payload);
});
}
export async function onRemotePtyExit(
callback: (msg: RemotePtyExit) => void,
): Promise<UnlistenFn> {
return listen<RemotePtyExit>('remote-pty-exit', (event) => {
callback(event.payload);
});
}
export async function onRemoteMachineReady(
callback: (msg: RemoteMachineEvent) => void,
): Promise<UnlistenFn> {
return listen<RemoteMachineEvent>('remote-machine-ready', (event) => {
callback(event.payload);
});
}
export async function onRemoteMachineDisconnected(
callback: (msg: RemoteMachineEvent) => void,
): Promise<UnlistenFn> {
return listen<RemoteMachineEvent>('remote-machine-disconnected', (event) => {
callback(event.payload);
});
}
export async function onRemoteStateSync(
callback: (msg: RemoteMachineEvent) => void,
): Promise<UnlistenFn> {
return listen<RemoteMachineEvent>('remote-state-sync', (event) => {
callback(event.payload);
});
}
export async function onRemoteError(
callback: (msg: RemoteMachineEvent) => void,
): Promise<UnlistenFn> {
return listen<RemoteMachineEvent>('remote-error', (event) => {
callback(event.payload);
});
}

View file

@ -4,6 +4,14 @@
import { notify } from '../../stores/notifications.svelte';
import { getCurrentFlavor, setFlavor } from '../../stores/theme.svelte';
import { ALL_FLAVORS, FLAVOR_LABELS, type CatppuccinFlavor } from '../../styles/themes';
import {
getMachines,
addMachine,
removeMachine,
connectMachine,
disconnectMachine,
loadMachines,
} from '../../stores/machines.svelte';
interface Props {
open: boolean;
@ -17,12 +25,21 @@
let maxPanes = $state('4');
let themeFlavor = $state<CatppuccinFlavor>('mocha');
// Machine form state
let newMachineLabel = $state('');
let newMachineUrl = $state('');
let newMachineToken = $state('');
let newMachineAutoConnect = $state(false);
let remoteMachines = $derived(getMachines());
onMount(async () => {
try {
defaultShell = (await getSetting('default_shell')) ?? '';
defaultCwd = (await getSetting('default_cwd')) ?? '';
maxPanes = (await getSetting('max_panes')) ?? '4';
themeFlavor = getCurrentFlavor();
await loadMachines();
} catch {
// Use defaults
}
@ -41,6 +58,49 @@
}
}
async function handleAddMachine() {
if (!newMachineLabel || !newMachineUrl || !newMachineToken) {
notify('error', 'Label, URL, and token are required');
return;
}
try {
await addMachine({
label: newMachineLabel,
url: newMachineUrl,
token: newMachineToken,
auto_connect: newMachineAutoConnect,
});
newMachineLabel = '';
newMachineUrl = '';
newMachineToken = '';
newMachineAutoConnect = false;
notify('success', 'Machine added');
} catch (e) {
notify('error', `Failed to add machine: ${e}`);
}
}
async function handleRemoveMachine(id: string) {
try {
await removeMachine(id);
notify('success', 'Machine removed');
} catch (e) {
notify('error', `Failed to remove machine: ${e}`);
}
}
async function handleToggleConnection(id: string, status: string) {
try {
if (status === 'connected') {
await disconnectMachine(id);
} else {
await connectMachine(id);
}
} catch (e) {
notify('error', `Connection error: ${e}`);
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
@ -81,6 +141,58 @@
</select>
<span class="field-hint">Catppuccin color scheme. New terminals use the updated theme.</span>
</label>
<div class="section-divider"></div>
<h3 class="section-title">Remote Machines</h3>
{#if remoteMachines.length > 0}
<div class="machine-list">
{#each remoteMachines as machine (machine.id)}
<div class="machine-item">
<div class="machine-info">
<span class="machine-label">{machine.label}</span>
<span class="machine-url">{machine.url}</span>
<span class="machine-status" class:connected={machine.status === 'connected'} class:error={machine.status === 'error'}>
{machine.status}
</span>
</div>
<div class="machine-actions">
<button
class="machine-btn"
onclick={() => handleToggleConnection(machine.id, machine.status)}
>
{machine.status === 'connected' ? 'Disconnect' : 'Connect'}
</button>
<button class="machine-btn machine-btn-danger" onclick={() => handleRemoveMachine(machine.id)}>
&times;
</button>
</div>
</div>
{/each}
</div>
{:else}
<p class="field-hint">No remote machines configured.</p>
{/if}
<div class="add-machine-form">
<label class="field">
<span class="field-label">Label</span>
<input type="text" bind:value={newMachineLabel} placeholder="devbox" />
</label>
<label class="field">
<span class="field-label">URL</span>
<input type="text" bind:value={newMachineUrl} placeholder="wss://host:9750" />
</label>
<label class="field">
<span class="field-label">Token</span>
<input type="password" bind:value={newMachineToken} placeholder="auth token" />
</label>
<label class="field-checkbox">
<input type="checkbox" bind:checked={newMachineAutoConnect} />
<span>Auto-connect on startup</span>
</label>
<button class="btn-save" onclick={handleAddMachine}>Add Machine</button>
</div>
</div>
<div class="dialog-footer">
<button class="btn-cancel" onclick={onClose}>Cancel</button>
@ -210,4 +322,112 @@
}
.btn-save:hover { opacity: 0.9; }
.section-divider {
height: 1px;
background: var(--border);
margin: 4px 0;
}
.section-title {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.machine-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.machine-item {
display: flex;
align-items: center;
justify-content: space-between;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--border-radius);
padding: 6px 8px;
}
.machine-info {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
}
.machine-label {
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
}
.machine-url {
font-size: 10px;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.machine-status {
font-size: 10px;
color: var(--ctp-overlay1);
}
.machine-status.connected {
color: var(--ctp-green);
}
.machine-status.error {
color: var(--ctp-red);
}
.machine-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.machine-btn {
background: var(--bg-surface);
border: 1px solid var(--border);
color: var(--text-secondary);
font-size: 10px;
padding: 2px 6px;
border-radius: 3px;
cursor: pointer;
}
.machine-btn:hover {
color: var(--text-primary);
border-color: var(--accent);
}
.machine-btn-danger:hover {
color: var(--ctp-red);
border-color: var(--ctp-red);
}
.add-machine-form {
display: flex;
flex-direction: column;
gap: 8px;
padding-top: 4px;
}
.field-checkbox {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-secondary);
}
.field-checkbox input[type="checkbox"] {
accent-color: var(--accent);
}
</style>

View file

@ -10,15 +10,29 @@
type LayoutPreset,
type Pane,
} from '../../stores/layout.svelte';
import { getMachines } from '../../stores/machines.svelte';
import SshSessionList from '../SSH/SshSessionList.svelte';
let panes = $derived(getPanes());
let preset = $derived(getActivePreset());
let machines = $derived(getMachines());
// Build machine label lookup
let machineLabels = $derived.by(() => {
const map = new Map<string, string>();
for (const m of machines) {
map.set(m.id, `${m.label} (${m.status})`);
}
return map;
});
let grouped = $derived.by(() => {
const groups = new Map<string, Pane[]>();
for (const pane of panes) {
const g = pane.group || '';
// Remote panes auto-group by machine label; local panes use explicit group
const g = pane.remoteMachineId
? machineLabels.get(pane.remoteMachineId) ?? `Remote ${pane.remoteMachineId.slice(0, 8)}`
: (pane.group || '');
if (!groups.has(g)) groups.set(g, []);
groups.get(g)!.push(pane);
}

View file

@ -23,6 +23,7 @@ export interface Pane {
args?: string[];
group?: string;
focused: boolean;
remoteMachineId?: string;
}
let panes = $state<Pane[]>([]);

View file

@ -0,0 +1,97 @@
// Remote machines store — tracks connection state for multi-machine support
import {
listRemoteMachines,
addRemoteMachine,
removeRemoteMachine,
connectRemoteMachine,
disconnectRemoteMachine,
onRemoteMachineReady,
onRemoteMachineDisconnected,
onRemoteError,
type RemoteMachineConfig,
type RemoteMachineInfo,
} from '../adapters/remote-bridge';
import { notify } from './notifications.svelte';
export interface Machine extends RemoteMachineInfo {}
let machines = $state<Machine[]>([]);
export function getMachines(): Machine[] {
return machines;
}
export function getMachine(id: string): Machine | undefined {
return machines.find(m => m.id === id);
}
export async function loadMachines(): Promise<void> {
try {
machines = await listRemoteMachines();
} catch (e) {
console.warn('Failed to load remote machines:', e);
}
}
export async function addMachine(config: RemoteMachineConfig): Promise<string> {
const id = await addRemoteMachine(config);
machines.push({
id,
label: config.label,
url: config.url,
status: 'disconnected',
auto_connect: config.auto_connect,
});
return id;
}
export async function removeMachine(id: string): Promise<void> {
await removeRemoteMachine(id);
machines = machines.filter(m => m.id !== id);
}
export async function connectMachine(id: string): Promise<void> {
const machine = machines.find(m => m.id === id);
if (machine) machine.status = 'connecting';
try {
await connectRemoteMachine(id);
if (machine) machine.status = 'connected';
} catch (e) {
if (machine) machine.status = 'error';
throw e;
}
}
export async function disconnectMachine(id: string): Promise<void> {
await disconnectRemoteMachine(id);
const machine = machines.find(m => m.id === id);
if (machine) machine.status = 'disconnected';
}
// Initialize event listeners for machine status updates
export async function initMachineListeners(): Promise<void> {
await onRemoteMachineReady((msg) => {
const machine = machines.find(m => m.id === msg.machineId);
if (machine) {
machine.status = 'connected';
notify('success', `Connected to ${machine.label}`);
}
});
await onRemoteMachineDisconnected((msg) => {
const machine = machines.find(m => m.id === msg.machineId);
if (machine) {
machine.status = 'disconnected';
notify('warning', `Disconnected from ${machine.label}`);
}
});
await onRemoteError((msg) => {
const machine = machines.find(m => m.id === msg.machineId);
if (machine) {
machine.status = 'error';
notify('error', `Error from ${machine.label}: ${msg.error}`);
}
});
}