From 1dbb4a91e136fafcd783a9f2714aa64e7d9e8b1c Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Thu, 14 May 2026 19:33:25 +0100 Subject: [PATCH 01/43] fix(ui): support draft prompt command sessions (#446) ## Summary - Show attachments added to the no-session draft prompt before a session exists. - Create and activate a real session before first-prompt slash commands or shell commands execute. - Keep existing session command behavior unchanged by adding an optional PromptInput command handler. ## Validation - npm run typecheck --workspace @codenomad/ui --- .../components/instance/instance-shell2.tsx | 49 ++++++++++++++++++- packages/ui/src/components/prompt-input.tsx | 6 ++- .../ui/src/components/prompt-input/types.ts | 1 + 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index f23ec071b..42b98c65c 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -29,6 +29,7 @@ import PermissionNotificationBanner from "../permission-notification-banner" import PermissionApprovalModal from "../permission-approval-modal" import SessionView from "../session/session-view" import MessageSection from "../message-section" +import PromptAttachmentsBar from "../prompt-input/PromptAttachmentsBar" import { formatTokenTotal } from "../../lib/formatters" import ContextMeter from "../context-meter" import { sseManager } from "../../lib/sse-manager" @@ -50,7 +51,8 @@ import type { Attachment } from "../../types/attachment" import { setAgentModelPreference, useConfig } from "../../stores/preferences" import { showPromptDialog } from "../../stores/alerts" import { openSessionPreview, sessionPreviews, showSessionChat, showSessionPreview } from "../../stores/session-previews" -import { createSession, getDefaultModel, providers, sendMessage, setActiveParentSession, updateSessionModel } from "../../stores/sessions" +import { createSession, executeCustomCommand, getDefaultModel, providers, runShellCommand, sendMessage, setActiveParentSession, updateSessionModel } from "../../stores/sessions" +import { getAttachments, removeAttachment } from "../../stores/attachments" import type { LayoutMode } from "./shell/types" import { @@ -123,6 +125,7 @@ const InstanceShell2: Component = (props) => { const [draftAgent, setDraftAgent] = createSignal("") const [draftModel, setDraftModel] = createSignal({ providerId: "", modelId: "" }) const [draftModelManuallySelected, setDraftModelManuallySelected] = createSignal(false) + const [draftPromptInputApi, setDraftPromptInputApi] = createSignal(null) // Worktree selector manages its own dialogs. const [showSessionSearch, setShowSessionSearch] = createSignal(false) @@ -805,7 +808,16 @@ const InstanceShell2: Component = (props) => { setDraftModelManuallySelected(true) } - async function handleFirstPromptSend(prompt: string, attachments: Attachment[]) { + const draftAttachments = createMemo(() => getAttachments(props.instance.id, NO_SESSION_DRAFT_SESSION_ID)) + + function registerDraftPromptInputApi(api: PromptInputApi) { + setDraftPromptInputApi(api) + return () => { + setDraftPromptInputApi((current) => (current === api ? null : current)) + } + } + + async function createAndActivateDraftSession() { const agent = draftAgent() const model = draftModel() if (agent && model.providerId && model.modelId) { @@ -816,9 +828,24 @@ const InstanceShell2: Component = (props) => { await updateSessionModel(props.instance.id, session.id, model) } setActiveParentSession(props.instance.id, session.id) + return session + } + + async function handleFirstPromptSend(prompt: string, attachments: Attachment[]) { + const session = await createAndActivateDraftSession() await sendMessage(props.instance.id, session.id, prompt, attachments) } + async function handleFirstPromptCommand(commandName: string, args: string) { + const session = await createAndActivateDraftSession() + await executeCustomCommand(props.instance.id, session.id, commandName, args) + } + + async function handleFirstPromptShell(command: string) { + const session = await createAndActivateDraftSession() + await runShellCommand(props.instance.id, session.id, command) + } + const sessionLayout = (
= (props) => { forceCompactStatusLayout={showEmbeddedSidebarToggle()} /> + 0}> + { + const api = draftPromptInputApi() + if (api) { + api.removeAttachment(attachmentId) + return + } + removeAttachment(props.instance.id, NO_SESSION_DRAFT_SESSION_ID, attachmentId) + }} + onExpandTextAttachment={(attachmentId) => draftPromptInputApi()?.expandTextAttachment(attachmentId)} + /> + + = (props) => { isActive={props.isActiveInstance} compactLayout={compactPromptLayout()} onSend={handleFirstPromptSend} + onCommand={handleFirstPromptCommand} + onRunShell={handleFirstPromptShell} escapeInDebounce={props.escapeInDebounce} + registerPromptInputApi={registerDraftPromptInputApi} />
} diff --git a/packages/ui/src/components/prompt-input.tsx b/packages/ui/src/components/prompt-input.tsx index afd30eb5a..c53939613 100644 --- a/packages/ui/src/components/prompt-input.tsx +++ b/packages/ui/src/components/prompt-input.tsx @@ -357,7 +357,11 @@ export default function PromptInput(props: PromptInputProps) { await props.onSend(resolvedPrompt, []) } } else if (isKnownSlashCommand) { - await executeCustomCommand(props.instanceId, props.sessionId, commandName, resolvedCommandArgs) + if (props.onCommand) { + await props.onCommand(commandName, resolvedCommandArgs) + } else { + await executeCustomCommand(props.instanceId, props.sessionId, commandName, resolvedCommandArgs) + } } else { await props.onSend(resolvedPrompt, currentAttachments) } diff --git a/packages/ui/src/components/prompt-input/types.ts b/packages/ui/src/components/prompt-input/types.ts index 4f2bcffde..1ad62e6a2 100644 --- a/packages/ui/src/components/prompt-input/types.ts +++ b/packages/ui/src/components/prompt-input/types.ts @@ -25,6 +25,7 @@ export interface PromptInputProps { // Phone/tablet layouts should keep the expanded prompt more compact. compactLayout?: boolean onSend: (prompt: string, attachments: Attachment[]) => Promise + onCommand?: (commandName: string, args: string) => Promise onRunShell?: (command: string) => Promise disabled?: boolean escapeInDebounce?: boolean From 1295cfabcabaeebeb0d5c8605a9c596d8ad98afe Mon Sep 17 00:00:00 2001 From: Claas Pancratius <54213976+OfflinePing@users.noreply.github.com> Date: Fri, 15 May 2026 15:09:03 +0200 Subject: [PATCH 02/43] feat(settings): add Info section with version, runtime, and diagnostics (#413) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- .../ui/src/components/settings-screen.tsx | 6 +- .../settings/info-settings-section.tsx | 333 ++++++++++++++++++ .../ui/src/lib/i18n/messages/en/settings.ts | 23 ++ .../ui/src/lib/i18n/messages/es/settings.ts | 23 ++ .../ui/src/lib/i18n/messages/fr/settings.ts | 23 ++ .../ui/src/lib/i18n/messages/he/settings.ts | 23 ++ .../ui/src/lib/i18n/messages/ja/settings.ts | 23 ++ .../ui/src/lib/i18n/messages/ru/settings.ts | 23 ++ .../src/lib/i18n/messages/zh-Hans/settings.ts | 23 ++ packages/ui/src/stores/settings-screen.ts | 2 +- .../src/styles/components/settings-info.css | 57 +++ packages/ui/src/styles/controls.css | 1 + 12 files changed, 558 insertions(+), 2 deletions(-) create mode 100644 packages/ui/src/components/settings/info-settings-section.tsx create mode 100644 packages/ui/src/styles/components/settings-info.css diff --git a/packages/ui/src/components/settings-screen.tsx b/packages/ui/src/components/settings-screen.tsx index d65e56530..921855bcc 100644 --- a/packages/ui/src/components/settings-screen.tsx +++ b/packages/ui/src/components/settings-screen.tsx @@ -1,5 +1,5 @@ import { Dialog } from "@kobalte/core/dialog" -import { Settings, Bell, MonitorUp, Paintbrush, Terminal, Volume2, Globe, X } from "lucide-solid" +import { Settings, Bell, Info, MonitorUp, Paintbrush, Terminal, Volume2, Globe, X } from "lucide-solid" import { createMemo, For, type Component } from "solid-js" import { useI18n } from "../lib/i18n" import { @@ -10,6 +10,7 @@ import { type SettingsSectionId, } from "../stores/settings-screen" import { AppearanceSettingsSection } from "./settings/appearance-settings-section" +import { InfoSettingsSection } from "./settings/info-settings-section" import { NotificationsSettingsSection } from "./settings/notifications-settings-section" import { OpenCodeSettingsSection } from "./settings/opencode-settings-section" import { RemoteAccessSettingsSection } from "./settings/remote-access-settings-section" @@ -27,6 +28,7 @@ export const SettingsScreen: Component = () => { { id: "speech" as SettingsSectionId, icon: Volume2, label: t("settings.nav.speech") }, { id: "sidecars" as SettingsSectionId, icon: Globe, label: t("settings.nav.sidecars") }, { id: "opencode" as SettingsSectionId, icon: Terminal, label: t("settings.nav.opencode") }, + { id: "info" as SettingsSectionId, icon: Info, label: t("settings.nav.info") }, ] if (canOpenRemoteWindows()) { @@ -48,6 +50,8 @@ export const SettingsScreen: Component = () => { return case "opencode": return + case "info": + return case "appearance": default: return diff --git a/packages/ui/src/components/settings/info-settings-section.tsx b/packages/ui/src/components/settings/info-settings-section.tsx new file mode 100644 index 000000000..cc1f19371 --- /dev/null +++ b/packages/ui/src/components/settings/info-settings-section.tsx @@ -0,0 +1,333 @@ +import { createEffect, createMemo, createResource, createSignal, onCleanup, type Component } from "solid-js" +import { Info } from "lucide-solid" +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" + +interface UserAgentData { + platform?: string + getHighEntropyValues?: (hints: string[]) => Promise> +} + +function getUserAgentData(): UserAgentData | undefined { + return (navigator as any).userAgentData +} + +function detectOs(): string { + if (typeof navigator === "undefined") return "Unknown" + + const uaData = getUserAgentData() + if (uaData?.platform) { + const arch = extractArchFromUA(navigator.userAgent) + return arch ? `${uaData.platform} ${arch}` : uaData.platform + } + + const ua = navigator.userAgent + const p = navigator.platform + if (!p) return "Unknown" + + const maybeArch = extractArchFromUA(ua) + if (maybeArch && !p.includes(maybeArch)) { + return `${p} ${maybeArch}` + } + return p +} + +function extractArchFromUA(ua: string): string | null { + const match = ua.match(/Linux\s+(x86_64|aarch64|armv[0-9]+[a-z]*|i[3-6]86)/i) + ?? ua.match(/Win64;\s*(x64|arm64)/i) + ?? ua.match(/Mac\s*OS\s*X[^)]*?_(x86_64|arm64)/i) + return match ? match[1] : null +} + +async function resolveArchitecture(): Promise { + try { + const uaData = getUserAgentData() + if (!uaData?.getHighEntropyValues) return null + const values = await uaData.getHighEntropyValues(["architecture", "bitness"]) + const parts: string[] = [] + if (values.architecture && !values.architecture.startsWith("x86")) { + parts.push(values.architecture) + } + if (values.bitness && values.bitness !== "64") { + parts.push(`${values.bitness}-bit`) + } + if (!parts.length && values.architecture) { + parts.push(values.architecture) + } + return parts.length > 0 ? parts.join(" ") : null + } catch { + return 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 { + 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) + const anchor = document.createElement("a") + anchor.href = url + anchor.download = filename + document.body.appendChild(anchor) + anchor.click() + document.body.removeChild(anchor) + URL.revokeObjectURL(url) +} + +function extractReleasePrefix(version: string): string { + return version.replace(/^v/, "").split("-")[0] +} + +function versionNewer(current: string, latest: string): boolean | null { + const c = extractReleasePrefix(current).split(".").map(Number) + const l = extractReleasePrefix(latest).split(".").map(Number) + if (c.some(isNaN) || l.some(isNaN)) return null + if (l[0] > c[0]) return true + if (l[0] < c[0]) return false + if (l[1] > c[1]) return true + if (l[1] < c[1]) return false + if (l[2] > c[2]) return true + return false +} + +export const InfoSettingsSection: Component = () => { + const { t } = useI18n() + const [meta, { mutate }] = createResource(() => getServerMeta()) + const [copyFeedback, setCopyFeedback] = createSignal<"success" | "error" | null>(null) + const [osArch, setOsArch] = createSignal(null) + + createEffect(() => { + resolveArchitecture().then((arch) => { + if (arch) setOsArch(arch) + }) + }) + + const updateInfo = createMemo(() => { + const m = meta() + if (!m?.update) return null + return m.update + }) + + const supportInfo = createMemo(() => meta()?.support ?? null) + + const latestVersion = createMemo(() => { + const update = updateInfo() + if (update?.version) return update.version + return supportInfo()?.latestServerVersion ?? null + }) + + const showDownloadLink = createMemo(() => { + let url: string | null = null + const update = updateInfo() + if (update?.url) url = update.url + else if (supportInfo()?.latestServerUrl) url = supportInfo()!.latestServerUrl ?? null + if (!url) return { url: null, show: false } + if (update?.url) return { url, show: true } + const current = meta()?.serverVersion + const latest = latestVersion() + if (!current || !latest) return { url: null, show: false } + return { url, show: versionNewer(current, latest) !== false } + }) + + let feedbackTimer: ReturnType | undefined + + createEffect(() => { + if (copyFeedback()) { + clearTimeout(feedbackTimer) + feedbackTimer = setTimeout(() => setCopyFeedback(null), 2500) + } + }) + + onCleanup(() => clearTimeout(feedbackTimer)) + + const handleRefresh = async () => { + const fresh = await getServerMeta(true) + mutate(fresh) + } + + const osDisplay = createMemo(() => { + const base = detectOs() + const arch = osArch() + return arch ? `${base} (${arch})` : base + }) + + const handleCopy = async () => { + const report = buildDiagnosticReport(meta() ?? null, osDisplay()) + const ok = await copyToClipboard(report) + if (ok) setCopyFeedback("success") + else setCopyFeedback("error") + } + + const handleDownload = () => { + const report = buildDiagnosticReport(meta() ?? null, osDisplay()) + const ts = new Date().toISOString().replace(/[:.]/g, "-") + downloadTextFile(`codenomad-diagnostics-${ts}.txt`, report) + } + + return ( +
+
+
+
+ +
+

{t("settings.section.info.title")}

+

{t("settings.section.info.subtitle")}

+
+
+
+ +
+
+ {t("settings.info.version.server")} + {meta()?.serverVersion ?? "—"} +
+
+ {t("settings.info.version.ui")} + {meta()?.ui?.version ?? "—"} +
+
+ {t("settings.info.version.uiSource")} + + {meta()?.ui?.source ?? "—"} + +
+
+ {t("settings.info.runtime.type")} + {runtimeEnv.host} +
+
+ {t("settings.info.runtime.platform")} + {runtimeEnv.platform} +
+
+ {t("settings.info.runtime.os")} + {osDisplay()} +
+
+ {t("settings.info.server.url")} + + {meta()?.localUrl ?? "—"} + +
+
+ {t("settings.info.server.root")} + + {meta()?.workspaceRoot ?? "—"} + +
+
+
+ +
+
+
+

{t("settings.info.updates.title")}

+

{t("settings.info.updates.subtitle")}

+
+
+ +
+
+ {t("settings.info.version.server")} + {meta()?.serverVersion ?? "—"} +
+
+ {t("settings.info.updates.latest")} + + {latestVersion() ?? "—"} + +
+
+ +
+ {showDownloadLink().show && ( + + {t("settings.info.updates.download")} + + )} + +
+
+ +
+
+
+

{t("settings.info.diagnostics.title")}

+

{t("settings.info.diagnostics.subtitle")}

+
+
+ +
+ + +
+ + {copyFeedback() === "success" && ( +
+ {t("settings.info.diagnostics.copied")} +
+ )} + {copyFeedback() === "error" && ( + + )} +
+
+ ) +} diff --git a/packages/ui/src/lib/i18n/messages/en/settings.ts b/packages/ui/src/lib/i18n/messages/en/settings.ts index 35f3999c0..79c271440 100644 --- a/packages/ui/src/lib/i18n/messages/en/settings.ts +++ b/packages/ui/src/lib/i18n/messages/en/settings.ts @@ -71,6 +71,7 @@ export const settingsMessages = { "settings.nav.remote": "Remote Access", "settings.nav.speech": "Speech", "settings.nav.opencode": "OpenCode", + "settings.nav.info": "Info", "settings.scope.device": "This device", "settings.scope.server": "Server setting", "settings.common.enabled": "Enabled", @@ -239,4 +240,26 @@ export const settingsMessages = { "sidecars.refresh": "Refresh", "sidecars.path": "Path", "sidecars.go": "Go", + + "settings.section.info.title": "About", + "settings.section.info.subtitle": "View version, runtime, and gather diagnostic information.", + "settings.info.version.server": "Server version", + "settings.info.version.ui": "UI version", + "settings.info.version.uiSource": "UI source", + "settings.info.runtime.type": "Runtime", + "settings.info.runtime.platform": "Platform", + "settings.info.runtime.os": "Operating system", + "settings.info.server.url": "Server URL", + "settings.info.server.root": "Workspace root", + "settings.info.updates.title": "Updates", + "settings.info.updates.subtitle": "Check for new releases of CodeNomad.", + "settings.info.updates.latest": "Latest version", + "settings.info.updates.download": "Download update", + "settings.info.updates.refresh": "Refresh", + "settings.info.diagnostics.copyFailed": "Clipboard access denied. Use the download button instead.", + "settings.info.diagnostics.title": "Diagnostics", + "settings.info.diagnostics.subtitle": "Collect system and version info for bug reports.", + "settings.info.diagnostics.copy": "Copy to clipboard", + "settings.info.diagnostics.download": "Download .txt", + "settings.info.diagnostics.copied": "Diagnostic info copied to clipboard.", } as const diff --git a/packages/ui/src/lib/i18n/messages/es/settings.ts b/packages/ui/src/lib/i18n/messages/es/settings.ts index a158ec511..44a6bacde 100644 --- a/packages/ui/src/lib/i18n/messages/es/settings.ts +++ b/packages/ui/src/lib/i18n/messages/es/settings.ts @@ -71,6 +71,7 @@ export const settingsMessages = { "settings.nav.remote": "Remote Access", "settings.nav.speech": "Speech", "settings.nav.opencode": "OpenCode", + "settings.nav.info": "Info", "settings.scope.device": "This device", "settings.scope.server": "Server setting", "settings.common.enabled": "Enabled", @@ -238,4 +239,26 @@ export const settingsMessages = { "sidecars.refresh": "Refresh", "sidecars.path": "Path", "sidecars.go": "Go", + + "settings.section.info.title": "About", + "settings.section.info.subtitle": "View version, runtime, and gather diagnostic information.", + "settings.info.version.server": "Server version", + "settings.info.version.ui": "UI version", + "settings.info.version.uiSource": "UI source", + "settings.info.runtime.type": "Runtime", + "settings.info.runtime.platform": "Platform", + "settings.info.runtime.os": "Operating system", + "settings.info.server.url": "Server URL", + "settings.info.server.root": "Workspace root", + "settings.info.updates.title": "Updates", + "settings.info.updates.subtitle": "Check for new releases of CodeNomad.", + "settings.info.updates.latest": "Latest version", + "settings.info.updates.download": "Download update", + "settings.info.updates.refresh": "Refresh", + "settings.info.diagnostics.copyFailed": "Clipboard access denied. Use the download button instead.", + "settings.info.diagnostics.title": "Diagnostics", + "settings.info.diagnostics.subtitle": "Recopila información del sistema y de la versión para informes de errores.", + "settings.info.diagnostics.copy": "Copy to clipboard", + "settings.info.diagnostics.download": "Download .txt", + "settings.info.diagnostics.copied": "Diagnostic info copied to clipboard.", } as const diff --git a/packages/ui/src/lib/i18n/messages/fr/settings.ts b/packages/ui/src/lib/i18n/messages/fr/settings.ts index 722292878..a462271f2 100644 --- a/packages/ui/src/lib/i18n/messages/fr/settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr/settings.ts @@ -71,6 +71,7 @@ export const settingsMessages = { "settings.nav.remote": "Remote Access", "settings.nav.speech": "Speech", "settings.nav.opencode": "OpenCode", + "settings.nav.info": "Info", "settings.scope.device": "This device", "settings.scope.server": "Server setting", "settings.common.enabled": "Enabled", @@ -238,4 +239,26 @@ export const settingsMessages = { "sidecars.refresh": "Refresh", "sidecars.path": "Path", "sidecars.go": "Go", + + "settings.section.info.title": "About", + "settings.section.info.subtitle": "View version, runtime, and gather diagnostic information.", + "settings.info.version.server": "Server version", + "settings.info.version.ui": "UI version", + "settings.info.version.uiSource": "UI source", + "settings.info.runtime.type": "Runtime", + "settings.info.runtime.platform": "Platform", + "settings.info.runtime.os": "Operating system", + "settings.info.server.url": "Server URL", + "settings.info.server.root": "Workspace root", + "settings.info.updates.title": "Updates", + "settings.info.updates.subtitle": "Check for new releases of CodeNomad.", + "settings.info.updates.latest": "Latest version", + "settings.info.updates.download": "Download update", + "settings.info.updates.refresh": "Refresh", + "settings.info.diagnostics.copyFailed": "Clipboard access denied. Use the download button instead.", + "settings.info.diagnostics.title": "Diagnostics", + "settings.info.diagnostics.subtitle": "Collectez les informations système et de version pour les rapports de bug.", + "settings.info.diagnostics.copy": "Copy to clipboard", + "settings.info.diagnostics.download": "Download .txt", + "settings.info.diagnostics.copied": "Diagnostic info copied to clipboard.", } as const diff --git a/packages/ui/src/lib/i18n/messages/he/settings.ts b/packages/ui/src/lib/i18n/messages/he/settings.ts index cc8b59044..b0c0e7f91 100644 --- a/packages/ui/src/lib/i18n/messages/he/settings.ts +++ b/packages/ui/src/lib/i18n/messages/he/settings.ts @@ -70,6 +70,7 @@ export const settingsMessages = { "settings.nav.notifications": "התראות", "settings.nav.remote": "גישה מרוחקת", "settings.nav.opencode": "OpenCode", + "settings.nav.info": "Info", "settings.scope.device": "מכשיר זה", "settings.scope.server": "הגדרת שרת", "settings.common.enabled": "מופעל", @@ -237,4 +238,26 @@ export const settingsMessages = { "sidecars.refresh": "Refresh", "sidecars.path": "Path", "sidecars.go": "Go", + + "settings.section.info.title": "About", + "settings.section.info.subtitle": "View version, runtime, and gather diagnostic information.", + "settings.info.version.server": "Server version", + "settings.info.version.ui": "UI version", + "settings.info.version.uiSource": "UI source", + "settings.info.runtime.type": "Runtime", + "settings.info.runtime.platform": "Platform", + "settings.info.runtime.os": "Operating system", + "settings.info.server.url": "Server URL", + "settings.info.server.root": "Workspace root", + "settings.info.updates.title": "Updates", + "settings.info.updates.subtitle": "Check for new releases of CodeNomad.", + "settings.info.updates.latest": "Latest version", + "settings.info.updates.download": "Download update", + "settings.info.updates.refresh": "Refresh", + "settings.info.diagnostics.copyFailed": "Clipboard access denied. Use the download button instead.", + "settings.info.diagnostics.title": "Diagnostics", + "settings.info.diagnostics.subtitle": "אסוף מידע על המערכת והגרסה לדיווחי באגים.", + "settings.info.diagnostics.copy": "Copy to clipboard", + "settings.info.diagnostics.download": "Download .txt", + "settings.info.diagnostics.copied": "Diagnostic info copied to clipboard.", } as const diff --git a/packages/ui/src/lib/i18n/messages/ja/settings.ts b/packages/ui/src/lib/i18n/messages/ja/settings.ts index f764acbfc..c69dd4023 100644 --- a/packages/ui/src/lib/i18n/messages/ja/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja/settings.ts @@ -71,6 +71,7 @@ export const settingsMessages = { "settings.nav.remote": "Remote Access", "settings.nav.speech": "Speech", "settings.nav.opencode": "OpenCode", + "settings.nav.info": "Info", "settings.scope.device": "This device", "settings.scope.server": "Server setting", "settings.common.enabled": "Enabled", @@ -238,4 +239,26 @@ export const settingsMessages = { "sidecars.refresh": "Refresh", "sidecars.path": "Path", "sidecars.go": "Go", + + "settings.section.info.title": "About", + "settings.section.info.subtitle": "View version, runtime, and gather diagnostic information.", + "settings.info.version.server": "Server version", + "settings.info.version.ui": "UI version", + "settings.info.version.uiSource": "UI source", + "settings.info.runtime.type": "Runtime", + "settings.info.runtime.platform": "Platform", + "settings.info.runtime.os": "Operating system", + "settings.info.server.url": "Server URL", + "settings.info.server.root": "Workspace root", + "settings.info.updates.title": "Updates", + "settings.info.updates.subtitle": "Check for new releases of CodeNomad.", + "settings.info.updates.latest": "Latest version", + "settings.info.updates.download": "Download update", + "settings.info.updates.refresh": "Refresh", + "settings.info.diagnostics.copyFailed": "Clipboard access denied. Use the download button instead.", + "settings.info.diagnostics.title": "Diagnostics", + "settings.info.diagnostics.subtitle": "バグ報告用にシステム情報とバージョン情報を収集します。", + "settings.info.diagnostics.copy": "Copy to clipboard", + "settings.info.diagnostics.download": "Download .txt", + "settings.info.diagnostics.copied": "Diagnostic info copied to clipboard.", } as const diff --git a/packages/ui/src/lib/i18n/messages/ru/settings.ts b/packages/ui/src/lib/i18n/messages/ru/settings.ts index 2b9dc2f2b..28fd6e230 100644 --- a/packages/ui/src/lib/i18n/messages/ru/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ru/settings.ts @@ -71,6 +71,7 @@ export const settingsMessages = { "settings.nav.remote": "Remote Access", "settings.nav.speech": "Speech", "settings.nav.opencode": "OpenCode", + "settings.nav.info": "Info", "settings.scope.device": "This device", "settings.scope.server": "Server setting", "settings.common.enabled": "Enabled", @@ -238,4 +239,26 @@ export const settingsMessages = { "sidecars.refresh": "Refresh", "sidecars.path": "Path", "sidecars.go": "Go", + + "settings.section.info.title": "About", + "settings.section.info.subtitle": "View version, runtime, and gather diagnostic information.", + "settings.info.version.server": "Server version", + "settings.info.version.ui": "UI version", + "settings.info.version.uiSource": "UI source", + "settings.info.runtime.type": "Runtime", + "settings.info.runtime.platform": "Platform", + "settings.info.runtime.os": "Operating system", + "settings.info.server.url": "Server URL", + "settings.info.server.root": "Workspace root", + "settings.info.updates.title": "Updates", + "settings.info.updates.subtitle": "Check for new releases of CodeNomad.", + "settings.info.updates.latest": "Latest version", + "settings.info.updates.download": "Download update", + "settings.info.updates.refresh": "Refresh", + "settings.info.diagnostics.copyFailed": "Clipboard access denied. Use the download button instead.", + "settings.info.diagnostics.title": "Diagnostics", + "settings.info.diagnostics.subtitle": "Соберите сведения о системе и версии для отчетов об ошибках.", + "settings.info.diagnostics.copy": "Copy to clipboard", + "settings.info.diagnostics.download": "Download .txt", + "settings.info.diagnostics.copied": "Diagnostic info copied to clipboard.", } as const diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts index beae0a580..613a5ca55 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts @@ -71,6 +71,7 @@ export const settingsMessages = { "settings.nav.remote": "Remote Access", "settings.nav.speech": "Speech", "settings.nav.opencode": "OpenCode", + "settings.nav.info": "Info", "settings.scope.device": "This device", "settings.scope.server": "Server setting", "settings.common.enabled": "Enabled", @@ -238,4 +239,26 @@ export const settingsMessages = { "sidecars.refresh": "Refresh", "sidecars.path": "Path", "sidecars.go": "Go", + + "settings.section.info.title": "About", + "settings.section.info.subtitle": "View version, runtime, and gather diagnostic information.", + "settings.info.version.server": "Server version", + "settings.info.version.ui": "UI version", + "settings.info.version.uiSource": "UI source", + "settings.info.runtime.type": "Runtime", + "settings.info.runtime.platform": "Platform", + "settings.info.runtime.os": "Operating system", + "settings.info.server.url": "Server URL", + "settings.info.server.root": "Workspace root", + "settings.info.updates.title": "Updates", + "settings.info.updates.subtitle": "Check for new releases of CodeNomad.", + "settings.info.updates.latest": "Latest version", + "settings.info.updates.download": "Download update", + "settings.info.updates.refresh": "Refresh", + "settings.info.diagnostics.copyFailed": "Clipboard access denied. Use the download button instead.", + "settings.info.diagnostics.title": "Diagnostics", + "settings.info.diagnostics.subtitle": "收集系统和版本信息,用于错误报告。", + "settings.info.diagnostics.copy": "Copy to clipboard", + "settings.info.diagnostics.download": "Download .txt", + "settings.info.diagnostics.copied": "Diagnostic info copied to clipboard.", } as const diff --git a/packages/ui/src/stores/settings-screen.ts b/packages/ui/src/stores/settings-screen.ts index e8ae4bf4c..bf12e43fb 100644 --- a/packages/ui/src/stores/settings-screen.ts +++ b/packages/ui/src/stores/settings-screen.ts @@ -1,6 +1,6 @@ import { createSignal } from "solid-js" -export type SettingsSectionId = "appearance" | "notifications" | "remote" | "speech" | "opencode" | "sidecars" +export type SettingsSectionId = "appearance" | "notifications" | "remote" | "speech" | "opencode" | "sidecars" | "info" const [settingsOpen, setSettingsOpen] = createSignal(false) const [activeSettingsSection, setActiveSettingsSection] = createSignal("appearance") diff --git a/packages/ui/src/styles/components/settings-info.css b/packages/ui/src/styles/components/settings-info.css new file mode 100644 index 000000000..37cab87bb --- /dev/null +++ b/packages/ui/src/styles/components/settings-info.css @@ -0,0 +1,57 @@ +.settings-info-grid { + display: flex; + flex-direction: column; + gap: 0; +} + +.settings-info-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.72rem 0; + border-top: 1px solid color-mix(in oklab, var(--border-base) 78%, transparent); +} + +.settings-info-row:first-child { + border-top: none; + padding-top: 0; +} + +.settings-info-label { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + color: var(--text-secondary); + flex-shrink: 0; +} + +.settings-info-value { + font-size: var(--font-size-sm); + color: var(--text-primary); + text-align: end; + font-family: var(--font-mono, monospace); + word-break: break-all; +} + +.settings-info-value-muted { + color: var(--text-muted); +} + +.settings-info-actions { + display: flex; + align-items: center; + gap: 0.625rem; + margin-top: 0.5rem; + flex-wrap: wrap; +} + +.settings-info-toast { + margin-top: 0.75rem; + padding: 0.625rem 0.875rem; + border: 1px solid var(--border-base); + background: color-mix(in oklab, var(--accent-primary) 10%, var(--surface-base)); + border-radius: 0; + color: var(--text-primary); + font-size: var(--font-size-sm); +} + diff --git a/packages/ui/src/styles/controls.css b/packages/ui/src/styles/controls.css index e7862d0ae..3b42fec40 100644 --- a/packages/ui/src/styles/controls.css +++ b/packages/ui/src/styles/controls.css @@ -9,3 +9,4 @@ @import "./components/remote-access.css"; @import "./components/permission-notification.css"; @import "./components/settings-screen.css"; +@import "./components/settings-info.css"; From 9a6450c03aa445677031bc5733a021978e310ef1 Mon Sep 17 00:00:00 2001 From: Yao Jianxuan <1378319314@qq.com> Date: Fri, 15 May 2026 21:46:46 +0800 Subject: [PATCH 03/43] feat(ui): add resizable session composer (#439) ## Summary - add a top-edge resize handle so the session composer can be freely stretched upward while keeping the bottom edge anchored - switch the expand/shrink control to track the actual composer height, clamp expansion to the session toolbar boundary, and reset the composer back to its default height after each send - preserve the right-side three-column control layout in narrow and windowed panes so the five utility controls stay outside the input, the stop button rises with the composer, and the send button remains bottom-pinned ## Testing - npm run typecheck --workspace @codenomad/ui - npm run build --workspace @codenomad/ui ## Notes - the build still reports the existing `virtua` JSX warning from `../../node_modules/virtua/lib/solid/index.jsx`; this PR does not introduce that warning - local unstaged changes in `.opencode/package-lock.json`, `.sisyphus/`, and Electron dev-port files were intentionally excluded from this PR because they are unrelated to the composer height feature --------- Co-authored-by: Shantur Rathore --- packages/ui/src/components/prompt-input.tsx | 95 ++++++++++++++++++- .../ui/src/lib/i18n/messages/en/messaging.ts | 1 + .../ui/src/lib/i18n/messages/es/messaging.ts | 1 + .../ui/src/lib/i18n/messages/fr/messaging.ts | 1 + .../ui/src/lib/i18n/messages/he/messaging.ts | 1 + .../ui/src/lib/i18n/messages/ja/messaging.ts | 1 + .../ui/src/lib/i18n/messages/ru/messaging.ts | 1 + .../lib/i18n/messages/zh-Hans/messaging.ts | 1 + .../ui/src/styles/messaging/prompt-input.css | 51 ++++++++++ 9 files changed, 151 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/prompt-input.tsx b/packages/ui/src/components/prompt-input.tsx index c53939613..d22125477 100644 --- a/packages/ui/src/components/prompt-input.tsx +++ b/packages/ui/src/components/prompt-input.tsx @@ -31,6 +31,15 @@ import { } from "../stores/conversation-speech" const log = getLogger("actions") const LazyUnifiedPicker = lazy(() => import("./unified-picker")) +const DEFAULT_PROMPT_FIELD_HEIGHT = 104 +const MAX_PROMPT_FIELD_HEIGHT_RATIO = 0.6 + +type ResizeDragState = { + pointerId: number + startY: number + startHeight: number + maxHeight: number +} function getConsumedPastedTextAttachmentIds(text: string, attachments: Attachment[]): string[] { if (!text || attachments.length === 0) return [] @@ -65,11 +74,16 @@ export default function PromptInput(props: PromptInputProps) { const [, setIsFocused] = createSignal(false) const [mode, setMode] = createSignal("normal") const [expandState, setExpandState] = createSignal("normal") + const [inputHeight, setInputHeight] = createSignal(null) + const [isResizing, setIsResizing] = createSignal(false) const [isFileBrowserOpen, setIsFileBrowserOpen] = createSignal(false) const SELECTION_INSERT_MAX_LENGTH = 2000 const MAX_READABLE_PICKED_FILE_BYTES = 5 * 1024 * 1024 let textareaRef: HTMLTextAreaElement | undefined let fileInputRef: HTMLInputElement | undefined + let wrapperRef: HTMLDivElement | undefined + let fieldContainerRef: HTMLDivElement | undefined + let resizeDragState: ResizeDragState | undefined const getPlaceholder = () => { if (mode() === "shell") { @@ -216,6 +230,7 @@ export default function PromptInput(props: PromptInputProps) { draftLoadedNonce, () => { // Session switch resets (picker/counters/ignored positions) stay in the component. + setInputHeight(null) setIgnoredAtPositions(new Set()) setShowPicker(false) setPickerMode("mention") @@ -294,6 +309,61 @@ export default function PromptInput(props: PromptInputProps) { }) }) + function computeMaxFieldHeight(): number { + if (typeof window === "undefined") return DEFAULT_PROMPT_FIELD_HEIGHT + + const sessionCenter = wrapperRef?.closest("[data-session-center-width]") + const availableHeight = sessionCenter?.getBoundingClientRect().height ?? window.innerHeight + const maxHeight = Math.floor(availableHeight * MAX_PROMPT_FIELD_HEIGHT_RATIO) + return Math.max(DEFAULT_PROMPT_FIELD_HEIGHT, maxHeight) + } + + function handleResizeStart(event: PointerEvent) { + event.preventDefault() + const target = event.currentTarget as HTMLElement + + resizeDragState = { + pointerId: event.pointerId, + startY: event.clientY, + startHeight: fieldContainerRef?.getBoundingClientRect().height ?? DEFAULT_PROMPT_FIELD_HEIGHT, + maxHeight: computeMaxFieldHeight(), + } + + setIsResizing(true) + + try { + target.setPointerCapture(event.pointerId) + } catch { + resizeDragState = undefined + setIsResizing(false) + } + } + + function handleResizeMove(event: PointerEvent) { + if (!resizeDragState || resizeDragState.pointerId !== event.pointerId) return + + event.preventDefault() + const deltaY = resizeDragState.startY - event.clientY + const nextHeight = Math.max( + DEFAULT_PROMPT_FIELD_HEIGHT, + Math.min(resizeDragState.maxHeight, resizeDragState.startHeight + deltaY), + ) + setInputHeight(nextHeight) + } + + function handleResizeEnd(event: PointerEvent) { + if (!resizeDragState || resizeDragState.pointerId !== event.pointerId) return + + event.preventDefault() + resizeDragState = undefined + setIsResizing(false) + textareaRef?.focus() + } + + onCleanup(() => { + resizeDragState = undefined + }) + async function handleSend() { const text = prompt().trim() const currentAttachments = attachments() @@ -324,6 +394,7 @@ export default function PromptInput(props: PromptInputProps) { const refreshHistory = () => recordHistoryEntry(historyEntry) setExpandState("normal") + setInputHeight(null) clearPrompt() clearHistoryDraft() setMode("normal") @@ -386,6 +457,7 @@ export default function PromptInput(props: PromptInputProps) { } function handleExpandToggle(nextState: "normal" | "expanded") { + setInputHeight(null) setExpandState(nextState) // Keep focus on textarea textareaRef?.focus() @@ -603,6 +675,7 @@ export default function PromptInput(props: PromptInputProps) { return (
-
+
+