diff --git a/apps/ios/CovenCave/CovenCave/State/AppModel.swift b/apps/ios/CovenCave/CovenCave/State/AppModel.swift index 4fb230c7f..641e0098f 100644 --- a/apps/ios/CovenCave/CovenCave/State/AppModel.swift +++ b/apps/ios/CovenCave/CovenCave/State/AppModel.swift @@ -73,6 +73,13 @@ final class AppModel { var familiarOrder: [String] = [] var threads: [ChatThread] = [] + /// Default Chats destination: the newest active conversation. Pinning only + /// affects list order and never makes an older thread the launch default. + var mostRecentThread: ChatThread? { + threads + .filter { !$0.archived } + .max { $0.updatedAt < $1.updatedAt } + } /// Process-lifetime launch intent. It survives destination remounts until a /// matching hydrated thread can be opened, then is consumed exactly once. var launchThreadId: String? diff --git a/apps/ios/CovenCave/CovenCave/Views/ChatsHomeView.swift b/apps/ios/CovenCave/CovenCave/Views/ChatsHomeView.swift index 0b1b4d3f7..af4b8daab 100644 --- a/apps/ios/CovenCave/CovenCave/Views/ChatsHomeView.swift +++ b/apps/ios/CovenCave/CovenCave/Views/ChatsHomeView.swift @@ -101,9 +101,11 @@ struct ChatsHomeView: View { .onAppear { consumeLaunchThreadIntent() consumeGlobalRequests() + selectMostRecentThreadIfNeeded() } .onChange(of: app.threads.map(\.id)) { _, _ in consumeLaunchThreadIntent() + selectMostRecentThreadIfNeeded() } // A slash command (`/new`, `/familiar `) or a task link asked to // open a specific thread — surface it in the detail column. @@ -390,6 +392,19 @@ struct ChatsHomeView: View { open(.thread(thread)) } + /// Open Chats at the latest active conversation without stealing focus from + /// a deep link, cross-view handoff, New Chat, or an existing selection. + private func selectMostRecentThreadIfNeeded() { + guard selection == nil, + !showNewChat, + app.threadToOpen == nil, + app.launchThreadId == nil, + !app.newChatRequested, + let thread = app.mostRecentThread + else { return } + open(.thread(thread)) + } + /// Consume a cross-destination thread handoff on first appearance and on /// later updates. Clearing the one-shot intent prevents re-appearance from /// reopening the same conversation. diff --git a/apps/ios/CovenCave/CovenCave/Views/RootView.swift b/apps/ios/CovenCave/CovenCave/Views/RootView.swift index dfca35b41..92d3aa33c 100644 --- a/apps/ios/CovenCave/CovenCave/Views/RootView.swift +++ b/apps/ios/CovenCave/CovenCave/Views/RootView.swift @@ -280,7 +280,7 @@ struct ConnectingView: View { .accessibilityHidden(true) .padding(.bottom, 34) - Text("Opening the Cave") + Text("Entering the Cave") .font(.title.weight(.medium)) .fontDesign(.serif) .italic() diff --git a/apps/ios/CovenCave/CovenCaveTests/LaunchThreadIntentTests.swift b/apps/ios/CovenCave/CovenCaveTests/LaunchThreadIntentTests.swift index b9592776f..d91944362 100644 --- a/apps/ios/CovenCave/CovenCaveTests/LaunchThreadIntentTests.swift +++ b/apps/ios/CovenCave/CovenCaveTests/LaunchThreadIntentTests.swift @@ -34,4 +34,31 @@ final class LaunchThreadIntentTests: XCTestCase { app.threads = [expected] XCTAssertTrue(app.consumeLaunchThreadIntent() === expected) } + + func testMostRecentThreadUsesUpdateTimeAndSkipsArchivedThreads() { + let app = AppModel() + let olderPinned = ChatThread(id: "older-pinned", title: "Older pinned", familiarIds: []) + olderPinned.updatedAt = Date(timeIntervalSince1970: 100) + olderPinned.pinned = true + + let newest = ChatThread(id: "newest", title: "Newest", familiarIds: []) + newest.updatedAt = Date(timeIntervalSince1970: 200) + + let archived = ChatThread(id: "archived", title: "Archived", familiarIds: []) + archived.updatedAt = Date(timeIntervalSince1970: 300) + archived.archived = true + + app.threads = [olderPinned, archived, newest] + + XCTAssertTrue(app.mostRecentThread === newest) + } + + func testMostRecentThreadIsNilWithoutAnActiveConversation() { + let app = AppModel() + let archived = ChatThread(id: "archived", title: "Archived", familiarIds: []) + archived.archived = true + app.threads = [archived] + + XCTAssertNil(app.mostRecentThread) + } } diff --git a/scripts/ios-claude-design-fidelity.test.mjs b/scripts/ios-claude-design-fidelity.test.mjs index f68e3c581..7cb069261 100644 --- a/scripts/ios-claude-design-fidelity.test.mjs +++ b/scripts/ios-claude-design-fidelity.test.mjs @@ -92,6 +92,11 @@ assert.match( /func consumeLaunchThreadIntent\(\) -> ChatThread\?/, "the launch thread intent waits for a matching thread before consuming", ); +assert.match( + appModel, + /var mostRecentThread: ChatThread\? \{[\s\S]{0,180}filter \{ !\$0\.archived \}[\s\S]{0,180}max \{ \$0\.updatedAt < \$1\.updatedAt \}/, + "the default chat is the newest active thread, independent of pin order", +); assert.match( appModel, /if let threadId = ChatNotifications\.threadId\(fromDeepLink: url\) \{[\s\S]{0,140}launchThreadId = threadId[\s\S]{0,140}selectedTab = \.chats/, @@ -109,8 +114,18 @@ assert.doesNotMatch( ); assert.match( home, - /onChange\(of: app\.threads\.map\(\\\.id\)\)[\s\S]{0,120}consumeLaunchThreadIntent\(\)/, - "Chats retries a pending launch intent when hydration adds threads", + /\.onAppear \{\s*consumeLaunchThreadIntent\(\)\s*consumeGlobalRequests\(\)\s*selectMostRecentThreadIfNeeded\(\)\s*\}/, + "Chats selects the most recent thread after honoring explicit launch requests", +); +assert.match( + home, + /onChange\(of: app\.threads\.map\(\\\.id\)\)[\s\S]{0,160}consumeLaunchThreadIntent\(\)[\s\S]{0,160}selectMostRecentThreadIfNeeded\(\)/, + "Chats retries explicit and default selection when hydration adds threads", +); +assert.match( + home, + /private func selectMostRecentThreadIfNeeded\(\) \{[\s\S]{0,500}guard selection == nil,[\s\S]{0,500}!showNewChat,[\s\S]{0,500}app\.threadToOpen == nil,[\s\S]{0,500}app\.launchThreadId == nil,[\s\S]{0,500}!app\.newChatRequested,[\s\S]{0,500}let thread = app\.mostRecentThread[\s\S]{0,180}open\(\.thread\(thread\)\)/, + "the default never overrides an explicit destination or New Chat intent", ); // Authored navigation and discovery surfaces. @@ -144,7 +159,7 @@ assert.match(glass, /UINavigationBarAppearance\.glass/, // Quiet Portal keeps the cold connection state honest, themed, deterministic, // and accessible without inventing stages the runtime cannot prove. -assert.match(root, /Text\("Opening the Cave"\)/, "connecting state uses the approved headline"); +assert.match(root, /Text\("Entering the Cave"\)/, "connecting state uses the approved headline"); assert.match(root, /Text\("Connecting to your desktop"\)/, "connecting state names the live operation"); assert.match(root, /if let host = app\.connection\?\.host/, "connecting state renders the real saved host"); assert.match( diff --git a/src/components/daemon-start-button.test.ts b/src/components/daemon-start-button.test.ts index 788f3447d..1e41c5779 100644 --- a/src/components/daemon-start-button.test.ts +++ b/src/components/daemon-start-button.test.ts @@ -2,7 +2,9 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; -const settings = await readFile(new URL("./settings-shell.tsx", import.meta.url), "utf8"); +const settingsShell = await readFile(new URL("./settings-shell.tsx", import.meta.url), "utf8"); +const settingsDaemon = await readFile(new URL("./settings-daemon.tsx", import.meta.url), "utf8"); +const settings = `${settingsShell}\n${settingsDaemon}`; const workspace = await readFile(new URL("./workspace.tsx", import.meta.url), "utf8"); assert.match(settings, /fetch\("\/api\/daemon\/start", \{ method: "POST" \}\)/); diff --git a/src/components/settings-action-buttons.test.ts b/src/components/settings-action-buttons.test.ts index 4adf4597b..1ff65e375 100644 --- a/src/components/settings-action-buttons.test.ts +++ b/src/components/settings-action-buttons.test.ts @@ -4,6 +4,7 @@ import { readFileSync } from "node:fs"; import ts from "typescript"; const shell = readFileSync(new URL("./settings-shell.tsx", import.meta.url), "utf8"); +const daemon = readFileSync(new URL("./settings-daemon.tsx", import.meta.url), "utf8"); const picker = readFileSync(new URL("./settings-familiar-picker.tsx", import.meta.url), "utf8"); const controls = readFileSync(new URL("./ui/settings-controls.tsx", import.meta.url), "utf8"); const css = readFileSync(new URL("../app/globals.css", import.meta.url), "utf8"); @@ -45,12 +46,13 @@ function jsxElementBlocks(fileName: string, source: string, tagName: string): st } const reviewedSemanticControl = (button: string): boolean => - /goToSetting\(e\)|selectDevice\(device\)|settings-nav__item|role="switch"|aria-pressed=|aria-label=\{`Pick \$\{label\} color`\}|aria-haspopup="dialog"|role="option"|role="radio"|role="checkbox"|setEnlarged\(true\)|Drag to reorder|familiar-studio-lifecycle__row-main/.test( + /goToSetting\(e\)|selectDevice\(device\)|settings-nav__item|role="switch"|aria-pressed=|aria-expanded=|aria-label=\{`Pick \$\{label\} color`\}|aria-haspopup="dialog"|role="option"|role="radio"|role="checkbox"|setEnlarged\(true\)|Drag to reorder|familiar-studio-lifecycle__row-main/.test( button, ); for (const [name, source] of [ ["settings shell", shell], + ["settings daemon", daemon], ["settings familiar picker", picker], ["settings segmented control", controls], ...studioSources, @@ -72,7 +74,7 @@ for (const [name, source] of [ } } -const saveConnectionButtons = jsxElementBlocks("settings-shell.tsx", shell, "Button").filter((block) => +const saveConnectionButtons = jsxElementBlocks("settings-daemon.tsx", daemon, "Button").filter((block) => block.includes("Save connection"), ); assert.equal(saveConnectionButtons.length, 1, "Save connection renders through exactly one shared Button"); diff --git a/src/components/settings-daemon-multihost.test.ts b/src/components/settings-daemon-multihost.test.ts index 38d0269b4..e034ee91d 100644 --- a/src/components/settings-daemon-multihost.test.ts +++ b/src/components/settings-daemon-multihost.test.ts @@ -1,26 +1,107 @@ // @ts-nocheck import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; -const shell = readFileSync(new URL("./settings-shell.tsx", import.meta.url), "utf8"); +const shellEntry = readFileSync(new URL("./settings-shell.tsx", import.meta.url), "utf8"); +const daemonUrl = new URL("./settings-daemon.tsx", import.meta.url); +const daemon = existsSync(daemonUrl) ? readFileSync(daemonUrl, "utf8") : ""; +const shell = `${shellEntry}\n${daemon}`; const sections = readFileSync(new URL("./settings-sections.ts", import.meta.url), "utf8"); +const daemonCssUrl = new URL("../styles/settings-daemon.css", import.meta.url); +const daemonCss = existsSync(daemonCssUrl) ? readFileSync(daemonCssUrl, "utf8") : ""; +// ── Claude Design daemon control sheet ─────────────────────────────────────── assert.match( + shellEntry, + /import \{ DaemonSection \} from "\.\/settings-daemon"/, + "SettingsShell should delegate the daemon control sheet to a focused component", +); +assert.match( + daemon, + /export function DaemonSection/, + "the focused daemon module should export the Settings section", +); +assert.match(daemon, /className="settings-daemon"/, "the daemon page should own a responsive control-sheet container"); +assert.match(daemon, /className="settings-daemon-hero"/, "the daemon page should open with the approved compact hero"); +assert.match(daemon, /Settings · Daemon/, "the hero should carry the approved settings kicker"); +assert.match(daemon, /className="settings-daemon-chip-list"/, "the hero should summarize target, API, queue, and uptime"); +assert.match(daemon, />\s*Refresh\s*HOMEAWAY\s*Revert\s*\s*Save connection\s*\}/, "the Vault-gated Omnigent settings must remain reachable from Daemon"); + +assert.match( + daemonCss, + /@container settings-daemon \(max-width:/, + "the control sheet should adapt to its pane with a container query", +); +assert.match( + daemonCss, + /@media \(prefers-reduced-motion: reduce\)/, + "daemon-specific motion should have an explicit reduced-motion treatment", +); +assert.doesNotMatch( + daemonCss, + /(?:gap|padding|width|height):\s*(?:2|3|6|8|12)px|box-shadow:\s*inset\s+2px/, + "daemon micro-spacing should use the design-system spacing tokens", +); +assert.doesNotMatch( shell, - /type MultiHostMode = "local" \| "hub"/, - "SettingsShell should model local vs server hub mode explicitly", + /bg-red-400/, + "daemon error states should use the semantic danger token across every theme", ); assert.match( shell, - /fetch\("\/api\/config", \{ cache: "no-store", signal: ctl\.signal \}\)/, - "Daemon settings should load Cave config before rendering connection controls", + /type MultiHostMode = "local" \| "hub"/, + "SettingsShell should model local vs server hub mode explicitly", ); assert.match( shell, - /body: JSON\.stringify\(\{ multiHost: \{ mode: nextMode, hubUrl, executorUrls: parseExecutorUrls\(executorText\) \} \}\)/, - "Daemon settings should persist the selected connection mode through cave-config", + /fetch\("\/api\/config", \{ cache: "no-store", signal: ctl\.signal \}\)/, + "Daemon settings should load Cave config before rendering connection controls", ); assert.match( diff --git a/src/components/settings-daemon.tsx b/src/components/settings-daemon.tsx new file mode 100644 index 000000000..c9357a26e --- /dev/null +++ b/src/components/settings-daemon.tsx @@ -0,0 +1,956 @@ +"use client"; + +import "@/styles/settings-daemon.css"; + +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { Button } from "@/components/ui/button"; +import { ErrorState } from "@/components/ui/error-state"; +import { IconButton } from "@/components/ui/icon-button"; +import { useAnnouncer } from "@/components/ui/live-region"; +import { RelativeTime } from "@/components/ui/relative-time"; +import { TextArea } from "@/components/ui/text-area"; +import { TextInput } from "@/components/ui/text-input"; +import { copyText } from "@/lib/clipboard"; +import { Icon, type IconName } from "@/lib/icon"; +import { parseExecutorUrls } from "./settings-multihost"; + +type DaemonStatus = { + running: boolean; + reason?: string; + checkedAt?: string; + covenVersion?: string; + apiVersion?: string; + workspacePath?: string; + daemon?: { pid: number; startedAt: string; socket: string }; + executors?: Array<{ + url: string; + healthUrl: string; + ok: boolean; + state: "available" | "unreachable"; + detail: string; + }>; + target?: { + mode: "local" | "hub" | "unconfigured-hub"; + label: string; + socket?: string; + url?: string; + error?: string; + }; + travel?: { + mode: "home" | "hub" | "watching-hub" | "travel" | "handoff-pending"; + authority: "local" | "hub" | "travel-local"; + reason: string; + manualOffline: boolean; + staleCache: boolean; + wakeLocalSubdaemon: boolean; + localBindHost: "127.0.0.1"; + hubUnreachableSince: string | null; + hubUnreachableForMs: number; + pendingQueueCount: number; + handoffPending: boolean; + }; +}; + +type MultiHostMode = "local" | "hub"; + +type TailscaleDevice = { + name: string; + dnsName: string | null; + hostName: string | null; + tailnetIp: string | null; + os: string | null; + online: boolean; + lastSeen: string | null; + isSelf: boolean; +}; + +type DaemonProbe = { + reachable: boolean; + status: number; + latencyMs: number; + reason?: string; + url: string; +}; + +type SavedConnection = { + mode: MultiHostMode; + hubUrl: string; + executorUrls: string[]; +}; + +type StatusTone = "danger" | "neutral" | "success" | "warning"; + +const RUNTIME_TARGETS: Array<{ + id: MultiHostMode; + label: string; + blurb: string; + icon: IconName; +}> = [ + { + id: "local", + label: "Local", + blurb: "Runs everything on this machine. The default, and the fastest path.", + icon: "ph:terminal-window", + }, + { + id: "hub", + label: "Server hub", + blurb: "Connects to a shared daemon on another machine over your tailnet.", + icon: "ph:cloud-bold", + }, +]; + +function SectionRule({ id, label, meta }: { id: string; label: string; meta?: ReactNode }) { + return ( +
+

{label}

+
+ ); +} + +function isValidHubUrl(value: string): boolean { + try { + const url = new URL(value.trim()); + return (url.protocol === "http:" || url.protocol === "https:") && Boolean(url.hostname); + } catch { + return false; + } +} + +function classifyTailscaleFailure(raw: string): { headline: string; hint: string } { + const text = raw.toLowerCase(); + if (text.includes("tailscale") && (text.includes("not installed") || text.includes("cli not found"))) { + return { + headline: "Tailscale isn’t installed", + hint: "Install Tailscale and sign in, then refresh devices.", + }; + } + if ( + text.includes("tailscale") && + (text.includes("signed out") || + text.includes("logged out") || + text.includes("not connected") || + text.includes("not running") || + text.includes("stopped") || + text.includes("unreachable")) + ) { + return { + headline: "Tailscale isn’t running", + hint: "Open Tailscale and sign in, then refresh devices.", + }; + } + return { + headline: "Tailnet devices unavailable", + hint: "Retry device discovery, or enter the server hub URL directly.", + }; +} + +export function DaemonSection({ + suggestedHubUrl, + onSuggestionConsumed, + omnigentSettings, +}: { + suggestedHubUrl: string | null; + onSuggestionConsumed: () => void; + omnigentSettings?: ReactNode; +}) { + const { announce } = useAnnouncer(); + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(true); + const [statusLoadError, setStatusLoadError] = useState(null); + const [starting, setStarting] = useState(false); + const [restarting, setRestarting] = useState(false); + const [startError, setStartError] = useState(null); + + const [mode, setMode] = useState("local"); + const [hubUrl, setHubUrl] = useState(""); + const [executorText, setExecutorText] = useState(""); + const [savedConnection, setSavedConnection] = useState(null); + const [savingConnection, setSavingConnection] = useState(false); + const [connectionError, setConnectionError] = useState(null); + const [executorsOpen, setExecutorsOpen] = useState(false); + + const [devices, setDevices] = useState([]); + const [devicesLoading, setDevicesLoading] = useState(false); + const [devicesError, setDevicesError] = useState(null); + const [probe, setProbe] = useState(null); + const [probing, setProbing] = useState(false); + + const [savingTravel, setSavingTravel] = useState(false); + const [travelError, setTravelError] = useState(null); + const [copiedInfo, setCopiedInfo] = useState<"socket" | "workspace" | null>(null); + + const refreshCtlRef = useRef(null); + const devicesCtlRef = useRef(null); + const suggestionAppliedRef = useRef(false); + const copyTimerRef = useRef | null>(null); + + const refresh = useCallback(async (announceResult = false) => { + refreshCtlRef.current?.abort(); + const ctl = new AbortController(); + refreshCtlRef.current = ctl; + setLoading(true); + setStatusLoadError(null); + try { + const response = await fetch("/api/daemon/status", { cache: "no-store", signal: ctl.signal }); + const result = await response.json().catch(() => ({})) as DaemonStatus & { error?: string }; + if (!response.ok) throw new Error(result.error || `status failed (${response.status})`); + if (ctl.signal.aborted) return; + setStatus(result); + if (announceResult) announce("Daemon status refreshed."); + } catch (error) { + if (ctl.signal.aborted) return; + const message = error instanceof Error ? error.message : "daemon status unavailable"; + setStatusLoadError(message); + if (announceResult) announce(`Couldn't refresh daemon status: ${message}`, "assertive"); + } finally { + if (!ctl.signal.aborted) setLoading(false); + } + }, [announce]); + + useEffect(() => { + void refresh(); + return () => refreshCtlRef.current?.abort(); + }, [refresh]); + + useEffect(() => { + const ctl = new AbortController(); + fetch("/api/config", { cache: "no-store", signal: ctl.signal }) + .then((response) => response.json()) + .then((result: { + ok?: boolean; + config?: { + multiHost?: { + mode?: MultiHostMode; + hubUrl?: string; + executorUrls?: string[]; + }; + }; + }) => { + if (ctl.signal.aborted || !result.ok) return; + const multiHost = result.config?.multiHost; + const next: SavedConnection = { + mode: multiHost?.mode === "hub" ? "hub" : "local", + hubUrl: multiHost?.hubUrl ?? "", + executorUrls: multiHost?.executorUrls ?? [], + }; + setSavedConnection(next); + setExecutorText(next.executorUrls.join("\n")); + setExecutorsOpen(next.executorUrls.length > 0); + if (suggestionAppliedRef.current) return; + setMode(next.mode); + setHubUrl(next.hubUrl); + }) + .catch(() => { + if (!ctl.signal.aborted) setConnectionError("Couldn’t load the saved daemon connection."); + }); + return () => ctl.abort(); + }, []); + + useEffect(() => { + if (!suggestedHubUrl) return; + suggestionAppliedRef.current = true; + setMode("hub"); + setHubUrl(suggestedHubUrl); + setProbe(null); + onSuggestionConsumed(); + }, [onSuggestionConsumed, suggestedHubUrl]); + + useEffect(() => () => { + if (copyTimerRef.current) clearTimeout(copyTimerRef.current); + }, []); + + const loadDevices = useCallback(() => { + devicesCtlRef.current?.abort(); + const ctl = new AbortController(); + devicesCtlRef.current = ctl; + setDevicesLoading(true); + setDevicesError(null); + fetch("/api/tailscale/devices", { cache: "no-store", signal: ctl.signal }) + .then((response) => response.json()) + .then((result: { ok?: boolean; devices?: TailscaleDevice[]; reason?: string }) => { + if (ctl.signal.aborted) return; + if (!result.ok) { + setDevices([]); + setDevicesError(result.reason || "Tailscale status unavailable"); + } else { + setDevices(result.devices ?? []); + } + setDevicesLoading(false); + }) + .catch((error) => { + if (ctl.signal.aborted) return; + setDevicesError(error instanceof Error ? error.message : "Tailscale status unavailable"); + setDevicesLoading(false); + }); + }, []); + + useEffect(() => { + if (mode !== "hub") { + devicesCtlRef.current?.abort(); + return; + } + loadDevices(); + return () => devicesCtlRef.current?.abort(); + }, [loadDevices, mode]); + + const connectionDirty = savedConnection !== null && ( + mode !== savedConnection.mode || + hubUrl.trim() !== savedConnection.hubUrl.trim() || + JSON.stringify(parseExecutorUrls(executorText)) !== JSON.stringify(savedConnection.executorUrls) + ); + + const persistConnection = async (nextMode = mode) => { + const normalizedHubUrl = hubUrl.trim(); + const normalizedExecutorUrls = parseExecutorUrls(executorText); + setSavingConnection(true); + setConnectionError(null); + try { + const res = await fetch("/api/config", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ multiHost: { mode: nextMode, hubUrl: normalizedHubUrl, executorUrls: normalizedExecutorUrls } }), + }); + const json = await res.json().catch(() => ({})); + if (!res.ok || json?.ok === false) { + throw new Error(json?.error || `save failed (${res.status})`); + } + setMode(nextMode); + setHubUrl(normalizedHubUrl); + setSavedConnection({ + mode: nextMode, + hubUrl: normalizedHubUrl, + executorUrls: normalizedExecutorUrls, + }); + announce("Daemon connection saved."); + await refresh(); + } catch (err) { + const msg = err instanceof Error ? err.message : "could not save daemon connection"; + setConnectionError(msg); + announce(`Couldn't save daemon connection: ${msg}`, "assertive"); + } finally { + setSavingConnection(false); + } + }; + + const probeHub = async (candidate: string, saveWhenReachable: boolean) => { + const url = candidate.trim(); + if (!url) { + setConnectionError("Enter a Server hub URL."); + return; + } + if (!isValidHubUrl(url)) { + setConnectionError("Enter a full HTTP URL, such as http://server.tailnet:8787."); + return; + } + setProbing(true); + setConnectionError(null); + try { + const response = await fetch("/api/daemon/probe", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ url }), + }); + const result = await response.json().catch(() => ({})) as Partial & { + ok?: boolean; + error?: string; + }; + if (!response.ok || result.ok === false || typeof result.reachable !== "boolean") { + throw new Error(result.error || `probe failed (${response.status})`); + } + const nextProbe: DaemonProbe = { + reachable: result.reachable, + status: result.status ?? 0, + latencyMs: result.latencyMs ?? 0, + reason: result.reason, + url, + }; + setProbe(nextProbe); + if (nextProbe.reachable && saveWhenReachable) await persistConnection("hub"); + } catch (error) { + const message = error instanceof Error ? error.message : "could not probe Server hub"; + setConnectionError(message); + announce(`Couldn't check the Server hub: ${message}`, "assertive"); + } finally { + setProbing(false); + } + }; + + const saveConnection = async (nextMode = mode) => { + if (nextMode === "hub") { + await probeHub(hubUrl, true); + return; + } + await persistConnection(nextMode); + }; + + const chooseMode = (nextMode: MultiHostMode) => { + setMode(nextMode); + setConnectionError(null); + setProbe(null); + }; + + const revertConnection = () => { + if (!savedConnection) return; + setMode(savedConnection.mode); + setHubUrl(savedConnection.hubUrl); + setExecutorText(savedConnection.executorUrls.join("\n")); + setExecutorsOpen(savedConnection.executorUrls.length > 0); + setConnectionError(null); + setProbe(null); + announce("Daemon connection changes reverted."); + }; + + const selectDevice = (device: TailscaleDevice) => { + const host = device.tailnetIp || device.dnsName; + if (!host) return; + const url = `http://${host}:8787`; + setMode("hub"); + setHubUrl(url); + setProbe(null); + void probeHub(url, false); + }; + + const startDaemon = async () => { + setStarting(true); + setStartError(null); + try { + const res = await fetch("/api/daemon/start", { method: "POST" }); + const json = await res.json().catch(() => ({})); + if (!res.ok || json?.ok === false) { + throw new Error(json?.error || json?.stderr || "daemon did not start"); + } + announce("Daemon started."); + await refresh(); + } catch (err) { + const message = err instanceof Error ? err.message : "daemon did not start"; + setStartError(message); + announce(`Couldn't start the daemon: ${message}`, "assertive"); + } finally { + setStarting(false); + } + }; + + const restartDaemon = async () => { + setRestarting(true); + setStartError(null); + try { + const res = await fetch("/api/daemon/start", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ restart: true }), + }); + const json = await res.json().catch(() => ({})); + if (!res.ok || json?.ok === false) { + throw new Error(json?.error || json?.stderr || "daemon did not restart"); + } + announce("Daemon restarted."); + await refresh(); + } catch (err) { + const message = err instanceof Error ? err.message : "daemon did not restart"; + setStartError(message); + announce(`Couldn't restart the daemon: ${message}`, "assertive"); + } finally { + setRestarting(false); + } + }; + + const setManualOffline = async (manualOffline: boolean) => { + setSavingTravel(true); + setTravelError(null); + try { + const res = await fetch("/api/travel/client", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ manualOffline }), + }); + const json = await res.json().catch(() => ({})); + if (!res.ok || json?.ok === false) { + throw new Error(json?.error || `travel mode save failed (${res.status})`); + } + announce(manualOffline ? "Daemon set to manual offline." : "Daemon back online."); + await refresh(); + } catch (err) { + const message = err instanceof Error ? err.message : "could not save travel mode"; + setTravelError(message); + announce(`Couldn't update travel mode: ${message}`, "assertive"); + } finally { + setSavingTravel(false); + } + }; + + const copyInfoValue = async (key: "socket" | "workspace", label: string, value?: string) => { + if (!value) return; + const ok = await copyText(value); + announce(ok ? `${label} copied.` : `Couldn't copy ${label.toLowerCase()}.`, ok ? "polite" : "assertive"); + if (!ok) return; + setCopiedInfo(key); + if (copyTimerRef.current) clearTimeout(copyTimerRef.current); + copyTimerRef.current = setTimeout(() => setCopiedInfo(null), 1200); + }; + + const manualOffline = status?.travel?.manualOffline === true; + const daemonOnline = status?.running === true && !manualOffline; + const statusTone: StatusTone = loading + ? "neutral" + : restarting || starting || manualOffline + ? "warning" + : status?.running + ? "success" + : "danger"; + const stateLabel = loading + ? "Checking…" + : restarting + ? "Restarting…" + : starting + ? "Starting…" + : manualOffline + ? "Offline (manual)" + : status?.running + ? "Running" + : "Offline"; + const targetLabel = status?.target?.label || (mode === "hub" ? "server hub" : "local runtime"); + const queueCount = status?.travel?.pendingQueueCount ?? 0; + const isAway = Boolean(status?.travel && status.travel.mode !== "home"); + const normalizedExecutorCount = parseExecutorUrls(executorText).length; + const hubUrlValid = isValidHubUrl(hubUrl); + const hubBadge = hubUrl.trim() ? (hubUrlValid ? "valid" : "check url") : "required"; + const hubBadgeTone = hubUrl.trim() ? (hubUrlValid ? "success" : "danger") : "neutral"; + + const statusMetrics = useMemo(() => [ + { + label: "AUTHORITY", + value: status?.travel?.authority || (mode === "hub" ? "server hub" : "local daemon"), + alert: false, + }, + { + label: "PENDING QUEUE", + value: String(queueCount), + alert: queueCount > 0, + }, + { + label: "LOCAL BIND", + value: status?.travel?.localBindHost || "127.0.0.1", + alert: false, + }, + { + label: "STALE CACHE", + value: status?.travel?.staleCache ? "yes" : "no", + alert: status?.travel?.staleCache === true, + }, + { + label: "WAKE LOCAL", + value: status?.travel?.wakeLocalSubdaemon ? "requested" : "standby", + alert: false, + }, + { + label: "HANDOFF", + value: status?.travel?.handoffPending ? "pending sync" : "clear", + alert: status?.travel?.handoffPending === true, + }, + ], [mode, queueCount, status?.travel]); + + const infoRows: Array<{ + key: string; + label: string; + value: ReactNode; + rawValue?: string; + copyKey?: "socket" | "workspace"; + }> = [ + { key: "coven", label: "Coven version", value: status?.covenVersion ?? "—" }, + { key: "api", label: "API version", value: status?.apiVersion ?? "—" }, + { + key: "socket", + label: "Socket", + value: status?.daemon?.socket ?? "—", + rawValue: status?.daemon?.socket, + copyKey: "socket", + }, + { + key: "workspace", + label: "Workspace", + value: status?.workspacePath ?? "—", + rawValue: status?.workspacePath, + copyKey: "workspace", + }, + { + key: "started", + label: "Started", + value: , + }, + ]; + + const tailscaleFailure = devicesError ? classifyTailscaleFailure(devicesError) : null; + + return ( +
+
+ +
+

Settings · Daemon

+

Daemon

+
    +
  • {targetLabel}
  • +
  • {status?.apiVersion || "coven.daemon.v1"}
  • +
  • 0 ? "warning" : "neutral"} />{queueCount} queued
  • +
  • + + up +
  • +
+
+
+ + {!loading && !status?.running && mode === "local" && ( + + )} + {status?.running && ( + + )} +
+
+ +
+ checked : "not checked"} + /> +
+
+
+ {statusLoadError ? ( + void refresh(true)}>Retry} + /> + ) : null} +
+ +
+ + {savedConnection === null ? "loading…" : connectionDirty ? "unsaved changes" : "saved"} + + } + /> +
+
+ RUNTIME TARGET +
+
+ {RUNTIME_TARGETS.map((target) => ( + + ))} +
+ + {mode === "hub" ? ( + <> +
+
+ + HTTP endpoint on your private network +
+
+ { + setHubUrl(event.target.value); + setProbe(null); + setConnectionError(null); + }} + aria-label="Server hub URL" + aria-describedby="settings-daemon-hub-hint" + placeholder="http://server.tailnet:8787" + spellCheck={false} + /> + {hubBadge} +
+ {probing || probe?.url === hubUrl.trim() ? ( +

+ {probing + ? "Checking reachability…" + : probe?.reachable + ? `Reachable · ${probe.latencyMs} ms` + : `Unreachable${probe?.reason ? ` · ${probe.reason}` : ""}`} +

+ ) : null} +
+ +
+
+
+ Tailnet devices + Choose a private-network machine or enter its address above. +
+ +
+ {tailscaleFailure ? ( +
+ {tailscaleFailure.headline} + {tailscaleFailure.hint} +
+ ) : null} + {!tailscaleFailure && !devicesLoading && devices.length === 0 ? ( +

No tailnet devices found.

+ ) : null} + {devices.length > 0 ? ( +
+ {devices.map((device) => { + const selectable = Boolean(device.tailnetIp || device.dnsName); + return ( + + ); + })} +
+ ) : null} +
+ + ) : null} + +
+ + {executorsOpen ? ( +
+