feat(ssh): rework remote instance setup and fix remote installs

Adding an SSH connection now starts from the hosts in the SSH config
instead of a blank command field, ports and install options sit behind
Advanced settings, and each connection reports one of three states with
the failure text and an action that resolves it.

Remote installs no longer touch the root-owned global npm prefix: npm is
pinned to a prefix under $HOME and bun is resolved at its known location,
because an SSH login shell exposes neither on PATH. The opencode CLI is
resolved the same way and handed to the remote server through
OPENCODE_BINARY, and the server is started and stopped through the
resolved binary rather than PATH — the HTTP shutdown route sits behind UI
authentication and never stopped anything.

A managed remote server can also be published to the remote machine's own
network. That requires a UI password, enforced in the form and again in
the SSH manager.
This commit is contained in:
Bohdan Triapitsyn
2026-08-22 16:45:56 +03:00
parent eed317f18d
commit f7a0d1f0f6
16 changed files with 1234 additions and 302 deletions
@@ -23,7 +23,9 @@ import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLay
import {
SettingsSection,
SettingsGroupTitle,
SettingsChipGroup,
SETTINGS_PAGE_TITLE_CLASS,
SETTINGS_SECTION_TITLE_CLASS,
SETTINGS_FIELD_LABEL_CLASS,
SETTINGS_SELECT_SIZE,
} from '@/components/sections/shared/SettingsSection';
@@ -150,6 +152,58 @@ const isConnectingPhase = (phase?: string): boolean => {
return Boolean(phase && CONNECTING_PHASES.has(phase));
};
// The backend reports 13 lifecycle phases. People only need to know which of
// three situations they are in; the phase stays as the secondary detail line.
type InstanceState = 'idle' | 'connecting' | 'ready' | 'error';
const instanceState = (phase?: string): InstanceState => {
if (phase === 'ready') return 'ready';
if (phase === 'error') return 'error';
if (phase === 'degraded' || isConnectingPhase(phase)) return 'connecting';
return 'idle';
};
const instanceStateLabelKey = (state: InstanceState): I18nKey => {
switch (state) {
case 'ready':
return 'settings.remoteInstances.page.state.ready';
case 'connecting':
return 'settings.remoteInstances.page.state.connecting';
case 'error':
return 'settings.remoteInstances.page.state.problem';
default:
return 'settings.remoteInstances.page.state.notConnected';
}
};
// Known backend failures that the user can act on from here. Everything else
// falls back to the raw detail plus the logs button.
type ErrorRemedy = 'uiPassword' | 'localPort' | 'noRuntime' | 'noOpencode' | 'externalPort' | null;
const errorRemedy = (detail?: string): ErrorRemedy => {
const text = (detail || '').toLowerCase();
if (!text) return null;
if (text.includes('ui authentication') || text.includes('ui password')) return 'uiPassword';
if (text.includes('already in use') || text.includes('eaddrinuse')) return 'localPort';
if (text.includes('neither bun nor npm')) return 'noRuntime';
if (text.includes('opencode cli is not installed')) return 'noOpencode';
if (text.includes('requires a ui password')) return 'uiPassword';
if (text.includes('preferred remote openchamber port')) return 'externalPort';
return null;
};
// Remedies the user resolves on the remote machine: explain, do not offer a button.
const REMEDY_HINT_KEYS = {
noRuntime: 'settings.remoteInstances.page.error.hint.noRuntime',
noOpencode: 'settings.remoteInstances.page.error.hint.noOpencode',
} satisfies Record<string, I18nKey>;
const remedyHintKey = (remedy: ErrorRemedy): I18nKey | null => {
if (remedy === 'noRuntime') return REMEDY_HINT_KEYS.noRuntime;
if (remedy === 'noOpencode') return REMEDY_HINT_KEYS.noOpencode;
return null;
};
const phaseDotClass = (phase?: string): string => {
if (phase === 'ready') {
return 'bg-[var(--status-success)] animate-pulse';
@@ -462,8 +516,11 @@ export const RemoteInstancesPage: React.FC = () => {
const [transportOptions, setTransportOptions] = React.useState<{ localUrl: string | null; lanUrl: string | null; relayAvailable: boolean } | null>(null);
const revokedClientCount = React.useMemo(() => remoteClients.filter((client) => Boolean(client.revokedAt)).length, [remoteClients]);
const [sshAddDialogOpen, setSshAddDialogOpen] = React.useState(false);
const [sshCommandDraft, setSshCommandDraft] = React.useState('ssh user@example.com');
const [sshAddMode, setSshAddMode] = React.useState<'saved' | 'manual'>('saved');
const [sshHostSearch, setSshHostSearch] = React.useState('');
const [sshCommandDraft, setSshCommandDraft] = React.useState('');
const [sshNameDraft, setSshNameDraft] = React.useState('');
const [advancedOpen, setAdvancedOpen] = React.useState(false);
React.useEffect(() => {
void load();
@@ -730,7 +787,7 @@ export const RemoteInstancesPage: React.FC = () => {
await createFromCommand(id, command, sshNameDraft.trim() || t('settings.remoteInstances.sidebar.newSshInstanceName'));
setSelectedId(id);
setSshAddDialogOpen(false);
setSshCommandDraft('ssh user@example.com');
setSshCommandDraft('');
setSshNameDraft('');
toast.success(t('settings.remoteInstances.page.toast.instanceCreated'));
} catch (error) {
@@ -740,6 +797,12 @@ export const RemoteInstancesPage: React.FC = () => {
}
}, [createFromCommand, setSelectedId, sshCommandDraft, sshNameDraft, t]);
const openSshAddDialog = React.useCallback(() => {
setSshHostSearch('');
setSshAddMode(importCandidates.length > 0 ? 'saved' : 'manual');
setSshAddDialogOpen(true);
}, [importCandidates.length]);
const setDefaultDirectHost = React.useCallback(async (id: string) => {
await persistDirectHosts(directHosts, id);
}, [directHosts, persistDirectHosts]);
@@ -1000,6 +1063,11 @@ export const RemoteInstancesPage: React.FC = () => {
setDraft(selectedInstance);
}, [selectedInstance]);
// Every instance opens on the simple view; advanced stays a deliberate choice.
React.useEffect(() => {
setAdvancedOpen(false);
}, [selectedId]);
React.useEffect(() => {
if (!selectedId) {
return;
@@ -1064,6 +1132,9 @@ export const RemoteInstancesPage: React.FC = () => {
const canDisconnect = isReady || isBusy;
const statusAgeMs = status ? Math.max(0, clockMs - status.updatedAtMs) : 0;
const reconnectAppearsStuck = isReconnecting && statusAgeMs > 12_000;
const currentState = instanceState(statusPhase);
const currentRemedy = currentState === 'error' ? errorRemedy(status?.detail) : null;
const currentRemedyHintKey = remedyHintKey(currentRemedy);
const hasChanges = React.useMemo(() => {
if (!draft || !selectedInstance) return false;
@@ -1083,6 +1154,25 @@ export const RemoteInstancesPage: React.FC = () => {
return;
}
// "Already running" cannot pick a port on its own; catching it here keeps
// the failure in the form instead of surfacing it mid-connect.
if (normalized.remoteOpenchamber.mode === 'external' && !normalized.remoteOpenchamber.preferredPort) {
toast.error(t('settings.remoteInstances.page.validation.externalPortRequired'));
setAdvancedOpen(true);
return;
}
if (
normalized.remoteOpenchamber.mode === 'managed' &&
normalized.remoteOpenchamber.bindHost === '0.0.0.0' &&
!normalized.auth.openchamberPassword?.value?.trim()
) {
toast.error(t('settings.remoteInstances.page.validation.remoteLanNeedsPassword'));
setAdvancedOpen(true);
window.setTimeout(() => uiPasswordRef.current?.focus(), 0);
return;
}
if (normalized.localForward.bindHost === '0.0.0.0') {
const allow = window.confirm(
t('settings.remoteInstances.page.confirm.bindAllInterfaces'),
@@ -1154,6 +1244,7 @@ export const RemoteInstancesPage: React.FC = () => {
const handleImportCandidate = React.useCallback(
(host: string, pattern: boolean) => {
setSshAddDialogOpen(false);
if (pattern) {
setPatternHost(host);
setPatternDestination(suggestConcreteHost(host));
@@ -1164,6 +1255,25 @@ export const RemoteInstancesPage: React.FC = () => {
[createImportedInstance],
);
const filteredImportCandidates = React.useMemo(() => {
const query = sshHostSearch.trim().toLowerCase();
if (!query) return importCandidates;
return importCandidates.filter((candidate) => {
return candidate.host.toLowerCase().includes(query) || candidate.sshCommand.toLowerCase().includes(query);
});
}, [importCandidates, sshHostSearch]);
// Opening a ready instance means pointing this window at the forwarded local
// URL — the same navigation the host switcher performs after its own connect.
const openInstanceUrl = React.useCallback((localUrl?: string) => {
const target = (localUrl || '').trim();
if (!target) {
toast.error(t('settings.remoteInstances.page.toast.instanceUrlUnavailable'));
return;
}
navigateToUrl(target);
}, [t]);
const handlePatternCreate = React.useCallback(async () => {
const host = patternHost;
const destination = patternDestination.trim();
@@ -1216,6 +1326,44 @@ export const RemoteInstancesPage: React.FC = () => {
}
}, [connect, selectedInstance, t, upsertInstance]);
const uiPasswordRef = React.useRef<HTMLInputElement | null>(null);
const remotePortRef = React.useRef<HTMLDivElement | null>(null);
// Turn a reported failure into the one action that resolves it, instead of
// leaving the raw backend sentence as the whole answer.
const applyErrorRemedy = React.useCallback(async (remedy: ErrorRemedy) => {
if (!selectedInstance) return;
if (remedy === 'localPort') {
const nextInstance: DesktopSshInstance = {
...selectedInstance,
localForward: {
...selectedInstance.localForward,
preferredLocalPort: randomPort(),
},
};
try {
await upsertInstance(nextInstance);
await connect(nextInstance.id);
toast.success(t('settings.remoteInstances.sidebar.toast.retriedWithRandomPort'));
} catch (error) {
toast.error(t('settings.remoteInstances.page.toast.connectFailed'), {
description: error instanceof Error ? error.message : String(error),
});
}
return;
}
setAdvancedOpen(true);
window.setTimeout(() => {
if (remedy === 'uiPassword') {
uiPasswordRef.current?.focus();
return;
}
remotePortRef.current?.scrollIntoView({ block: 'center' });
}, 0);
}, [connect, selectedInstance, t, upsertInstance]);
const readLogsForInstance = React.useCallback(async (id: string) => {
const lines = await desktopSshLogs(id, 600);
return lines.map((line) => formatLogLine(line));
@@ -1321,6 +1469,24 @@ export const RemoteInstancesPage: React.FC = () => {
return;
}
if (!canDisconnect && draft.remoteOpenchamber.mode === 'external' && !draft.remoteOpenchamber.preferredPort) {
toast.error(t('settings.remoteInstances.page.validation.externalPortRequired'));
setAdvancedOpen(true);
return;
}
if (
!canDisconnect &&
draft.remoteOpenchamber.mode === 'managed' &&
draft.remoteOpenchamber.bindHost === '0.0.0.0' &&
!draft.auth.openchamberPassword?.value?.trim()
) {
toast.error(t('settings.remoteInstances.page.validation.remoteLanNeedsPassword'));
setAdvancedOpen(true);
window.setTimeout(() => uiPasswordRef.current?.focus(), 0);
return;
}
setIsPrimaryActionPending(true);
const operation = canDisconnect ? disconnect(draft.id) : connectWithPortRecovery();
void operation
@@ -1774,7 +1940,7 @@ export const RemoteInstancesPage: React.FC = () => {
title={t('settings.remoteInstances.sidebar.title')}
description={t('settings.remoteInstances.sidebar.total', { count: instances.length })}
headerAction={(
<Button type="button" size="xs" className="!font-normal" onClick={() => setSshAddDialogOpen(true)}>
<Button type="button" size="xs" className="!font-normal" onClick={openSshAddDialog}>
<Icon name="add" className="h-3.5 w-3.5" />
{t('settings.remoteInstances.sidebar.actions.addSshInstance')}
</Button>
@@ -1782,50 +1948,71 @@ export const RemoteInstancesPage: React.FC = () => {
contentClassName="space-y-2.5"
>
{isLoading ? (
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.import.loading')}</p>
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.state.loadingInstances')}</p>
) : instances.length === 0 ? (
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.import.noneFound')}</p>
<p className="typography-meta text-muted-foreground">
{importCandidates.length === 1
? t('settings.remoteInstances.page.empty.noInstancesWithOneImport')
: importCandidates.length > 1
? t('settings.remoteInstances.page.empty.noInstancesWithImports', { count: importCandidates.length })
: t('settings.remoteInstances.page.empty.noInstances')}
</p>
) : instances.map((instance) => {
const instanceStatus = statusesById[instance.id];
const title = instance.nickname?.trim() || instance.sshParsed?.destination || instance.id;
const phase = instanceStatus?.phase;
const ready = phase === 'ready';
const state = instanceState(phase);
const failureDetail = state === 'error' ? instanceStatus?.detail : undefined;
return (
<div key={instance.id} className="flex items-center justify-between gap-3 py-1.5">
<div className="min-w-0 space-y-0.5">
<div className="flex min-w-0 items-center gap-2">
<span className={`h-2 w-2 rounded-full ${phaseDotClass(phase)}`} />
<p className="typography-ui-label text-foreground truncate">{title}</p>
<div key={instance.id} className="space-y-1.5 py-1.5">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 space-y-0.5">
<div className="flex min-w-0 items-center gap-2">
<span className={`h-2 w-2 rounded-full ${phaseDotClass(phase)}`} />
<p className="typography-ui-label text-foreground truncate">{title}</p>
</div>
<p className="typography-micro text-muted-foreground truncate">
{t(instanceStateLabelKey(state))}
{state === 'connecting' ? ` · ${t(phaseLabelKey(phase))}` : ''}
{ready && instanceStatus?.localUrl ? ` · ${instanceStatus.localUrl}` : ''}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
{ready ? (
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => openInstanceUrl(instanceStatus?.localUrl)}>
<Icon name="external-link" className="h-3.5 w-3.5" />
{t('settings.remoteInstances.page.actions.open')}
</Button>
) : null}
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => {
const op = ready ? disconnect(instance.id) : connect(instance.id);
void op.catch((err) => toast.error(ready ? t('settings.remoteInstances.sidebar.toast.disconnectFailed') : t('settings.remoteInstances.sidebar.toast.connectFailed'), {
description: err instanceof Error ? err.message : String(err),
}));
}}>
{ready ? <Icon name="stop" className="h-3.5 w-3.5" /> : <Icon name="plug-2" className="h-3.5 w-3.5" />}
{ready ? t('settings.remoteInstances.sidebar.actions.disconnect') : t('settings.remoteInstances.sidebar.actions.connect')}
</Button>
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => setSelectedId(instance.id)}>
<Icon name="pencil" className="h-3.5 w-3.5" />
{t('desktopHostSwitcher.actions.edit')}
</Button>
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => {
const ok = window.confirm(t('settings.remoteInstances.page.confirm.removeInstance'));
if (!ok) return;
void removeInstance(instance.id).catch((err) => toast.error(t('settings.remoteInstances.page.toast.removeInstanceFailed'), {
description: err instanceof Error ? err.message : String(err),
}));
}}>
<Icon name="delete-bin" className="h-3.5 w-3.5" />
{t('settings.common.actions.delete')}
</Button>
</div>
<p className="typography-micro text-muted-foreground truncate">
{t(phaseLabelKey(phase))}{instanceStatus?.localUrl ? ` · ${instanceStatus.localUrl}` : ''}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => {
const op = ready ? disconnect(instance.id) : connect(instance.id);
void op.catch((err) => toast.error(ready ? t('settings.remoteInstances.sidebar.toast.disconnectFailed') : t('settings.remoteInstances.sidebar.toast.connectFailed'), {
description: err instanceof Error ? err.message : String(err),
}));
}}>
{ready ? <Icon name="stop" className="h-3.5 w-3.5" /> : <Icon name="plug-2" className="h-3.5 w-3.5" />}
{ready ? t('settings.remoteInstances.sidebar.actions.disconnect') : t('settings.remoteInstances.sidebar.actions.connect')}
</Button>
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => setSelectedId(instance.id)}>
<Icon name="pencil" className="h-3.5 w-3.5" />
{t('desktopHostSwitcher.actions.edit')}
</Button>
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => {
const ok = window.confirm(t('settings.remoteInstances.page.confirm.removeInstance'));
if (!ok) return;
void removeInstance(instance.id).catch((err) => toast.error(t('settings.remoteInstances.page.toast.removeInstanceFailed'), {
description: err instanceof Error ? err.message : String(err),
}));
}}>
<Icon name="delete-bin" className="h-3.5 w-3.5" />
{t('settings.common.actions.delete')}
</Button>
</div>
{failureDetail ? (
<p className="typography-micro text-[var(--status-error)] break-words">{failureDetail}</p>
) : null}
</div>
);
})}
@@ -1835,52 +2022,70 @@ export const RemoteInstancesPage: React.FC = () => {
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{t('settings.remoteInstances.sidebar.actions.addSshInstance')}</DialogTitle>
<DialogDescription>{t('settings.remoteInstances.page.section.instanceDescription')}</DialogDescription>
<DialogDescription>{t('settings.remoteInstances.page.addDialog.description')}</DialogDescription>
</DialogHeader>
<form className="space-y-3" onSubmit={(event) => { event.preventDefault(); void createSshInstanceFromDialog(); }}>
<Input className="h-8" value={sshNameDraft} onChange={(event) => setSshNameDraft(event.target.value)} placeholder={t('settings.remoteInstances.page.field.nicknamePlaceholder')} disabled={isSaving} />
<Input className="h-8" value={sshCommandDraft} onChange={(event) => setSshCommandDraft(event.target.value)} placeholder={t('settings.remoteInstances.page.field.sshCommandPlaceholder')} disabled={isSaving} autoFocus />
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={() => setSshAddDialogOpen(false)} disabled={isSaving}>{t('settings.common.actions.cancel')}</Button>
<Button type="submit" size="xs" className="!font-normal" disabled={isSaving || !sshCommandDraft.trim()}>{t('settings.common.actions.create')}</Button>
<SettingsChipGroup
value={sshAddMode}
onChange={setSshAddMode}
aria-label={t('settings.remoteInstances.page.addDialog.sourceLabel')}
options={[
{ value: 'saved', label: t('settings.remoteInstances.page.addDialog.tab.saved') },
{ value: 'manual', label: t('settings.remoteInstances.page.addDialog.tab.manual') },
]}
/>
{sshAddMode === 'saved' ? (
<div className="space-y-2">
<Input
className="h-8"
value={sshHostSearch}
onChange={(event) => setSshHostSearch(event.target.value)}
placeholder={t('settings.remoteInstances.page.addDialog.searchPlaceholder')}
autoFocus
/>
{isImportsLoading ? (
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.import.loading')}</p>
) : importCandidates.length === 0 ? (
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.addDialog.emptySaved')}</p>
) : filteredImportCandidates.length === 0 ? (
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.addDialog.searchEmpty')}</p>
) : (
<div className="max-h-[45vh] overflow-auto">
{filteredImportCandidates.map((candidate) => (
<div key={`${candidate.source}:${candidate.host}`} className="flex items-center justify-between gap-3 border-b border-[var(--surface-subtle)] py-2.5 last:border-b-0">
<div className="min-w-0">
<div className="typography-ui-label font-medium text-foreground truncate">
{candidate.host}
{candidate.pattern ? ` ${t('settings.remoteInstances.page.import.patternSuffix')}` : ''}
</div>
<div className="typography-meta text-muted-foreground truncate">{candidate.sshCommand}</div>
</div>
<Button
type="button"
variant="outline"
size="xs"
className="!font-normal"
onClick={() => void handleImportCandidate(candidate.host, candidate.pattern)}
>
{t('settings.remoteInstances.page.addDialog.use')}
</Button>
</div>
))}
</div>
)}
</div>
</form>
) : (
<form className="space-y-3" onSubmit={(event) => { event.preventDefault(); void createSshInstanceFromDialog(); }}>
<Input className="h-8" value={sshNameDraft} onChange={(event) => setSshNameDraft(event.target.value)} placeholder={t('settings.remoteInstances.page.field.nicknamePlaceholder')} disabled={isSaving} />
<Input className="h-8" value={sshCommandDraft} onChange={(event) => setSshCommandDraft(event.target.value)} placeholder={t('settings.remoteInstances.page.field.sshCommandPlaceholder')} disabled={isSaving} autoFocus />
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={() => setSshAddDialogOpen(false)} disabled={isSaving}>{t('settings.common.actions.cancel')}</Button>
<Button type="submit" size="xs" className="!font-normal" disabled={isSaving || !sshCommandDraft.trim()}>{t('settings.common.actions.create')}</Button>
</div>
</form>
)}
</DialogContent>
</Dialog> : null}
{showInstanceManagement ? <SettingsSection
title={t('settings.remoteInstances.page.import.sectionTitle')}
>
{isImportsLoading ? (
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.import.loading')}</p>
) : importCandidates.length === 0 ? (
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.import.noneFound')}</p>
) : (
<div>
{importCandidates.map((candidate) => (
<div key={`${candidate.source}:${candidate.host}`} className="flex items-center justify-between gap-3 border-b border-[var(--surface-subtle)] py-3 last:border-b-0">
<div className="min-w-0">
<div className="typography-ui-label font-medium text-foreground truncate">
{candidate.host}
{candidate.pattern ? ` ${t('settings.remoteInstances.page.import.patternSuffix')}` : ''}
</div>
<div className="typography-meta text-muted-foreground truncate">{candidate.sshCommand}</div>
</div>
<Button
type="button"
variant="outline"
size="xs"
className="!font-normal"
onClick={() => void handleImportCandidate(candidate.host, candidate.pattern)}
>
{t('settings.common.actions.import')}
</Button>
</div>
))}
</div>
)}
</SettingsSection> : null}
<Dialog
open={Boolean(patternHost)}
onOpenChange={(open) => {
@@ -1925,6 +2130,10 @@ export const RemoteInstancesPage: React.FC = () => {
}
const isManagedMode = draft.remoteOpenchamber.mode === 'managed';
// Publishing the remote server to its network turns the UI password from an
// option into the only thing standing in front of it.
const remoteLanExposed = isManagedMode && draft.remoteOpenchamber.bindHost === '0.0.0.0';
const uiPasswordMissing = remoteLanExposed && !draft.auth.openchamberPassword?.value?.trim();
const instanceTitle = draft.nickname?.trim() || draft.sshParsed?.destination || draft.id;
return (
@@ -1934,7 +2143,8 @@ export const RemoteInstancesPage: React.FC = () => {
<h1 className={`${SETTINGS_PAGE_TITLE_CLASS} truncate`}>{instanceTitle}</h1>
<div className="mt-1 flex flex-wrap items-center gap-2 typography-meta text-muted-foreground">
<span className={`h-2.5 w-2.5 rounded-full ${phaseDotClass(statusPhase)}`} />
<span>{t(phaseLabelKey(statusPhase))}</span>
<span className="text-foreground">{t(instanceStateLabelKey(currentState))}</span>
{currentState === 'connecting' ? <span>{t(phaseLabelKey(statusPhase))}</span> : null}
{status?.localUrl ? <span className="font-mono text-foreground/80">{status.localUrl}</span> : null}
{reconnectAppearsStuck ? <span>{t('settings.remoteInstances.page.status.reconnectStale')}</span> : null}
</div>
@@ -2005,6 +2215,29 @@ export const RemoteInstancesPage: React.FC = () => {
{t('settings.remoteInstances.sidebar.actions.remove')}
</Button>
</div>
{currentState === 'error' && status?.detail ? (
<div className="space-y-2 rounded-md border border-[var(--status-error)]/30 bg-[var(--status-error-background)] p-3">
<p className="typography-meta text-[var(--status-error)] break-words">{status.detail}</p>
{currentRemedyHintKey ? (
<p className="typography-micro text-muted-foreground">{t(currentRemedyHintKey)}</p>
) : null}
{currentRemedy && !currentRemedyHintKey ? (
<Button
type="button"
variant="outline"
size="xs"
className="!font-normal"
onClick={() => void applyErrorRemedy(currentRemedy)}
>
{currentRemedy === 'uiPassword'
? t('settings.remoteInstances.page.error.action.setUiPassword')
: currentRemedy === 'localPort'
? t('settings.remoteInstances.page.error.action.pickRandomPort')
: t('settings.remoteInstances.page.error.action.setRemotePort')}
</Button>
) : null}
</div>
) : null}
{status?.localUrl ? (
<div className="flex flex-wrap items-center gap-2 typography-meta text-muted-foreground">
<span>{t('settings.remoteInstances.page.status.currentLocalUrl')}</span>
@@ -2046,30 +2279,6 @@ export const RemoteInstancesPage: React.FC = () => {
placeholder={t('settings.remoteInstances.page.field.nicknamePlaceholder')}
/>
</div>
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
<span className="typography-ui-label text-foreground w-56 shrink-0">{t('settings.remoteInstances.page.field.connectionTimeoutSeconds')}</span>
<NumberInput
containerClassName="w-fit"
min={5}
max={240}
step={1}
className="w-16 tabular-nums"
value={draft.connectionTimeoutSec}
onValueChange={(next) => {
updateDraft((current) => ({
...current,
connectionTimeoutSec: Number.isFinite(next) ? next : current.connectionTimeoutSec,
}));
}}
/>
</div>
</SettingsSection>
<SettingsSection
title={t('settings.remoteInstances.page.section.remoteServer')}
info={t('settings.remoteInstances.page.section.remoteServerDescription')}
contentClassName="space-y-3"
>
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
<div className="w-56 shrink-0">
<HintLabel
@@ -2099,8 +2308,40 @@ export const RemoteInstancesPage: React.FC = () => {
</Select>
</div>
</SettingsSection>
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger className="mt-6 w-auto justify-start gap-1.5">
<span className={SETTINGS_SECTION_TITLE_CLASS}>{t('settings.remoteInstances.page.section.advanced')}</span>
<Icon name={advancedOpen ? 'arrow-up-s' : 'arrow-down-s'} className="h-4 w-4 text-muted-foreground" />
</CollapsibleTrigger>
<CollapsibleContent>
<p className="px-2 pb-2 typography-micro text-muted-foreground">{t('settings.remoteInstances.page.section.advancedHint')}</p>
<div className="flex flex-col gap-1.5 px-2 py-1.5 md:flex-row md:items-center md:gap-8">
<span className="typography-ui-label text-foreground w-56 shrink-0">{t('settings.remoteInstances.page.field.connectionTimeoutSeconds')}</span>
<NumberInput
containerClassName="w-fit"
min={5}
max={240}
step={1}
className="w-16 tabular-nums"
value={draft.connectionTimeoutSec}
onValueChange={(next) => {
updateDraft((current) => ({
...current,
connectionTimeoutSec: Number.isFinite(next) ? next : current.connectionTimeoutSec,
}));
}}
/>
</div>
<SettingsSection
title={t('settings.remoteInstances.page.section.remoteServer')}
info={t('settings.remoteInstances.page.section.remoteServerDescription')}
contentClassName="space-y-3"
>
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
<div className="w-56 shrink-0">
<div className="w-56 shrink-0" ref={remotePortRef}>
<HintLabel
label={t('settings.remoteInstances.page.field.preferredRemotePort')}
hint={t('settings.remoteInstances.page.field.preferredRemotePortHint')}
@@ -2150,10 +2391,7 @@ export const RemoteInstancesPage: React.FC = () => {
...current,
remoteOpenchamber: {
...current.remoteOpenchamber,
installMethod:
value === 'npm' || value === 'download_release' || value === 'upload_bundle'
? value
: 'bun',
installMethod: value === 'npm' || value === 'bun' ? value : 'auto',
},
}))
}
@@ -2162,15 +2400,45 @@ export const RemoteInstancesPage: React.FC = () => {
<SelectValue placeholder={t('settings.remoteInstances.page.field.selectInstallMethodPlaceholder')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">{t('settings.remoteInstances.page.field.installMethodAuto')}</SelectItem>
<SelectItem value="bun">bun</SelectItem>
<SelectItem value="npm">npm</SelectItem>
<SelectItem value="download_release">{t('settings.remoteInstances.page.field.installMethodDownloadRelease')}</SelectItem>
<SelectItem value="upload_bundle">{t('settings.remoteInstances.page.field.installMethodUploadBundle')}</SelectItem>
</SelectContent>
</Select>
</div>
) : null}
{isManagedMode ? (
<div className="py-1.5">
<div className="flex flex-col gap-1.5 md:flex-row md:items-center md:gap-8">
<div className="w-56 shrink-0">
<HintLabel
label={t('settings.remoteInstances.page.field.remoteLanAccess')}
hint={t('settings.remoteInstances.page.field.remoteLanAccessHint')}
/>
</div>
<Switch
checked={remoteLanExposed}
onCheckedChange={(checked) =>
updateDraft((current) => ({
...current,
remoteOpenchamber: {
...current.remoteOpenchamber,
bindHost: checked ? '0.0.0.0' : '127.0.0.1',
},
}))
}
aria-label={t('settings.remoteInstances.page.field.remoteLanAccess')}
/>
</div>
{remoteLanExposed ? (
<p className="mt-2 typography-micro text-[var(--status-warning)] md:pl-[16rem]">
{t('settings.remoteInstances.page.field.remoteLanAccessWarning')}
</p>
) : null}
</div>
) : null}
{isManagedMode ? (
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
<div className="w-56 shrink-0">
@@ -2227,13 +2495,13 @@ export const RemoteInstancesPage: React.FC = () => {
}));
}}
>
<SelectTrigger size={SETTINGS_SELECT_SIZE} className="w-fit min-w-[140px]">
<SelectTrigger size={SETTINGS_SELECT_SIZE} className="w-fit min-w-[240px]">
<SelectValue placeholder={t('settings.remoteInstances.page.field.selectBindHostPlaceholder')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="127.0.0.1">127.0.0.1</SelectItem>
<SelectItem value="localhost">localhost</SelectItem>
<SelectItem value="0.0.0.0">0.0.0.0</SelectItem>
<SelectItem value="127.0.0.1">{t('settings.remoteInstances.page.field.bindHostOption.loopback')}</SelectItem>
<SelectItem value="localhost">{t('settings.remoteInstances.page.field.bindHostOption.localhost')}</SelectItem>
<SelectItem value="0.0.0.0">{t('settings.remoteInstances.page.field.bindHostOption.lan')}</SelectItem>
</SelectContent>
</Select>
</div>
@@ -2293,6 +2561,13 @@ export const RemoteInstancesPage: React.FC = () => {
</Button>
</div>
</div>
<div className="space-y-1 pt-1">
<p className="typography-micro text-muted-foreground">{t('settings.remoteInstances.page.tunnelPreview.caption')}</p>
<p className="typography-micro font-mono text-foreground/80 break-all">
{`${draft.localForward.bindHost}:${draft.localForward.preferredLocalPort || 'auto'}${draft.sshParsed?.destination || draft.nickname || 'remote'}:${draft.remoteOpenchamber.preferredPort || 'auto'}`}
</p>
</div>
</SettingsSection>
<SettingsSection
@@ -2301,7 +2576,12 @@ export const RemoteInstancesPage: React.FC = () => {
contentClassName="space-y-3"
>
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
<span className="typography-ui-label text-foreground w-56 shrink-0">{t('settings.remoteInstances.page.field.sshPasswordOptional')}</span>
<div className="w-56 shrink-0">
<HintLabel
label={t('settings.remoteInstances.page.field.sshPasswordOptional')}
hint={t('settings.remoteInstances.page.field.sshPasswordHint')}
/>
</div>
<Input
className="h-7 md:max-w-sm"
type="password"
@@ -2324,10 +2604,21 @@ export const RemoteInstancesPage: React.FC = () => {
</div>
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
<span className="typography-ui-label text-foreground w-56 shrink-0">{t('settings.remoteInstances.page.field.uiPasswordOptional')}</span>
<div className="w-56 shrink-0">
<HintLabel
label={remoteLanExposed
? t('settings.remoteInstances.page.field.uiPasswordRequired')
: t('settings.remoteInstances.page.field.uiPasswordOptional')}
hint={isManagedMode
? t('settings.remoteInstances.page.field.uiPasswordHintManaged')
: t('settings.remoteInstances.page.field.uiPasswordHintExternal')}
/>
</div>
<Input
className="h-7 md:max-w-sm"
className={cn('h-7 md:max-w-sm', uiPasswordMissing && 'border-[var(--status-error)]')}
type="password"
ref={uiPasswordRef}
aria-invalid={uiPasswordMissing}
value={draft.auth.openchamberPassword?.value || ''}
onChange={(event) =>
updateDraft((current) => ({
@@ -2345,6 +2636,11 @@ export const RemoteInstancesPage: React.FC = () => {
placeholder={t('settings.remoteInstances.page.field.uiPasswordPlaceholder')}
/>
</div>
{uiPasswordMissing ? (
<p className="typography-micro text-[var(--status-error)] md:pl-[16rem]">
{t('settings.remoteInstances.page.field.uiPasswordMissingForLan')}
</p>
) : null}
</SettingsSection>
<SettingsSection
@@ -2621,6 +2917,9 @@ export const RemoteInstancesPage: React.FC = () => {
</Button>
</SettingsSection>
</CollapsibleContent>
</Collapsible>
<div className="mt-8 border-t border-[var(--interactive-border)] pt-3">
<div className="flex items-center gap-2">
<Button type="button" size="xs" className="!font-normal" onClick={() => void handleSave()} disabled={!hasChanges || isSaving}>