Skip to content

Commit 1295cfa

Browse files
OfflinePingshantur
andauthored
feat(settings): add Info section with version, runtime, and diagnostics (#413)
## Summary Adds a new **Info** section to the Settings screen, giving users quick visibility into their CodeNomad version, runtime environment, and a way to collect diagnostic data for bug reports. Closes #412 ## Changes - **New component:** `info-settings-section.tsx` — renders three cards: - **About** — Server/UI version, runtime type (electron/tauri/web), platform, OS with CPU architecture detection, server URL, workspace root - **Updates** — Placeholder "Check for updates" button, ready to wire into the existing dev release monitor (`serverMeta.update`) - **Diagnostics** — Log scope dropdown (Summary only / Summary + workspace logs), Copy to clipboard, Download .txt — generates a structured diagnostic report - **New styles:** `settings-info.css` — info row layout, select row, toast feedback, update note - **Wiring:** Added `"info"` to `SettingsSectionId` union, registered nav item and routing case in `settings-screen.tsx`, imported CSS in `controls.css` - **i18n:** 20 new keys added to all 7 locales (English canonical, others fall back via existing fallback chain) ## Design decisions - No server changes needed — version/runtime info comes from existing `GET /api/meta` endpoint and client-side `navigator` detection - No sensitive data (API keys, env values) included in diagnostic reports - CPU architecture extracted from `navigator.userAgent` for Linux/Windows/macOS - Log scope dropdown uses existing Kobalte Select pattern for consistency ## Verification - `tsc --noEmit` passes (0 errors) - `vite build` bundles successfully - Tauri Rust backend compiles and starts correctly --- > **Note:** The majority of this implementation was produced through CodeNomad using DeepSeek v4 Pro and has undergone manual review before submission. All design, architecture, and code quality decisions were evaluated by a human reviewer. --------- Co-authored-by: Shantur Rathore <i@shantur.com>
1 parent 1dbb4a9 commit 1295cfa

12 files changed

Lines changed: 558 additions & 2 deletions

File tree

packages/ui/src/components/settings-screen.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Dialog } from "@kobalte/core/dialog"
2-
import { Settings, Bell, MonitorUp, Paintbrush, Terminal, Volume2, Globe, X } from "lucide-solid"
2+
import { Settings, Bell, Info, MonitorUp, Paintbrush, Terminal, Volume2, Globe, X } from "lucide-solid"
33
import { createMemo, For, type Component } from "solid-js"
44
import { useI18n } from "../lib/i18n"
55
import {
@@ -10,6 +10,7 @@ import {
1010
type SettingsSectionId,
1111
} from "../stores/settings-screen"
1212
import { AppearanceSettingsSection } from "./settings/appearance-settings-section"
13+
import { InfoSettingsSection } from "./settings/info-settings-section"
1314
import { NotificationsSettingsSection } from "./settings/notifications-settings-section"
1415
import { OpenCodeSettingsSection } from "./settings/opencode-settings-section"
1516
import { RemoteAccessSettingsSection } from "./settings/remote-access-settings-section"
@@ -27,6 +28,7 @@ export const SettingsScreen: Component = () => {
2728
{ id: "speech" as SettingsSectionId, icon: Volume2, label: t("settings.nav.speech") },
2829
{ id: "sidecars" as SettingsSectionId, icon: Globe, label: t("settings.nav.sidecars") },
2930
{ id: "opencode" as SettingsSectionId, icon: Terminal, label: t("settings.nav.opencode") },
31+
{ id: "info" as SettingsSectionId, icon: Info, label: t("settings.nav.info") },
3032
]
3133

3234
if (canOpenRemoteWindows()) {
@@ -48,6 +50,8 @@ export const SettingsScreen: Component = () => {
4850
return <SideCarsSettingsSection />
4951
case "opencode":
5052
return <OpenCodeSettingsSection />
53+
case "info":
54+
return <InfoSettingsSection />
5155
case "appearance":
5256
default:
5357
return <AppearanceSettingsSection />
Lines changed: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,333 @@
1+
import { createEffect, createMemo, createResource, createSignal, onCleanup, type Component } from "solid-js"
2+
import { Info } from "lucide-solid"
3+
import { useI18n } from "../../lib/i18n"
4+
import { getServerMeta } from "../../lib/server-meta"
5+
import { runtimeEnv } from "../../lib/runtime-env"
6+
import type { ServerMeta } from "../../../../server/src/api-types"
7+
8+
interface UserAgentData {
9+
platform?: string
10+
getHighEntropyValues?: (hints: string[]) => Promise<Record<string, string>>
11+
}
12+
13+
function getUserAgentData(): UserAgentData | undefined {
14+
return (navigator as any).userAgentData
15+
}
16+
17+
function detectOs(): string {
18+
if (typeof navigator === "undefined") return "Unknown"
19+
20+
const uaData = getUserAgentData()
21+
if (uaData?.platform) {
22+
const arch = extractArchFromUA(navigator.userAgent)
23+
return arch ? `${uaData.platform} ${arch}` : uaData.platform
24+
}
25+
26+
const ua = navigator.userAgent
27+
const p = navigator.platform
28+
if (!p) return "Unknown"
29+
30+
const maybeArch = extractArchFromUA(ua)
31+
if (maybeArch && !p.includes(maybeArch)) {
32+
return `${p} ${maybeArch}`
33+
}
34+
return p
35+
}
36+
37+
function extractArchFromUA(ua: string): string | null {
38+
const match = ua.match(/Linux\s+(x86_64|aarch64|armv[0-9]+[a-z]*|i[3-6]86)/i)
39+
?? ua.match(/Win64;\s*(x64|arm64)/i)
40+
?? ua.match(/Mac\s*OS\s*X[^)]*?_(x86_64|arm64)/i)
41+
return match ? match[1] : null
42+
}
43+
44+
async function resolveArchitecture(): Promise<string | null> {
45+
try {
46+
const uaData = getUserAgentData()
47+
if (!uaData?.getHighEntropyValues) return null
48+
const values = await uaData.getHighEntropyValues(["architecture", "bitness"])
49+
const parts: string[] = []
50+
if (values.architecture && !values.architecture.startsWith("x86")) {
51+
parts.push(values.architecture)
52+
}
53+
if (values.bitness && values.bitness !== "64") {
54+
parts.push(`${values.bitness}-bit`)
55+
}
56+
if (!parts.length && values.architecture) {
57+
parts.push(values.architecture)
58+
}
59+
return parts.length > 0 ? parts.join(" ") : null
60+
} catch {
61+
return null
62+
}
63+
}
64+
65+
function buildDiagnosticReport(
66+
meta: ServerMeta | null,
67+
osDisplay: string,
68+
): string {
69+
const lines: string[] = []
70+
lines.push("CodeNomad Diagnostic Report")
71+
lines.push("============================")
72+
lines.push(`Generated: ${new Date().toISOString()}`)
73+
lines.push(`Server version: ${meta?.serverVersion ?? "unknown"}`)
74+
lines.push(`UI version: ${meta?.ui?.version ?? "unknown"} (source: ${meta?.ui?.source ?? "unknown"})`)
75+
lines.push(`Runtime: ${runtimeEnv.host}`)
76+
lines.push(`Platform: ${runtimeEnv.platform}`)
77+
lines.push(`Window context: ${runtimeEnv.windowContext}`)
78+
lines.push(`OS: ${osDisplay}`)
79+
lines.push(`Server URL: ${meta?.localUrl ?? "unknown"}`)
80+
lines.push(`Workspace root: ${meta?.workspaceRoot ?? "unknown"}`)
81+
lines.push(`UI source: ${meta?.ui?.source ?? "unknown"}`)
82+
lines.push("")
83+
return lines.join("\n")
84+
}
85+
86+
async function copyToClipboard(text: string): Promise<boolean> {
87+
try {
88+
await navigator.clipboard.writeText(text)
89+
return true
90+
} catch {
91+
return false
92+
}
93+
}
94+
95+
function downloadTextFile(filename: string, text: string) {
96+
const blob = new Blob([text], { type: "text/plain;charset=utf-8" })
97+
const url = URL.createObjectURL(blob)
98+
const anchor = document.createElement("a")
99+
anchor.href = url
100+
anchor.download = filename
101+
document.body.appendChild(anchor)
102+
anchor.click()
103+
document.body.removeChild(anchor)
104+
URL.revokeObjectURL(url)
105+
}
106+
107+
function extractReleasePrefix(version: string): string {
108+
return version.replace(/^v/, "").split("-")[0]
109+
}
110+
111+
function versionNewer(current: string, latest: string): boolean | null {
112+
const c = extractReleasePrefix(current).split(".").map(Number)
113+
const l = extractReleasePrefix(latest).split(".").map(Number)
114+
if (c.some(isNaN) || l.some(isNaN)) return null
115+
if (l[0] > c[0]) return true
116+
if (l[0] < c[0]) return false
117+
if (l[1] > c[1]) return true
118+
if (l[1] < c[1]) return false
119+
if (l[2] > c[2]) return true
120+
return false
121+
}
122+
123+
export const InfoSettingsSection: Component = () => {
124+
const { t } = useI18n()
125+
const [meta, { mutate }] = createResource(() => getServerMeta())
126+
const [copyFeedback, setCopyFeedback] = createSignal<"success" | "error" | null>(null)
127+
const [osArch, setOsArch] = createSignal<string | null>(null)
128+
129+
createEffect(() => {
130+
resolveArchitecture().then((arch) => {
131+
if (arch) setOsArch(arch)
132+
})
133+
})
134+
135+
const updateInfo = createMemo(() => {
136+
const m = meta()
137+
if (!m?.update) return null
138+
return m.update
139+
})
140+
141+
const supportInfo = createMemo(() => meta()?.support ?? null)
142+
143+
const latestVersion = createMemo(() => {
144+
const update = updateInfo()
145+
if (update?.version) return update.version
146+
return supportInfo()?.latestServerVersion ?? null
147+
})
148+
149+
const showDownloadLink = createMemo(() => {
150+
let url: string | null = null
151+
const update = updateInfo()
152+
if (update?.url) url = update.url
153+
else if (supportInfo()?.latestServerUrl) url = supportInfo()!.latestServerUrl ?? null
154+
if (!url) return { url: null, show: false }
155+
if (update?.url) return { url, show: true }
156+
const current = meta()?.serverVersion
157+
const latest = latestVersion()
158+
if (!current || !latest) return { url: null, show: false }
159+
return { url, show: versionNewer(current, latest) !== false }
160+
})
161+
162+
let feedbackTimer: ReturnType<typeof setTimeout> | undefined
163+
164+
createEffect(() => {
165+
if (copyFeedback()) {
166+
clearTimeout(feedbackTimer)
167+
feedbackTimer = setTimeout(() => setCopyFeedback(null), 2500)
168+
}
169+
})
170+
171+
onCleanup(() => clearTimeout(feedbackTimer))
172+
173+
const handleRefresh = async () => {
174+
const fresh = await getServerMeta(true)
175+
mutate(fresh)
176+
}
177+
178+
const osDisplay = createMemo(() => {
179+
const base = detectOs()
180+
const arch = osArch()
181+
return arch ? `${base} (${arch})` : base
182+
})
183+
184+
const handleCopy = async () => {
185+
const report = buildDiagnosticReport(meta() ?? null, osDisplay())
186+
const ok = await copyToClipboard(report)
187+
if (ok) setCopyFeedback("success")
188+
else setCopyFeedback("error")
189+
}
190+
191+
const handleDownload = () => {
192+
const report = buildDiagnosticReport(meta() ?? null, osDisplay())
193+
const ts = new Date().toISOString().replace(/[:.]/g, "-")
194+
downloadTextFile(`codenomad-diagnostics-${ts}.txt`, report)
195+
}
196+
197+
return (
198+
<div class="settings-section-stack">
199+
<div class="settings-card">
200+
<div class="settings-card-header">
201+
<div class="settings-card-heading-with-icon">
202+
<Info class="settings-card-heading-icon" />
203+
<div>
204+
<h3 class="settings-card-title">{t("settings.section.info.title")}</h3>
205+
<p class="settings-card-subtitle">{t("settings.section.info.subtitle")}</p>
206+
</div>
207+
</div>
208+
</div>
209+
210+
<div class="settings-info-grid">
211+
<div class="settings-info-row">
212+
<span class="settings-info-label">{t("settings.info.version.server")}</span>
213+
<span class="settings-info-value">{meta()?.serverVersion ?? "—"}</span>
214+
</div>
215+
<div class="settings-info-row">
216+
<span class="settings-info-label">{t("settings.info.version.ui")}</span>
217+
<span class="settings-info-value">{meta()?.ui?.version ?? "—"}</span>
218+
</div>
219+
<div class="settings-info-row">
220+
<span class="settings-info-label">{t("settings.info.version.uiSource")}</span>
221+
<span class="settings-info-value settings-info-value-muted">
222+
{meta()?.ui?.source ?? "—"}
223+
</span>
224+
</div>
225+
<div class="settings-info-row">
226+
<span class="settings-info-label">{t("settings.info.runtime.type")}</span>
227+
<span class="settings-info-value">{runtimeEnv.host}</span>
228+
</div>
229+
<div class="settings-info-row">
230+
<span class="settings-info-label">{t("settings.info.runtime.platform")}</span>
231+
<span class="settings-info-value">{runtimeEnv.platform}</span>
232+
</div>
233+
<div class="settings-info-row">
234+
<span class="settings-info-label">{t("settings.info.runtime.os")}</span>
235+
<span class="settings-info-value settings-info-value-muted">{osDisplay()}</span>
236+
</div>
237+
<div class="settings-info-row">
238+
<span class="settings-info-label">{t("settings.info.server.url")}</span>
239+
<span class="settings-info-value settings-info-value-muted">
240+
{meta()?.localUrl ?? "—"}
241+
</span>
242+
</div>
243+
<div class="settings-info-row">
244+
<span class="settings-info-label">{t("settings.info.server.root")}</span>
245+
<span class="settings-info-value settings-info-value-muted">
246+
{meta()?.workspaceRoot ?? "—"}
247+
</span>
248+
</div>
249+
</div>
250+
</div>
251+
252+
<div class="settings-card">
253+
<div class="settings-card-header">
254+
<div>
255+
<h3 class="settings-card-title">{t("settings.info.updates.title")}</h3>
256+
<p class="settings-card-subtitle">{t("settings.info.updates.subtitle")}</p>
257+
</div>
258+
</div>
259+
260+
<div class="settings-info-grid">
261+
<div class="settings-info-row">
262+
<span class="settings-info-label">{t("settings.info.version.server")}</span>
263+
<span class="settings-info-value">{meta()?.serverVersion ?? "—"}</span>
264+
</div>
265+
<div class="settings-info-row">
266+
<span class="settings-info-label">{t("settings.info.updates.latest")}</span>
267+
<span class="settings-info-value settings-info-value-muted">
268+
{latestVersion() ?? "—"}
269+
</span>
270+
</div>
271+
</div>
272+
273+
<div class="settings-info-actions">
274+
{showDownloadLink().show && (
275+
<a
276+
href={showDownloadLink().url!}
277+
target="_blank"
278+
rel="noopener noreferrer"
279+
class="settings-pill-button"
280+
>
281+
{t("settings.info.updates.download")}
282+
</a>
283+
)}
284+
<button
285+
type="button"
286+
class="settings-pill-button"
287+
onClick={handleRefresh}
288+
disabled={meta.loading}
289+
>
290+
{t("settings.info.updates.refresh")}
291+
</button>
292+
</div>
293+
</div>
294+
295+
<div class="settings-card">
296+
<div class="settings-card-header">
297+
<div>
298+
<h3 class="settings-card-title">{t("settings.info.diagnostics.title")}</h3>
299+
<p class="settings-card-subtitle">{t("settings.info.diagnostics.subtitle")}</p>
300+
</div>
301+
</div>
302+
303+
<div class="settings-info-actions">
304+
<button
305+
type="button"
306+
class="settings-pill-button"
307+
onClick={handleCopy}
308+
>
309+
{t("settings.info.diagnostics.copy")}
310+
</button>
311+
<button
312+
type="button"
313+
class="settings-pill-button"
314+
onClick={handleDownload}
315+
>
316+
{t("settings.info.diagnostics.download")}
317+
</button>
318+
</div>
319+
320+
{copyFeedback() === "success" && (
321+
<div class="settings-info-toast" role="status" aria-live="polite">
322+
{t("settings.info.diagnostics.copied")}
323+
</div>
324+
)}
325+
{copyFeedback() === "error" && (
326+
<div class="settings-error-message" role="alert">
327+
{t("settings.info.diagnostics.copyFailed")}
328+
</div>
329+
)}
330+
</div>
331+
</div>
332+
)
333+
}

0 commit comments

Comments
 (0)