feat(FN-2518): add remote TUI settings controls and routing

- Wire remote dashboard route contracts into CLI/TUI state and controller flows
- Add remote settings interaction UX in dashboard and dashboard-tui command paths
- Expand regression coverage for remote TUI behavior in dashboard and app test suites
- Document remote TUI controls and QR behavior in the CLI reference
- Add a changeset for @runfusion/fusion patch release
This commit is contained in:
Fusion
2026-04-26 01:20:32 -07:00
committed by gsxdsm
parent caf538d7d6
commit 984f7965f6
10 changed files with 717 additions and 75 deletions

View File

@@ -33,6 +33,17 @@ function makeInteractiveData(opts: {
settings?: SettingsValues;
models?: ModelItem[];
taskDetail?: TaskDetailData | null;
remote?: Partial<{
getSettings: () => Promise<{ remoteEnabled: boolean; activeProvider: "tailscale" | "cloudflare" | null; tailscaleEnabled: boolean; cloudflareEnabled: boolean; shortLivedEnabled: boolean; shortLivedTtlMs: number }>;
getStatus: () => Promise<{ provider: "tailscale" | "cloudflare" | null; state: "stopped" | "starting" | "running" | "error"; url: string | null; lastError: string | null }>;
activateProvider: (provider: "tailscale" | "cloudflare") => Promise<void>;
startTunnel: () => Promise<void>;
stopTunnel: () => Promise<void>;
regeneratePersistentToken: () => Promise<{ maskedToken?: string; tokenType: "persistent"; expiresAt: null }>;
generateShortLivedToken: (ttlMs: number) => Promise<{ token?: string; tokenType: "short-lived"; expiresAt: string | null }>;
getRemoteUrl: (tokenType: "persistent" | "short-lived", ttlMs?: number) => Promise<{ url: string; tokenType: "persistent" | "short-lived"; expiresAt: string | null }>;
getQrPayload: (tokenType: "persistent" | "short-lived", ttlMs?: number) => Promise<{ url: string; expiresAt: string | null; format: "text" | "image/svg"; data?: string }>;
}>;
} = {}) {
const projects = opts.projects ?? [];
const tasks = opts.tasks ?? [];
@@ -53,6 +64,26 @@ function makeInteractiveData(opts: {
remoteShortLivedTtlMs: 900000,
};
const models = opts.models ?? [];
const remoteDefaults = {
getSettings: async () => ({
remoteEnabled: settings.remoteEnabled,
activeProvider: settings.remoteActiveProvider,
tailscaleEnabled: true,
cloudflareEnabled: true,
shortLivedEnabled: settings.remoteShortLivedEnabled,
shortLivedTtlMs: settings.remoteShortLivedTtlMs,
}),
getStatus: async () => ({ provider: null, state: "stopped" as const, url: null, lastError: null }),
activateProvider: async (_provider: "tailscale" | "cloudflare") => {},
startTunnel: async () => {},
stopTunnel: async () => {},
regeneratePersistentToken: async () => ({ maskedToken: "tok_****", tokenType: "persistent" as const, expiresAt: null }),
generateShortLivedToken: async (_ttlMs: number) => ({ token: "short", tokenType: "short-lived" as const, expiresAt: new Date().toISOString() }),
getRemoteUrl: async (_tokenType: "persistent" | "short-lived", _ttlMs?: number) => ({ url: "https://remote.example.com", tokenType: "persistent" as const, expiresAt: null }),
getQrPayload: async (_tokenType: "persistent" | "short-lived", _ttlMs?: number) => ({ url: "https://remote.example.com", expiresAt: null, format: "image/svg" as const, data: "<svg/>" }),
};
const remote = { ...remoteDefaults, ...(opts.remote ?? {}) };
return {
listProjects: async () => projects,
listTasks: async () => tasks,
@@ -69,16 +100,7 @@ function makeInteractiveData(opts: {
getSettings: async () => settings,
updateSettings: async (_partial: Partial<SettingsValues>) => {},
listModels: () => models,
remote: {
getStatus: async () => ({ provider: null, state: "stopped", url: null, lastError: null }),
activateProvider: async () => {},
start: async () => {},
stop: async () => {},
regeneratePersistentToken: async () => {},
generateShortLivedToken: async () => ({ token: "short", expiresAt: new Date().toISOString(), ttlMs: 60000 }),
fetchUrl: async () => ({ url: "https://remote.example.com", tokenType: "persistent", expiresAt: null }),
fetchQr: async () => ({ url: "https://remote.example.com", expiresAt: null, format: "image/svg", data: "<svg/>" }),
},
remote,
git: {
getStatus: async () => ({
branch: "main",
@@ -365,6 +387,132 @@ describe("Settings view", () => {
expect(frame).toContain("Claude 3.5 Sonnet");
unmount();
});
it("renders the Remote subsection and supports provider/lifecycle actions", async () => {
const controller = newController();
controller.setSystemInfo(makeSystemInfo());
let remoteState: "stopped" | "starting" | "running" | "error" = "running";
const activateProvider = vi.fn(async () => {});
const settings: SettingsValues = {
maxConcurrent: 1,
maxWorktrees: 2,
autoMerge: false,
mergeStrategy: "direct",
pollIntervalMs: 60000,
enginePaused: false,
globalPause: false,
remoteEnabled: true,
remoteActiveProvider: "cloudflare",
remoteShortLivedEnabled: true,
remoteShortLivedTtlMs: 600000,
remoteStatus: { provider: "cloudflare", state: "running", url: "https://remote.example.com", lastError: null },
};
controller.setInteractiveData(makeInteractiveData({
settings,
remote: {
activateProvider,
getStatus: async () => ({ provider: "cloudflare", state: remoteState, url: "https://remote.example.com", lastError: null }),
startTunnel: async () => {
remoteState = "starting";
},
stopTunnel: async () => {
remoteState = "stopped";
},
},
}));
controller.setMode("interactive");
controller.setInteractiveView("settings");
const { lastFrame, stdin, unmount } = render(renderDashboardAppNode(controller));
await waitForFrameContains(lastFrame, "Remote");
expect(lastFrame() ?? "").toContain("cloudflare");
stdin.write("\t");
await new Promise((r) => setTimeout(r, 20));
stdin.write("C");
await new Promise((r) => setTimeout(r, 20));
expect(activateProvider).toHaveBeenCalledWith("cloudflare");
stdin.write("V");
await waitForFrameContains(lastFrame, "Remote tunnel starting");
stdin.write("X");
await waitForFrameContains(lastFrame, "Remote tunnel stopped");
unmount();
});
it("renders short-lived token expiry and QR text payload", async () => {
const controller = newController();
controller.setSystemInfo(makeSystemInfo());
controller.setInteractiveData(makeInteractiveData({
remote: {
getQrPayload: async () => ({
url: "https://remote.example.com?token=text",
expiresAt: null,
format: "text",
data: "ASCII-QR-PAYLOAD",
}),
},
}));
controller.setMode("interactive");
controller.setInteractiveView("settings");
const { lastFrame, stdin, unmount } = render(renderDashboardAppNode(controller));
stdin.write("\t");
await new Promise((r) => setTimeout(r, 20));
stdin.write("L");
await waitForFrameContains(lastFrame, "TTL ms:");
stdin.write("\r");
await waitForFrameContains(lastFrame, "Short-lived expires:");
stdin.write("K");
await waitForFrameContains(lastFrame, "QR text payload: ASCII-QR-PAYLOAD");
unmount();
});
it("keeps global shortcuts inactive during TTL input", async () => {
const controller = newController();
controller.setSystemInfo(makeSystemInfo());
controller.setInteractiveData(makeInteractiveData());
controller.setMode("interactive");
controller.setInteractiveView("settings");
const { lastFrame, stdin, unmount } = render(renderDashboardAppNode(controller));
stdin.write("\t");
await new Promise((r) => setTimeout(r, 20));
stdin.write("L");
await waitForFrameContains(lastFrame, "TTL ms:");
stdin.write("a");
await new Promise((r) => setTimeout(r, 20));
expect(controller.getSnapshot().interactiveView).toBe("settings");
unmount();
});
it("renders SVG QR fallback instruction", async () => {
const controller = newController();
controller.setSystemInfo(makeSystemInfo());
controller.setInteractiveData(makeInteractiveData({
remote: {
getQrPayload: async () => ({
url: "https://remote.example.com?token=svg",
expiresAt: new Date().toISOString(),
format: "image/svg",
data: "<svg/>",
}),
},
}));
controller.setMode("interactive");
controller.setInteractiveView("settings");
const { lastFrame, stdin, unmount } = render(renderDashboardAppNode(controller));
stdin.write("\t");
await new Promise((r) => setTimeout(r, 20));
stdin.write("K");
await waitForFrameContains(lastFrame, "QR SVG returned by server.");
unmount();
});
});
describe("Board view", () => {

View File

@@ -2128,7 +2128,7 @@ const SETTING_DEFS: SettingDef[] = [
{ key: "remoteShortLivedTtlMs", label: "Short-Lived TTL (ms)", type: "number" },
];
function SettingsInteractiveView({ state }: { state: DashboardState }) {
function SettingsInteractiveView({ state, controller }: { state: DashboardState; controller: DashboardTUI }) {
const [selectedIndex, setSelectedIndex] = useState(0);
const [localSettings, setLocalSettings] = useState<SettingsValues | null>(null);
const [models, setModels] = useState<ModelItem[]>([]);
@@ -2137,16 +2137,32 @@ function SettingsInteractiveView({ state }: { state: DashboardState }) {
const [detailFocused, setDetailFocused] = useState(false);
const [remoteUrl, setRemoteUrl] = useState<string | null>(null);
const [remoteTokenMeta, setRemoteTokenMeta] = useState<string | null>(null);
const [remoteQrDisplay, setRemoteQrDisplay] = useState<string | null>(null);
const [remoteQrFallback, setRemoteQrFallback] = useState<string | null>(null);
const [persistentMaskedToken, setPersistentMaskedToken] = useState<string | null>(null);
const [shortLivedExpiresAt, setShortLivedExpiresAt] = useState<string | null>(null);
const [ttlInputMode, setTtlInputMode] = useState(false);
const [ttlInputValue, setTtlInputValue] = useState("900000");
const data = state.interactiveData;
useEffect(() => {
controller.setInteractiveInputLocked(ttlInputMode);
return () => {
controller.setInteractiveInputLocked(false);
};
}, [controller, ttlInputMode]);
useEffect(() => {
if (!data) return;
data.getSettings().then(async (settings) => {
if (data.remote) {
try {
const remoteStatus = await data.remote.getStatus();
setLocalSettings({ ...settings, remoteStatus });
const [remoteStatus, remoteSettingsSnapshot] = await Promise.all([
data.remote.getStatus(),
data.remote.getSettings().catch(() => settings.remoteSettingsSnapshot),
]);
setLocalSettings({ ...settings, remoteStatus, remoteSettingsSnapshot });
} catch {
setLocalSettings(settings);
}
@@ -2166,7 +2182,8 @@ function SettingsInteractiveView({ state }: { state: DashboardState }) {
await data.updateSettings(partial);
const updated = await data.getSettings();
const remoteStatus = data.remote ? await data.remote.getStatus().catch(() => null) : null;
setLocalSettings(remoteStatus ? { ...updated, remoteStatus } : updated);
const remoteSettingsSnapshot = data.remote ? await data.remote.getSettings().catch(() => updated.remoteSettingsSnapshot) : undefined;
setLocalSettings(remoteStatus ? { ...updated, remoteStatus, remoteSettingsSnapshot } : updated);
setStatusMsg("Saved");
} catch (err) {
setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`);
@@ -2178,13 +2195,37 @@ function SettingsInteractiveView({ state }: { state: DashboardState }) {
async function refreshRemoteStatus() {
if (!data || !localSettings) return;
try {
const remoteStatus = await data.remote.getStatus();
setLocalSettings({ ...localSettings, remoteStatus });
const [remoteStatus, remoteSettingsSnapshot] = await Promise.all([
data.remote.getStatus(),
data.remote.getSettings().catch(() => localSettings.remoteSettingsSnapshot),
]);
setLocalSettings({ ...localSettings, remoteStatus, remoteSettingsSnapshot });
} catch {
// best-effort
}
}
async function handleFetchRemoteUrl(tokenType: "persistent" | "short-lived", ttlMs?: number) {
if (!data?.remote) return;
const result = await data.remote.getRemoteUrl(tokenType, ttlMs);
setRemoteUrl(result.url);
setRemoteTokenMeta(result.expiresAt ? `expires ${new Date(result.expiresAt).toLocaleString()}` : result.tokenType);
}
async function handleFetchRemoteQr(tokenType: "persistent" | "short-lived", ttlMs?: number) {
if (!data?.remote) return;
const result = await data.remote.getQrPayload(tokenType, ttlMs);
setRemoteUrl(result.url);
setRemoteTokenMeta(result.expiresAt ? `expires ${new Date(result.expiresAt).toLocaleString()}` : tokenType);
if (result.format === "text") {
setRemoteQrDisplay(result.data ?? result.url);
setRemoteQrFallback(null);
return;
}
setRemoteQrDisplay(null);
setRemoteQrFallback("QR SVG returned by server. Open the authenticated URL on your phone/browser to continue.");
}
useInput((input, key) => {
// Tab cycles list ↔ detail. Left/right also switch — list = left,
// detail = right, matching the visual layout (consistent with AgentsView).
@@ -2215,48 +2256,72 @@ function SettingsInteractiveView({ state }: { state: DashboardState }) {
if (!selectedDef || !localSettings) return;
if (ttlInputMode) {
if (key.escape) {
setTtlInputMode(false);
setStatusMsg("Cancelled short-lived token input");
}
return;
}
if (input === "R") {
void refreshRemoteStatus();
setStatusMsg("Remote status refreshed");
return;
}
if (data?.remote && input === "S") {
void data.remote.start().then(() => refreshRemoteStatus()).then(() => setStatusMsg("Remote tunnel starting"))
if (data?.remote && input === "C") {
const provider = localSettings.remoteActiveProvider;
if (!provider) {
setStatusMsg("Select a remote provider first");
} else {
void data.remote.activateProvider(provider)
.then(() => refreshRemoteStatus())
.then(() => setStatusMsg(`Activated provider: ${provider}`))
.catch((err) => setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`));
}
return;
}
if (data?.remote && input === "V") {
void data.remote.startTunnel().then(() => refreshRemoteStatus()).then(() => setStatusMsg("Remote tunnel starting"))
.catch((err) => setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`));
return;
}
if (data?.remote && input === "X") {
void data.remote.stop().then(() => refreshRemoteStatus()).then(() => setStatusMsg("Remote tunnel stopped"))
void data.remote.stopTunnel().then(() => refreshRemoteStatus()).then(() => setStatusMsg("Remote tunnel stopped"))
.catch((err) => setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`));
return;
}
if (data?.remote && input === "P") {
void data.remote.regeneratePersistentToken().then(() => setStatusMsg("Persistent token regenerated"))
void data.remote.regeneratePersistentToken()
.then((result) => {
setPersistentMaskedToken(result.maskedToken ?? null);
setStatusMsg("Persistent token regenerated");
})
.catch((err) => setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`));
return;
}
if (data?.remote && input === "L") {
setTtlInputValue(String(localSettings.remoteShortLivedTtlMs));
setTtlInputMode(true);
setStatusMsg("Enter TTL milliseconds and press Enter");
return;
}
if (data?.remote && input === "U") {
void data.remote.fetchUrl()
.then((result) => {
setRemoteUrl(result.url);
setRemoteTokenMeta(result.expiresAt ? `expires ${new Date(result.expiresAt).toLocaleString()}` : result.tokenType);
setStatusMsg("Remote URL fetched");
})
void handleFetchRemoteUrl("persistent")
.then(() => setStatusMsg("Remote URL fetched"))
.catch((err) => setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`));
return;
}
if (data?.remote && input === "Q") {
void data.remote.fetchQr()
.then((result) => {
setRemoteUrl(result.url);
setRemoteTokenMeta(result.expiresAt ? `expires ${new Date(result.expiresAt).toLocaleString()}` : "persistent");
setStatusMsg("QR URL generated");
})
if (data?.remote && input === "K") {
void handleFetchRemoteQr("persistent")
.then(() => setStatusMsg("QR payload fetched"))
.catch((err) => setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`));
return;
}
@@ -2296,6 +2361,9 @@ function SettingsInteractiveView({ state }: { state: DashboardState }) {
const updated = { ...localSettings, [selectedDef.key]: next };
setLocalSettings(updated);
void saveField({ [selectedDef.key]: next });
if (selectedDef.key === "remoteActiveProvider" && data?.remote) {
void data.remote.activateProvider(next as "tailscale" | "cloudflare").catch(() => {});
}
return;
}
if (key.leftArrow || input === "h") {
@@ -2303,6 +2371,9 @@ function SettingsInteractiveView({ state }: { state: DashboardState }) {
const updated = { ...localSettings, [selectedDef.key]: prev };
setLocalSettings(updated);
void saveField({ [selectedDef.key]: prev });
if (selectedDef.key === "remoteActiveProvider" && data?.remote) {
void data.remote.activateProvider(prev as "tailscale" | "cloudflare").catch(() => {});
}
return;
}
}
@@ -2319,6 +2390,31 @@ function SettingsInteractiveView({ state }: { state: DashboardState }) {
return <Text>{String(v)}</Text>;
}
async function submitShortLivedTtlInput(value: string) {
if (!data?.remote || !localSettings) return;
const ttlMs = Number(value.trim());
if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
setStatusMsg("TTL must be a positive number (ms)");
return;
}
setTtlInputMode(false);
setSaving(true);
try {
const tokenResult = await data.remote.generateShortLivedToken(ttlMs);
setShortLivedExpiresAt(tokenResult.expiresAt);
setRemoteTokenMeta(tokenResult.expiresAt ? `expires ${new Date(tokenResult.expiresAt).toLocaleString()}` : "short-lived");
setLocalSettings({ ...localSettings, remoteShortLivedTtlMs: ttlMs });
await saveField({ remoteShortLivedTtlMs: ttlMs });
await handleFetchRemoteUrl("short-lived", ttlMs);
setStatusMsg("Short-lived token generated");
} catch (err) {
setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`);
} finally {
setSaving(false);
}
}
return (
<Box flexDirection="column" flexGrow={1}>
{statusMsg && (
@@ -2409,23 +2505,55 @@ function SettingsInteractiveView({ state }: { state: DashboardState }) {
)}
<Box height={1} />
<Text dimColor> Remote Access </Text>
<Text dimColor> Remote </Text>
<Box flexDirection="row" gap={1}>
<Text dimColor>Provider:</Text>
<Text>{localSettings.remoteActiveProvider ?? "none"}</Text>
<Text dimColor>State:</Text>
<Text color={localSettings.remoteStatus?.state === "running" ? "green" : "yellow"}>{localSettings.remoteStatus?.state ?? "unknown"}</Text>
</Box>
<Box flexDirection="row" gap={1}>
<Text dimColor>Enabled:</Text>
<Text>{localSettings.remoteSettingsSnapshot?.remoteEnabled ? "yes" : "no"}</Text>
<Text dimColor>Short-lived:</Text>
<Text>{localSettings.remoteSettingsSnapshot?.shortLivedEnabled ? "on" : "off"}</Text>
</Box>
{localSettings.remoteStatus?.url && (
<Text dimColor wrap="truncate-end">URL: {localSettings.remoteStatus.url}</Text>
<Text dimColor wrap="truncate-end">Tunnel URL: {localSettings.remoteStatus.url}</Text>
)}
{remoteUrl && (
<Text dimColor wrap="truncate-end">Auth URL: {remoteUrl}</Text>
<Text color="white" wrap="truncate-end">Auth URL: {remoteUrl}</Text>
)}
{remoteTokenMeta && (
<Text dimColor>{remoteTokenMeta}</Text>
<Text dimColor wrap="truncate-end">Token: {remoteTokenMeta}</Text>
)}
<Text dimColor>[S] start [X] stop [P] regenerate token [U] URL [Q] QR URL [R] refresh</Text>
{persistentMaskedToken && (
<Text dimColor wrap="truncate-end">Persistent token: {persistentMaskedToken}</Text>
)}
{shortLivedExpiresAt && (
<Text dimColor wrap="truncate-end">Short-lived expires: {new Date(shortLivedExpiresAt).toLocaleString()}</Text>
)}
{remoteQrDisplay && (
<Text wrap="truncate-end">QR text payload: {remoteQrDisplay}</Text>
)}
{remoteQrFallback && (
<Text color="yellow" wrap="truncate-end">{remoteQrFallback}</Text>
)}
{ttlInputMode && (
<Box flexDirection="row" gap={1}>
<Text dimColor>TTL ms:</Text>
<TextInput
value={ttlInputValue}
onChange={setTtlInputValue}
onSubmit={(value) => {
void submitShortLivedTtlInput(value);
}}
/>
<Text dimColor>[Enter] generate [Esc] cancel</Text>
</Box>
)}
<Text dimColor>[C] activate provider [V] start [X] stop [P] persistent token [L] short-lived token</Text>
<Text dimColor>[U] URL hand-off [K] QR hand-off [R] refresh</Text>
{/* Models subsection */}
{models.length > 0 && (
@@ -2453,7 +2581,7 @@ function SettingsInteractiveView({ state }: { state: DashboardState }) {
</Box>
<Box paddingX={1}>
<Text dimColor>[Tab] switch panel select setting [Space] toggle bool [+/-] adjust num [/] cycle enum [S/X/P/U/Q/R] remote actions</Text>
<Text dimColor>[Tab] switch panel select setting [Space] toggle bool [+/-] adjust num [/] cycle enum [C/V/X/P/L/U/K/R] remote actions</Text>
</Box>
</Box>
);
@@ -3610,7 +3738,7 @@ function InteractiveMode({ state, controller }: { state: DashboardState; control
<Box flexGrow={1} overflow="hidden">
{state.interactiveView === "board" && <BoardView state={state} controller={controller} />}
{state.interactiveView === "agents" && <AgentsView state={state} />}
{state.interactiveView === "settings" && <SettingsInteractiveView state={state} />}
{state.interactiveView === "settings" && <SettingsInteractiveView state={state} controller={controller} />}
{state.interactiveView === "git" && <GitView state={state} />}
{state.interactiveView === "files" && <FilesView state={state} />}
</Box>
@@ -3663,6 +3791,10 @@ export function DashboardApp({ controller }: DashboardAppProps) {
return;
}
if (state.mode === "interactive" && state.interactiveInputLocked) {
return;
}
// View switching shortcuts — b/a/g enter interactive + set view
if (input === "b" || input === "B") {
controller.setMode("interactive");

View File

@@ -61,6 +61,7 @@ export class DashboardTUI {
private lastAutoKillAt = 0;
interactiveData: InteractiveData | null = null;
interactiveView: InteractiveView = "board";
interactiveInputLocked = false;
// Subscribers registered by the Ink App component.
private subscribers: Set<() => void> = new Set();
@@ -119,6 +120,7 @@ export class DashboardTUI {
mode: this.mode,
interactiveData: this.interactiveData,
interactiveView: this.interactiveView,
interactiveInputLocked: this.interactiveInputLocked,
autoKillVitestOnPressure: this.autoKillVitestOnPressure,
vitestKillThreshold: this.vitestKillThreshold,
};
@@ -324,6 +326,12 @@ export class DashboardTUI {
this.notify();
}
setInteractiveInputLocked(locked: boolean): void {
if (this.interactiveInputLocked === locked) return;
this.interactiveInputLocked = locked;
this.notify();
}
addLog(entry: Omit<LogEntry, "timestamp">): void {
// If the cursor was sitting on the most recent entry (or there were no
// entries yet), keep it pinned to the new tail so live logs follow the

View File

@@ -12,6 +12,11 @@ export type {
SystemInfo,
TaskStats,
SettingsValues,
RemoteProvider,
RemoteStatus,
RemoteTokenResult,
RemoteQrPayload,
RemoteSettingsSnapshot,
UtilityAction,
TUICallbacks,
InteractiveData,

View File

@@ -51,13 +51,38 @@ export interface SystemStats {
platform: string;
}
export interface RemoteStatusValue {
provider: "tailscale" | "cloudflare" | null;
export type RemoteProvider = "tailscale" | "cloudflare";
export interface RemoteStatus {
provider: RemoteProvider | null;
state: "stopped" | "starting" | "running" | "error";
url: string | null;
lastError: string | null;
}
export interface RemoteTokenResult {
token?: string;
maskedToken?: string;
tokenType: "persistent" | "short-lived";
expiresAt: string | null;
}
export interface RemoteQrPayload {
url: string;
expiresAt: string | null;
format: "text" | "image/svg";
data?: string;
}
export interface RemoteSettingsSnapshot {
remoteEnabled: boolean;
activeProvider: RemoteProvider | null;
tailscaleEnabled: boolean;
cloudflareEnabled: boolean;
shortLivedEnabled: boolean;
shortLivedTtlMs: number;
}
export interface SettingsValues {
maxConcurrent: number;
maxWorktrees: number;
@@ -67,10 +92,11 @@ export interface SettingsValues {
enginePaused: boolean;
globalPause: boolean;
remoteEnabled: boolean;
remoteActiveProvider: "tailscale" | "cloudflare" | null;
remoteActiveProvider: RemoteProvider | null;
remoteShortLivedEnabled: boolean;
remoteShortLivedTtlMs: number;
remoteStatus?: RemoteStatusValue;
remoteSettingsSnapshot?: RemoteSettingsSnapshot;
remoteStatus?: RemoteStatus;
}
export interface UtilityAction {
@@ -258,14 +284,15 @@ export interface InteractiveData {
updateSettings: (partial: Partial<SettingsValues>) => Promise<void>;
listModels: () => ModelItem[];
remote: {
getStatus: () => Promise<RemoteStatusValue>;
activateProvider: (provider: "tailscale" | "cloudflare") => Promise<void>;
start: () => Promise<void>;
stop: () => Promise<void>;
regeneratePersistentToken: () => Promise<void>;
generateShortLivedToken: (ttlMs: number) => Promise<{ token: string; expiresAt: string; ttlMs: number }>;
fetchUrl: () => Promise<{ url: string; tokenType: "persistent" | "short-lived"; expiresAt: string | null }>;
fetchQr: () => Promise<{ url: string; expiresAt: string | null; format: "text" | "image/svg"; data?: string }>;
getSettings: () => Promise<RemoteSettingsSnapshot>;
getStatus: () => Promise<RemoteStatus>;
activateProvider: (provider: RemoteProvider) => Promise<void>;
startTunnel: () => Promise<void>;
stopTunnel: () => Promise<void>;
regeneratePersistentToken: () => Promise<RemoteTokenResult>;
generateShortLivedToken: (ttlMs: number) => Promise<RemoteTokenResult>;
getRemoteUrl: (tokenType: "persistent" | "short-lived", ttlMs?: number) => Promise<{ url: string; tokenType: "persistent" | "short-lived"; expiresAt: string | null }>;
getQrPayload: (tokenType: "persistent" | "short-lived", ttlMs?: number) => Promise<RemoteQrPayload>;
};
git: {
getStatus: (projectPath: string) => Promise<GitStatus>;
@@ -313,6 +340,7 @@ export interface DashboardState {
mode: AppMode;
interactiveData: InteractiveData | null;
interactiveView: InteractiveView;
interactiveInputLocked: boolean;
autoKillVitestOnPressure: boolean;
vitestKillThreshold: number;
}
@@ -338,6 +366,7 @@ export function createInitialState(): DashboardState {
mode: "status",
interactiveData: null,
interactiveView: "board",
interactiveInputLocked: false,
autoKillVitestOnPressure: true,
vitestKillThreshold: 0.9,
};