Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/pr-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -105,11 +105,14 @@ jobs:
run: >-
node --import tsx --test
packages/ui/src/components/session-list-visibility.test.ts
packages/ui/src/components/settings/info-settings-diagnostics.test.ts
packages/ui/src/components/unified-picker-path.test.ts
packages/ui/src/lib/hooks/use-app-session-capture.test.ts
packages/ui/src/lib/hooks/use-foreground-refresh.test.ts
packages/ui/src/lib/launch-errors.test.ts
packages/ui/src/lib/clipboard.test.ts
packages/ui/src/lib/message-selection-position.test.ts
packages/ui/src/lib/server-meta.test.ts
packages/ui/src/lib/trailing-resync.test.ts
packages/ui/src/stores/abort-created-workspace-cleanup.test.ts
packages/ui/src/stores/app-session-reconciliation.test.ts
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"

import type { ServerMeta } from "../../../../server/src/api-types"
import { buildDiagnosticReport } from "./info-settings-diagnostics"

const meta: ServerMeta = {
localUrl: "http://127.0.0.1:9899",
remoteUrl: "https://192.168.1.20:9898",
eventsUrl: "http://127.0.0.1:9899/api/events",
host: "0.0.0.0",
listeningMode: "all",
localPort: 9899,
remotePort: 9898,
hostLabel: "0.0.0.0",
workspaceRoot: "/home/user/projects",
addresses: [
{ ip: "192.168.1.20", family: "ipv4", scope: "external", remoteUrl: "https://192.168.1.20:9898" },
{ ip: "127.0.0.1", family: "ipv4", scope: "loopback", remoteUrl: "https://127.0.0.1:9898" },
],
serverVersion: "1.2.3",
ui: { version: "1.2.3", source: "bundled" },
}

describe("buildDiagnosticReport", () => {
it("includes effective connectivity details and candidate addresses", () => {
const report = buildDiagnosticReport(
meta,
"Linux x86_64",
{ host: "tauri", platform: "desktop", windowContext: "local" },
new Date("2026-08-10T12:00:00.000Z"),
)

assert.match(report, /Generated: 2026-08-10T12:00:00\.000Z/)
assert.match(report, /Listening mode: all/)
assert.match(report, /Bind host: 0\.0\.0\.0/)
assert.match(report, /Local URL: http:\/\/127\.0\.0\.1:9899/)
assert.match(report, /Remote URL: https:\/\/192\.168\.1\.20:9898/)
assert.match(report, /Candidate addresses: 2/)
assert.match(report, /ipv4\/external: https:\/\/192\.168\.1\.20:9898/)
})

it("uses explicit fallbacks when server metadata is unavailable", () => {
const report = buildDiagnosticReport(
null,
"Unknown",
{ host: "web", platform: "web", windowContext: "remote" },
new Date("2026-08-10T12:00:00.000Z"),
)

assert.match(report, /Server version: unknown/)
assert.match(report, /Remote URL: none/)
assert.match(report, /Candidate addresses: 0/)
})

it("identifies a specific interface and omits its unreachable loopback candidate", () => {
const report = buildDiagnosticReport(
{ ...meta, host: "192.168.1.20", addresses: meta.addresses },
"Linux x86_64",
{ host: "electron", platform: "desktop", windowContext: "local" },
new Date("2026-08-10T12:00:00.000Z"),
)

assert.match(report, /Listening mode: specific/)
assert.match(report, /Candidate addresses: 1/)
assert.doesNotMatch(report, /https:\/\/127\.0\.0\.1:9898/)
})
})
54 changes: 54 additions & 0 deletions packages/ui/src/components/settings/info-settings-diagnostics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type { NetworkAddress, ServerMeta } from "../../../../server/src/api-types"

export interface DiagnosticRuntime {
host: string
platform: string
windowContext: string
}

export type DiagnosticListeningMode = ServerMeta["listeningMode"] | "specific"

export function getDiagnosticListeningMode(meta: ServerMeta): DiagnosticListeningMode {
if (meta.listeningMode === "all" && meta.host !== "0.0.0.0" && meta.host !== "::") return "specific"
return meta.listeningMode
}

export function getDiagnosticAddresses(meta: ServerMeta): NetworkAddress[] {
if (getDiagnosticListeningMode(meta) !== "specific") return meta.addresses
return meta.addresses.filter((address) => address.scope !== "loopback")
}

export function buildDiagnosticReport(
meta: ServerMeta | null,
osDisplay: string,
runtime: DiagnosticRuntime,
generatedAt = new Date(),
): string {
const addresses = meta ? getDiagnosticAddresses(meta) : []
const lines = [
"CodeNomad Diagnostic Report",
"============================",
`Generated: ${generatedAt.toISOString()}`,
`Server version: ${meta?.serverVersion ?? "unknown"}`,
`UI version: ${meta?.ui?.version ?? "unknown"} (source: ${meta?.ui?.source ?? "unknown"})`,
`Runtime: ${runtime.host}`,
`Platform: ${runtime.platform}`,
`Window context: ${runtime.windowContext}`,
`OS: ${osDisplay}`,
`Listening mode: ${meta ? getDiagnosticListeningMode(meta) : "unknown"}`,
`Bind host: ${meta?.host ?? "unknown"}`,
`Local URL: ${meta?.localUrl ?? "unknown"}`,
`Local port: ${meta?.localPort ?? "unknown"}`,
`Remote URL: ${meta?.remoteUrl ?? "none"}`,
`Remote port: ${meta?.remotePort ?? "none"}`,
`Workspace root: ${meta?.workspaceRoot ?? "unknown"}`,
`Candidate addresses: ${addresses.length}`,
]

for (const address of addresses) {
lines.push(`- ${address.family}/${address.scope}: ${address.remoteUrl}`)
}

lines.push("")
return lines.join("\n")
}
172 changes: 118 additions & 54 deletions packages/ui/src/components/settings/info-settings-section.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { createEffect, createMemo, createResource, createSignal, onCleanup, type Component } from "solid-js"
import { Info } from "lucide-solid"
import { createEffect, createMemo, createResource, createSignal, For, onCleanup, Show, type Component } from "solid-js"
import { Info, Network } from "lucide-solid"
import { copyToClipboard } from "../../lib/clipboard"
import { useI18n } from "../../lib/i18n"
import { getServerMeta } from "../../lib/server-meta"
import { runtimeEnv } from "../../lib/runtime-env"
import type { ServerMeta } from "../../../../server/src/api-types"
import { canOpenRemoteWindows, runtimeEnv } from "../../lib/runtime-env"
import { openSettings } from "../../stores/settings-screen"
import { buildDiagnosticReport, getDiagnosticAddresses, getDiagnosticListeningMode } from "./info-settings-diagnostics"

interface UserAgentData {
platform?: string
Expand Down Expand Up @@ -62,36 +64,6 @@ async function resolveArchitecture(): Promise<string | null> {
}
}

function buildDiagnosticReport(
meta: ServerMeta | null,
osDisplay: string,
): string {
const lines: string[] = []
lines.push("CodeNomad Diagnostic Report")
lines.push("============================")
lines.push(`Generated: ${new Date().toISOString()}`)
lines.push(`Server version: ${meta?.serverVersion ?? "unknown"}`)
lines.push(`UI version: ${meta?.ui?.version ?? "unknown"} (source: ${meta?.ui?.source ?? "unknown"})`)
lines.push(`Runtime: ${runtimeEnv.host}`)
lines.push(`Platform: ${runtimeEnv.platform}`)
lines.push(`Window context: ${runtimeEnv.windowContext}`)
lines.push(`OS: ${osDisplay}`)
lines.push(`Server URL: ${meta?.localUrl ?? "unknown"}`)
lines.push(`Workspace root: ${meta?.workspaceRoot ?? "unknown"}`)
lines.push(`UI source: ${meta?.ui?.source ?? "unknown"}`)
lines.push("")
return lines.join("\n")
}

async function copyToClipboard(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
return false
}
}

function downloadTextFile(filename: string, text: string) {
const blob = new Blob([text], { type: "text/plain;charset=utf-8" })
const url = URL.createObjectURL(blob)
Expand Down Expand Up @@ -122,7 +94,16 @@ function versionNewer(current: string, latest: string): boolean | null {

export const InfoSettingsSection: Component = () => {
const { t } = useI18n()
const [meta, { mutate }] = createResource(() => getServerMeta())
const [metaLoadFailed, setMetaLoadFailed] = createSignal(false)
const [meta, { mutate }] = createResource(async () => {
setMetaLoadFailed(false)
try {
return await getServerMeta()
} catch {
setMetaLoadFailed(true)
return null
}
})
const [copyFeedback, setCopyFeedback] = createSignal<"success" | "error" | null>(null)
const [osArch, setOsArch] = createSignal<string | null>(null)

Expand Down Expand Up @@ -171,8 +152,13 @@ export const InfoSettingsSection: Component = () => {
onCleanup(() => clearTimeout(feedbackTimer))

const handleRefresh = async () => {
const fresh = await getServerMeta(true)
mutate(fresh)
setMetaLoadFailed(false)
try {
const fresh = await getServerMeta(true)
mutate(fresh)
} catch {
setMetaLoadFailed(true)
}
}

const osDisplay = createMemo(() => {
Expand All @@ -182,14 +168,14 @@ export const InfoSettingsSection: Component = () => {
})

const handleCopy = async () => {
const report = buildDiagnosticReport(meta() ?? null, osDisplay())
const report = buildDiagnosticReport(meta() ?? null, osDisplay(), runtimeEnv)
const ok = await copyToClipboard(report)
if (ok) setCopyFeedback("success")
else setCopyFeedback("error")
}

const handleDownload = () => {
const report = buildDiagnosticReport(meta() ?? null, osDisplay())
const report = buildDiagnosticReport(meta() ?? null, osDisplay(), runtimeEnv)
const ts = new Date().toISOString().replace(/[:.]/g, "-")
downloadTextFile(`codenomad-diagnostics-${ts}.txt`, report)
}
Expand All @@ -208,10 +194,6 @@ export const InfoSettingsSection: Component = () => {
</div>

<div class="settings-info-grid">
<div class="settings-info-row">
<span class="settings-info-label">{t("settings.info.version.server")}</span>
<span class="settings-info-value">{meta()?.serverVersion ?? "—"}</span>
</div>
<div class="settings-info-row">
<span class="settings-info-label">{t("settings.info.version.ui")}</span>
<span class="settings-info-value">{meta()?.ui?.version ?? "—"}</span>
Expand All @@ -234,19 +216,99 @@ export const InfoSettingsSection: Component = () => {
<span class="settings-info-label">{t("settings.info.runtime.os")}</span>
<span class="settings-info-value settings-info-value-muted">{osDisplay()}</span>
</div>
<div class="settings-info-row">
<span class="settings-info-label">{t("settings.info.server.url")}</span>
<span class="settings-info-value settings-info-value-muted">
{meta()?.localUrl ?? "—"}
</span>
</div>
<div class="settings-info-row">
<span class="settings-info-label">{t("settings.info.server.root")}</span>
<span class="settings-info-value settings-info-value-muted">
{meta()?.workspaceRoot ?? "—"}
</span>
</div>
</div>

<div class="settings-card">
<div class="settings-card-header">
<div class="settings-card-heading-with-icon">
<Network class="settings-card-heading-icon" />
<div>
<h3 class="settings-card-title">{t("settings.info.connectivity.title")}</h3>
<p class="settings-card-subtitle">{t("settings.info.connectivity.subtitle")}</p>
</div>
</div>
</div>

<Show when={meta.loading}>
<div class="settings-card-message" role="status" aria-live="polite">
{t("remoteAccess.addresses.loading")}
</div>
</Show>
<Show when={metaLoadFailed()}>
<div class="settings-error-message" role="alert">
{t("settings.info.connectivity.loadFailed")}
</div>
</Show>
<Show when={meta()}>
{(serverMeta) => (
<>
<dl class="settings-info-grid">
<div class="settings-info-row">
<dt class="settings-info-label">{t("settings.info.version.server")}</dt>
<dd class="settings-info-value" dir="ltr">{serverMeta().serverVersion ?? "—"}</dd>
</div>
<div class="settings-info-row">
<dt class="settings-info-label">{t("remoteAccess.sections.listeningMode.label")}</dt>
<dd class="settings-info-value">
{t(getDiagnosticListeningMode(serverMeta()) === "specific"
? "settings.info.connectivity.mode.specific"
: getDiagnosticListeningMode(serverMeta()) === "all"
? "settings.info.connectivity.mode.all"
: "settings.info.connectivity.mode.local")}
</dd>
</div>
<div class="settings-info-row">
<dt class="settings-info-label">{t("settings.info.connectivity.host")}</dt>
<dd class="settings-info-value settings-info-value-muted" dir="ltr">{serverMeta().host}</dd>
</div>
<div class="settings-info-row">
<dt class="settings-info-label">{t("settings.info.connectivity.localListener")}</dt>
<dd class="settings-info-value settings-info-value-muted" dir="ltr">{serverMeta().localUrl}</dd>
</div>
<Show when={serverMeta().remoteUrl}>
{(remoteUrl) => (
<div class="settings-info-row">
<dt class="settings-info-label">{t("settings.info.connectivity.remoteListener")}</dt>
<dd class="settings-info-value settings-info-value-muted" dir="ltr">{remoteUrl()}</dd>
</div>
)}
</Show>
<div class="settings-info-row">
<dt class="settings-info-label">{t("settings.info.server.root")}</dt>
<dd class="settings-info-value settings-info-value-muted" dir="ltr">{serverMeta().workspaceRoot}</dd>
</div>
</dl>

<h4 class="settings-card-title">{t("remoteAccess.sections.addresses.label")}</h4>
<Show when={getDiagnosticAddresses(serverMeta()).length > 0} fallback={<div class="settings-card-message">{t("remoteAccess.addresses.none")}</div>}>
<dl class="settings-info-grid">
<For each={getDiagnosticAddresses(serverMeta())}>{(address) => (
<div class="settings-info-row">
<dt class="settings-info-label">
{address.family.toUpperCase()} · {t(address.scope === "external"
? "remoteAccess.address.scope.network"
: address.scope === "internal"
? "remoteAccess.address.scope.internal"
: "remoteAccess.address.scope.loopback")}
</dt>
<dd class="settings-info-value settings-info-value-muted" dir="ltr">{address.remoteUrl}</dd>
</div>
)}</For>
</dl>
</Show>
<p class="settings-help-text">{t("settings.info.connectivity.disclaimer")}</p>

<Show when={canOpenRemoteWindows()}>
<div class="settings-info-actions">
<button type="button" class="settings-pill-button" onClick={() => openSettings("remote")}>
{t("settings.nav.remote")}
</button>
</div>
</Show>
</>
)}
</Show>
</div>

<div class="settings-card">
Expand Down Expand Up @@ -300,6 +362,8 @@ export const InfoSettingsSection: Component = () => {
</div>
</div>

<div class="settings-card-message">{t("settings.info.diagnostics.privacy")}</div>

<div class="settings-info-actions">
<button
type="button"
Expand Down
Loading
Loading