From 1a0734c6b19f6ad353d088b821d2afa049a9dfbd Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Mon, 16 Feb 2026 21:39:46 +0000 Subject: [PATCH 01/37] fix(ui): persist listening mode before restart --- .../src/components/remote-access-overlay.tsx | 27 ++++++++++++++----- packages/ui/src/stores/preferences.tsx | 8 +++--- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/packages/ui/src/components/remote-access-overlay.tsx b/packages/ui/src/components/remote-access-overlay.tsx index 08815a15..6bcf53cf 100644 --- a/packages/ui/src/components/remote-access-overlay.tsx +++ b/packages/ui/src/components/remote-access-overlay.tsx @@ -23,6 +23,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) { const [meta, setMeta] = createSignal(null) const [authStatus, setAuthStatus] = createSignal<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean } | null>(null) const [loading, setLoading] = createSignal(false) + const [applyingListeningMode, setApplyingListeningMode] = createSignal(false) const [qrCodes, setQrCodes] = createSignal>({}) const [expandedUrl, setExpandedUrl] = createSignal(null) const [error, setError] = createSignal(null) @@ -88,6 +89,10 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) { return } + if (applyingListeningMode()) { + return + } + const confirmed = await showConfirmDialog(t("remoteAccess.listeningMode.restartConfirm.message"), { title: allow ? t("remoteAccess.listeningMode.restartConfirm.title.all") : t("remoteAccess.listeningMode.restartConfirm.title.local"), variant: "warning", @@ -100,12 +105,21 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) { return } - setListeningMode(targetMode) - const restarted = await restartCli() - if (!restarted) { - setError(t("remoteAccess.restart.errorManual")) - } else { - setMeta((prev) => (prev ? { ...prev, listeningMode: targetMode } : prev)) + setApplyingListeningMode(true) + setError(null) + try { + // Important: await the config patch before restart so Electron reads the updated mode from disk. + await setListeningMode(targetMode) + const restarted = await restartCli() + if (!restarted) { + setError(t("remoteAccess.restart.errorManual")) + } else { + setMeta((prev) => (prev ? { ...prev, listeningMode: targetMode } : prev)) + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setApplyingListeningMode(false) } void refreshMeta() @@ -196,6 +210,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) { onChange={(nextChecked) => { void handleAllowConnectionsChange(nextChecked) }} + disabled={loading() || applyingListeningMode()} > diff --git a/packages/ui/src/stores/preferences.tsx b/packages/ui/src/stores/preferences.tsx index 96ec386a..00b0a04e 100644 --- a/packages/ui/src/stores/preferences.tsx +++ b/packages/ui/src/stores/preferences.tsx @@ -304,10 +304,10 @@ function setThemePreference(preference: ThemePreference): void { void patchConfigOwner("ui", { theme: preference }).catch((error) => log.error("Failed to set theme", error)) } -function setListeningMode(mode: ListeningMode): void { - if (serverSettings().listeningMode === mode) return - void patchConfigOwner("server", { listeningMode: mode }).catch((error) => log.error("Failed to set listening mode", error)) -} + async function setListeningMode(mode: ListeningMode): Promise { + if (serverSettings().listeningMode === mode) return + await patchConfigOwner("server", { listeningMode: mode }) + } function updateEnvironmentVariables(envVars: Record): void { void patchConfigOwner("server", { environmentVariables: envVars }).catch((error) => From eafd4d83afa5449fa9d5ebb05d27139ad973f417 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Tue, 17 Feb 2026 11:13:17 +0000 Subject: [PATCH 02/37] fix(ui): use model input limit for avail tokens Upgrade @opencode-ai/sdk to 1.2.6 and prefer v2 model limit.input when present for the session AVAIL chip; otherwise keep the existing context-window-based estimate. --- package-lock.json | 8 ++++---- packages/ui/package.json | 2 +- packages/ui/src/stores/message-v2/session-info.ts | 13 ++++++++++++- packages/ui/src/stores/session-api.ts | 6 ++++-- packages/ui/src/types/session.ts | 1 + 5 files changed, 22 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5c62f29f..100833b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2809,9 +2809,9 @@ } }, "node_modules/@opencode-ai/sdk": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.1.11.tgz", - "integrity": "sha512-vqdNDz8Q+4bygmDdQem6oxhU31ci4JVdoND4ZJNeCs9x6OIU6MM3ybgemGpzNkgtJDlfb4xCdrPaZZ6Sr3V1IQ==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.2.6.tgz", + "integrity": "sha512-dWMF8Aku4h7fh8sw5tQ2FtbqRLbIFT8FcsukpxTird49ax7oUXP+gzqxM/VdxHjfksQvzLBjLZyMdDStc5g7xA==", "license": "MIT" }, "node_modules/@pinojs/redact": { @@ -12075,7 +12075,7 @@ "dependencies": { "@git-diff-view/solid": "^0.0.8", "@kobalte/core": "0.13.11", - "@opencode-ai/sdk": "1.1.11", + "@opencode-ai/sdk": "1.2.6", "@solidjs/router": "^0.13.0", "@suid/icons-material": "^0.9.0", "@suid/material": "^0.19.0", diff --git a/packages/ui/package.json b/packages/ui/package.json index 7a75280f..e619fff6 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -13,7 +13,7 @@ "dependencies": { "@git-diff-view/solid": "^0.0.8", "@kobalte/core": "0.13.11", - "@opencode-ai/sdk": "1.1.11", + "@opencode-ai/sdk": "1.2.6", "@solidjs/router": "^0.13.0", "@suid/icons-material": "^0.9.0", "@suid/material": "^0.19.0", diff --git a/packages/ui/src/stores/message-v2/session-info.ts b/packages/ui/src/stores/message-v2/session-info.ts index dd0fe16f..a0970ebf 100644 --- a/packages/ui/src/stores/message-v2/session-info.ts +++ b/packages/ui/src/stores/message-v2/session-info.ts @@ -63,9 +63,14 @@ export function updateSessionInfo(instanceId: string, sessionId: string): void { resolveSelectedModel(instanceProviders, latestProviderId, latestModelId) let modelOutputLimit = DEFAULT_MODEL_OUTPUT_LIMIT + let modelInputLimit: number | null = null if (selectedModel) { contextWindow = selectedModel.limit?.context ?? 0 + const inputLimit = selectedModel.limit?.input + if (typeof inputLimit === "number" && inputLimit > 0) { + modelInputLimit = inputLimit + } const outputLimit = selectedModel.limit?.output if (typeof outputLimit === "number" && outputLimit > 0) { modelOutputLimit = Math.min(outputLimit, DEFAULT_MODEL_OUTPUT_LIMIT) @@ -107,7 +112,13 @@ export function updateSessionInfo(instanceId: string, sessionId: string): void { const outputBudget = Math.min(modelOutputLimit, DEFAULT_MODEL_OUTPUT_LIMIT) - if (!contextAvailableFromPrevious) { + if (modelInputLimit !== null) { + // Prefer explicit input limits when provided by the API. + // This is used by the UI "Avail" chip. + contextAvailableTokens = modelInputLimit + } + + if (!contextAvailableFromPrevious && contextAvailableTokens === null) { if (contextWindow > 0) { if (latestHasContextUsage && actualUsageTokens > 0) { contextAvailableTokens = Math.max(contextWindow - (actualUsageTokens + outputBudget), 0) diff --git a/packages/ui/src/stores/session-api.ts b/packages/ui/src/stores/session-api.ts index dc8ad63e..9fd12b2c 100644 --- a/packages/ui/src/stores/session-api.ts +++ b/packages/ui/src/stores/session-api.ts @@ -291,12 +291,13 @@ async function createSession(instanceId: string, agent?: string): Promise p.id === session.model.providerId) const initialModel = initialProvider?.models.find((m) => m.id === session.model.modelId) const initialContextWindow = initialModel?.limit?.context ?? 0 + const initialInputLimit = initialModel?.limit?.input ?? 0 const initialSubscriptionModel = initialModel?.cost?.input === 0 && initialModel?.cost?.output === 0 const initialOutputLimit = initialModel?.limit?.output && initialModel.limit.output > 0 ? initialModel.limit.output : DEFAULT_MODEL_OUTPUT_LIMIT - const initialContextAvailable = initialContextWindow > 0 ? initialContextWindow : null + const initialContextAvailable = initialInputLimit > 0 ? initialInputLimit : initialContextWindow > 0 ? initialContextWindow : null setSessionInfoByInstance((prev) => { const next = new Map(prev) @@ -398,10 +399,11 @@ async function forkSession( const forkProvider = instanceProviders.find((p) => p.id === forkedSession.model.providerId) const forkModel = forkProvider?.models.find((m) => m.id === forkedSession.model.modelId) const forkContextWindow = forkModel?.limit?.context ?? 0 + const forkInputLimit = forkModel?.limit?.input ?? 0 const forkSubscriptionModel = forkModel?.cost?.input === 0 && forkModel?.cost?.output === 0 const forkOutputLimit = forkModel?.limit?.output && forkModel.limit.output > 0 ? forkModel.limit.output : DEFAULT_MODEL_OUTPUT_LIMIT - const forkContextAvailable = forkContextWindow > 0 ? forkContextWindow : null + const forkContextAvailable = forkInputLimit > 0 ? forkInputLimit : forkContextWindow > 0 ? forkContextWindow : null setSessionInfoByInstance((prev) => { const next = new Map(prev) diff --git a/packages/ui/src/types/session.ts b/packages/ui/src/types/session.ts index 1cae7c21..d0a864d5 100644 --- a/packages/ui/src/types/session.ts +++ b/packages/ui/src/types/session.ts @@ -90,6 +90,7 @@ export interface Model { variantKeys?: string[] limit?: { context?: number + input?: number output?: number } cost?: { From ddc58a2c3cf14e8ab65fa31aad71cb9b631c4edc Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Tue, 17 Feb 2026 12:26:03 +0000 Subject: [PATCH 03/37] feat(ui): add context meter indicator Replace duplicated Used/Avail pills with a shared ContextMeter component and add a small filled context usage indicator for quick scanning. --- packages/ui/src/components/context-meter.tsx | 123 ++++++++++++++++++ .../components/instance/instance-shell2.tsx | 51 +++----- .../ui/src/components/message-list-header.tsx | 20 ++- 3 files changed, 147 insertions(+), 47 deletions(-) create mode 100644 packages/ui/src/components/context-meter.tsx diff --git a/packages/ui/src/components/context-meter.tsx b/packages/ui/src/components/context-meter.tsx new file mode 100644 index 00000000..cd375269 --- /dev/null +++ b/packages/ui/src/components/context-meter.tsx @@ -0,0 +1,123 @@ +import type { Component } from "solid-js" + +interface ContextMeterProps { + usedTokens: number + availableTokens: number | null + formatTokens: (value: number) => string + usedLabel: string + availableLabel: string + class?: string +} + +const LABEL_CLASS = "uppercase text-[10px] tracking-wide text-muted" + +function clamp(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max) +} + +function resolveFillColor(percent: number): string { + if (percent >= 0.8) return "var(--status-error)" + if (percent >= 0.6) return "var(--status-warning)" + return "var(--status-success)" +} + +export const ContextMeter: Component = (props) => { + const hasAvailable = () => typeof props.availableTokens === "number" && props.availableTokens > 0 + const used = () => (typeof props.usedTokens === "number" && props.usedTokens > 0 ? props.usedTokens : 0) + const available = () => (hasAvailable() ? (props.availableTokens as number) : null) + + const percent = () => { + const usedValue = used() + const availableValue = available() + if (availableValue === null || availableValue <= 0) return null + + // Heuristic: if available >= used, treat it like a capacity/limit. + // Otherwise treat it like remaining tokens. + const ratio = availableValue >= usedValue ? usedValue / availableValue : usedValue / (usedValue + availableValue) + return clamp(ratio, 0, 1) + } + + const fillColor = () => { + const value = percent() + if (value === null) return "var(--border-base)" + return resolveFillColor(value) + } + + const percentLabel = () => { + const value = percent() + if (value === null) return "--" + return `${Math.round(value * 100)}%` + } + + const containerClass = + `inline-flex items-center gap-2 rounded-full border border-base px-2 py-0.5 text-xs text-primary ${props.class ?? ""}` + + function polarToCartesian(cx: number, cy: number, r: number, angleDeg: number) { + const rad = (angleDeg * Math.PI) / 180 + return { + x: cx + r * Math.cos(rad), + y: cy + r * Math.sin(rad), + } + } + + function describeSectorPath(cx: number, cy: number, r: number, startAngle: number, endAngle: number) { + const start = polarToCartesian(cx, cy, r, startAngle) + const end = polarToCartesian(cx, cy, r, endAngle) + const delta = ((endAngle - startAngle) % 360 + 360) % 360 + const largeArc = delta > 180 ? 1 : 0 + + return `M ${cx} ${cy} L ${start.x} ${start.y} A ${r} ${r} 0 ${largeArc} 1 ${end.x} ${end.y} Z` + } + + const circle = () => { + const value = percent() + const size = 22 + const r = 9 + const cx = 11 + const cy = 11 + const progress = value === null ? 0 : value + const startAngle = -90 + const endAngle = startAngle + progress * 360 + const isFull = progress >= 0.999 + const hasFill = progress > 0.001 + + const sectorPath = hasFill && !isFull ? describeSectorPath(cx, cy, r, startAngle, endAngle) : null + + return ( + + ) + } + + const tooltipText = () => `Context Used: ${percentLabel()}` + + return ( +
+ {circle()} +
+ {props.usedLabel} + {props.formatTokens(used())} + / + {props.availableLabel} + + {available() !== null ? props.formatTokens(available() as number) : "--"} + +
+
+ ) +} + +export default ContextMeter diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index ff5d0408..5238159b 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 { formatTokenTotal } from "../../lib/formatters" +import ContextMeter from "../context-meter" import { sseManager } from "../../lib/sse-manager" import { getLogger } from "../../lib/logger" import { serverApi } from "../../lib/api-client" @@ -349,16 +350,6 @@ const InstanceShell2: Component = (props) => { measureDrawerHost, }) - const formattedUsedTokens = () => formatTokenTotal(tokenStats().used) - - - const formattedAvailableTokens = () => { - const avail = tokenStats().avail - if (typeof avail === "number") { - return formatTokenTotal(avail) - } - return "--" - } const renderLeftPanel = () => { if (leftPinned()) { @@ -661,20 +652,15 @@ const InstanceShell2: Component = (props) => { -
-
- - {t("instanceShell.metrics.usedLabel")} - - {formattedUsedTokens()} +
+
-
- - {t("instanceShell.metrics.availableLabel")} - - {formattedAvailableTokens()} -
-
} > @@ -693,18 +679,13 @@ const InstanceShell2: Component = (props) => { -
- - {t("instanceShell.metrics.usedLabel")} - - {formattedUsedTokens()} -
-
- - {t("instanceShell.metrics.availableLabel")} - - {formattedAvailableTokens()} -
+
diff --git a/packages/ui/src/components/message-list-header.tsx b/packages/ui/src/components/message-list-header.tsx index 2a7bc6ad..75513971 100644 --- a/packages/ui/src/components/message-list-header.tsx +++ b/packages/ui/src/components/message-list-header.tsx @@ -1,10 +1,8 @@ import { Show } from "solid-js" import Kbd from "./kbd" +import ContextMeter from "./context-meter" import { useI18n } from "../lib/i18n" -const METRIC_CHIP_CLASS = "inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary" -const METRIC_LABEL_CLASS = "uppercase text-[10px] tracking-wide text-muted" - interface MessageListHeaderProps { usedTokens: number @@ -21,7 +19,6 @@ export default function MessageListHeader(props: MessageListHeaderProps) { const { t } = useI18n() const hasAvailableTokens = () => typeof props.availableTokens === "number" - const availableDisplay = () => (hasAvailableTokens() ? props.formatTokens(props.availableTokens as number) : "--") return (
@@ -40,14 +37,13 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
-
- {t("messageListHeader.metrics.usedLabel")} - {props.formatTokens(props.usedTokens)} -
-
- {t("messageListHeader.metrics.availableLabel")} - {hasAvailableTokens() ? availableDisplay() : "--"} -
+
From dea5079713a0ce41f50f696665a5c364ebc82db4 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Tue, 17 Feb 2026 13:47:07 +0000 Subject: [PATCH 04/37] feat(ui): add diff toolbar toggles and word wrap Replace split/unified and context controls with icon toggles, add a word-wrap toggle (default on), and move the toolbar into the tab header to free vertical space. --- .../file-viewer/monaco-diff-viewer.tsx | 17 +++++- .../instance/shell/right-panel/RightPanel.tsx | 15 ++++- .../right-panel/components/DiffToolbar.tsx | 57 +++++++++++-------- .../shell/right-panel/tabs/ChangesTab.tsx | 24 +++++--- .../shell/right-panel/tabs/GitChangesTab.tsx | 22 ++++--- .../instance/shell/right-panel/types.ts | 2 + .../src/components/instance/shell/storage.ts | 1 + packages/ui/src/styles/panels/right-panel.css | 18 +++++- 8 files changed, 112 insertions(+), 44 deletions(-) diff --git a/packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx b/packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx index 6c6b71de..c6694f80 100644 --- a/packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx +++ b/packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx @@ -12,6 +12,7 @@ interface MonacoDiffViewerProps { after: string viewMode?: "split" | "unified" contextMode?: "expanded" | "collapsed" + wordWrap?: "on" | "off" } export function MonacoDiffViewer(props: MonacoDiffViewerProps) { @@ -54,7 +55,7 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) { scrollBeyondLastLine: false, renderWhitespace: "selection", fontSize: 13, - wordWrap: "off", + wordWrap: props.wordWrap === "on" ? "on" : "off", glyphMargin: false, folding: false, // Keep enough gutter space so unified diffs don't overlap `+`/`-` markers. @@ -81,6 +82,7 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) { if (!ready() || !monaco || !diffEditor) return const viewMode = props.viewMode === "unified" ? "unified" : "split" const contextMode = props.contextMode === "collapsed" ? "collapsed" : "expanded" + const wordWrap = props.wordWrap === "on" ? "on" : "off" diffEditor.updateOptions({ renderSideBySide: viewMode === "split", @@ -89,7 +91,20 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) { contextMode === "collapsed" ? { enabled: true } : { enabled: false }, + wordWrap, }) + + try { + diffEditor.getOriginalEditor?.()?.updateOptions?.({ wordWrap }) + } catch { + // ignore + } + + try { + diffEditor.getModifiedEditor?.()?.updateOptions?.({ wordWrap }) + } catch { + // ignore + } }) createEffect(() => { diff --git a/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx b/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx index 5e240088..fc0e58aa 100644 --- a/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx @@ -18,7 +18,7 @@ import type { Instance } from "../../../../types/instance" import type { BackgroundProcess } from "../../../../../../server/src/api-types" import type { Session } from "../../../../types/session" import type { DrawerViewState } from "../types" -import type { DiffContextMode, DiffViewMode, RightPanelTab } from "./types" +import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, RightPanelTab } from "./types" import ChangesTab from "./tabs/ChangesTab" import FilesTab from "./tabs/FilesTab" @@ -32,6 +32,7 @@ import { useGlobalPointerDrag } from "../useGlobalPointerDrag" import { RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, + RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY, RIGHT_PANEL_CHANGES_LIST_OPEN_NONPHONE_KEY, RIGHT_PANEL_CHANGES_LIST_OPEN_PHONE_KEY, RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY, @@ -102,6 +103,9 @@ const RightPanel: Component = (props) => { const [diffContextMode, setDiffContextMode] = createSignal( readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, ["expanded", "collapsed"] as const) ?? "collapsed", ) + const [diffWordWrapMode, setDiffWordWrapMode] = createSignal( + readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY, ["on", "off"] as const) ?? "on", + ) const [changesSplitWidth, setChangesSplitWidth] = createSignal(320) const [filesSplitWidth, setFilesSplitWidth] = createSignal(320) @@ -195,6 +199,11 @@ const RightPanel: Component = (props) => { window.localStorage.setItem(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, diffContextMode()) }) + createEffect(() => { + if (typeof window === "undefined") return + window.localStorage.setItem(RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY, diffWordWrapMode()) + }) + const clampSplitWidth = (value: number) => { const min = 200 const maxByDrawer = Math.max(min, Math.floor(props.rightDrawerWidth() * 0.65)) @@ -738,8 +747,10 @@ const RightPanel: Component = (props) => { onSelectFile={handleSelectChangesFile} diffViewMode={diffViewMode} diffContextMode={diffContextMode} + diffWordWrapMode={diffWordWrapMode} onViewModeChange={setDiffViewMode} onContextModeChange={setDiffContextMode} + onWordWrapModeChange={setDiffWordWrapMode} listOpen={changesListOpen} onToggleList={toggleChangesList} splitWidth={changesSplitWidth} @@ -765,8 +776,10 @@ const RightPanel: Component = (props) => { scopeKey={gitScopeKey} diffViewMode={diffViewMode} diffContextMode={diffContextMode} + diffWordWrapMode={diffWordWrapMode} onViewModeChange={setDiffViewMode} onContextModeChange={setDiffContextMode} + onWordWrapModeChange={setDiffWordWrapMode} onOpenFile={(path) => void openGitFile(path)} onRefresh={() => void refreshGitStatus()} listOpen={gitChangesListOpen} diff --git a/packages/ui/src/components/instance/shell/right-panel/components/DiffToolbar.tsx b/packages/ui/src/components/instance/shell/right-panel/components/DiffToolbar.tsx index beb249ee..2e9e980b 100644 --- a/packages/ui/src/components/instance/shell/right-panel/components/DiffToolbar.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/components/DiffToolbar.tsx @@ -1,50 +1,61 @@ import type { Component } from "solid-js" -import type { DiffContextMode, DiffViewMode } from "../types" +import { AlignJustify, FoldVertical, Split, UnfoldVertical, WrapText } from "lucide-solid" + +import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types" interface DiffToolbarProps { viewMode: DiffViewMode contextMode: DiffContextMode + wordWrapMode: DiffWordWrapMode onViewModeChange: (mode: DiffViewMode) => void onContextModeChange: (mode: DiffContextMode) => void + onWordWrapModeChange: (mode: DiffWordWrapMode) => void } const DiffToolbar: Component = (props) => { + const nextViewMode = (): DiffViewMode => (props.viewMode === "split" ? "unified" : "split") + const nextContextMode = (): DiffContextMode => (props.contextMode === "collapsed" ? "expanded" : "collapsed") + const nextWordWrapMode = (): DiffWordWrapMode => (props.wordWrapMode === "on" ? "off" : "on") + + const viewModeTitle = () => (nextViewMode() === "split" ? "Switch to split view" : "Switch to unified view") + const contextModeTitle = () => + nextContextMode() === "collapsed" ? "Hide unchanged regions" : "Show full file" + const wordWrapTitle = () => (nextWordWrapMode() === "on" ? "Enable word wrap" : "Disable word wrap") + return (
+ -
) diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/ChangesTab.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/ChangesTab.tsx index 9821d3c5..a4fcd46a 100644 --- a/packages/ui/src/components/instance/shell/right-panel/tabs/ChangesTab.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/ChangesTab.tsx @@ -4,7 +4,7 @@ import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer" import DiffToolbar from "../components/DiffToolbar" import SplitFilePanel from "../components/SplitFilePanel" -import type { DiffContextMode, DiffViewMode } from "../types" +import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types" interface ChangesTabProps { t: (key: string, vars?: Record) => string @@ -18,8 +18,10 @@ interface ChangesTabProps { diffViewMode: Accessor diffContextMode: Accessor + diffWordWrapMode: Accessor onViewModeChange: (mode: DiffViewMode) => void onContextModeChange: (mode: DiffContextMode) => void + onWordWrapModeChange: (mode: DiffWordWrapMode) => void listOpen: Accessor onToggleList: () => void @@ -77,14 +79,6 @@ const ChangesTab: Component = (props) => { const renderViewer = () => (
-
- -
0 ? selectedFileData : null} @@ -102,6 +96,7 @@ const ChangesTab: Component = (props) => { after={String((file() as any).after || "")} viewMode={props.diffViewMode()} contextMode={props.diffContextMode()} + wordWrap={props.diffWordWrapMode()} /> )} @@ -182,6 +177,17 @@ const ChangesTab: Component = (props) => { -{totals.deletions}
+ +
+ +
} list={{ panel: renderListPanel, overlay: renderListOverlay }} diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/GitChangesTab.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/GitChangesTab.tsx index bd77d7e5..4575f1e5 100644 --- a/packages/ui/src/components/instance/shell/right-panel/tabs/GitChangesTab.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/GitChangesTab.tsx @@ -7,7 +7,7 @@ import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer" import DiffToolbar from "../components/DiffToolbar" import SplitFilePanel from "../components/SplitFilePanel" -import type { DiffContextMode, DiffViewMode } from "../types" +import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types" interface GitChangesTabProps { t: (key: string, vars?: Record) => string @@ -29,8 +29,10 @@ interface GitChangesTabProps { diffViewMode: Accessor diffContextMode: Accessor + diffWordWrapMode: Accessor onViewModeChange: (mode: DiffViewMode) => void onContextModeChange: (mode: DiffContextMode) => void + onWordWrapModeChange: (mode: DiffWordWrapMode) => void onOpenFile: (path: string) => void onRefresh: () => void @@ -80,14 +82,6 @@ const GitChangesTab: Component = (props) => { const renderViewer = () => (
-
- -
= (props) => { after={String((file() as any).after || "")} viewMode={props.diffViewMode()} contextMode={props.diffContextMode()} + wordWrap={props.diffWordWrapMode()} /> )} @@ -237,6 +232,15 @@ const GitChangesTab: Component = (props) => { > + + } list={{ panel: renderListPanel, overlay: renderListOverlay }} diff --git a/packages/ui/src/components/instance/shell/right-panel/types.ts b/packages/ui/src/components/instance/shell/right-panel/types.ts index e651e528..3273e5ec 100644 --- a/packages/ui/src/components/instance/shell/right-panel/types.ts +++ b/packages/ui/src/components/instance/shell/right-panel/types.ts @@ -3,3 +3,5 @@ export type RightPanelTab = "changes" | "git-changes" | "files" | "status" export type DiffViewMode = "split" | "unified" export type DiffContextMode = "expanded" | "collapsed" + +export type DiffWordWrapMode = "on" | "off" diff --git a/packages/ui/src/components/instance/shell/storage.ts b/packages/ui/src/components/instance/shell/storage.ts index 5f1d7421..b17b6743 100644 --- a/packages/ui/src/components/instance/shell/storage.ts +++ b/packages/ui/src/components/instance/shell/storage.ts @@ -23,6 +23,7 @@ export const RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY = "opencode-session- export const RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-git-changes-list-open-phone-v1" export const RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY = "opencode-session-right-panel-changes-diff-view-mode-v1" export const RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY = "opencode-session-right-panel-changes-diff-context-mode-v1" +export const RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY = "opencode-session-right-panel-changes-diff-word-wrap-v1" export const clampWidth = (value: number) => Math.min(MAX_SESSION_SIDEBAR_WIDTH, Math.max(MIN_SESSION_SIDEBAR_WIDTH, value)) diff --git a/packages/ui/src/styles/panels/right-panel.css b/packages/ui/src/styles/panels/right-panel.css index f2bfb016..edf4f565 100644 --- a/packages/ui/src/styles/panels/right-panel.css +++ b/packages/ui/src/styles/panels/right-panel.css @@ -282,7 +282,7 @@ } .file-viewer-toolbar { - @apply ml-auto flex items-center gap-1; + @apply flex items-center gap-1; } .file-viewer-toolbar-button { @@ -291,6 +291,22 @@ color: var(--text-secondary); } +.file-viewer-toolbar-icon-button { + @apply inline-flex items-center justify-center shrink-0 w-7 h-7 border border-base transition-colors; + background-color: var(--surface-base); + color: var(--text-secondary); +} + +.file-viewer-toolbar-icon-button:hover { + background-color: var(--surface-hover); + color: var(--text-primary); +} + +.file-viewer-toolbar-icon-button.active { + color: var(--text-primary); + box-shadow: inset 0 0 0 1px var(--accent-primary); +} + .file-viewer-toolbar-button:hover { background-color: var(--surface-hover); color: var(--text-primary); From 29557fba6d9e174126108253f9e78be95625da79 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Tue, 17 Feb 2026 17:30:03 +0000 Subject: [PATCH 05/37] feat(ui): add mobile fullscreen mode Adds an in-memory mobile fullscreen toggle that hides chrome and uses the Fullscreen API when available. --- packages/ui/src/App.tsx | 89 +++++++++++++++++-- .../components/instance/instance-shell2.tsx | 56 +++++++++--- .../ui/src/lib/i18n/messages/en/instance.ts | 3 + .../ui/src/lib/i18n/messages/es/instance.ts | 3 + .../ui/src/lib/i18n/messages/fr/instance.ts | 3 + .../ui/src/lib/i18n/messages/ja/instance.ts | 3 + .../ui/src/lib/i18n/messages/ru/instance.ts | 3 + .../src/lib/i18n/messages/zh-Hans/instance.ts | 3 + .../ui/src/styles/panels/session-layout.css | 12 +++ 9 files changed, 155 insertions(+), 20 deletions(-) diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 7475c486..d19a5da3 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -1,6 +1,7 @@ import { Component, For, Show, createMemo, createEffect, createSignal, onMount, onCleanup } from "solid-js" import { Dialog } from "@kobalte/core/dialog" import { Toaster } from "solid-toast" +import useMediaQuery from "@suid/material/useMediaQuery" import AlertDialog from "./components/alert-dialog" import FolderSelectionView from "./components/folder-selection-view" import { showConfirmDialog } from "./stores/alerts" @@ -82,6 +83,46 @@ const App: Component = () => { const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false) const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0) + const phoneQuery = useMediaQuery("(max-width: 767px)") + const isPhoneLayout = createMemo(() => phoneQuery()) + + // In-memory only: hides chrome on phone; may also request browser fullscreen. + const [mobileFullscreenMode, setMobileFullscreenMode] = createSignal(false) + const [browserFullscreenActive, setBrowserFullscreenActive] = createSignal(false) + + const fullscreenSupported = () => { + if (typeof document === "undefined") return false + const el = document.documentElement as any + return Boolean(document.fullscreenEnabled) && typeof el?.requestFullscreen === "function" + } + + const syncBrowserFullscreenState = () => { + if (typeof document === "undefined") return + setBrowserFullscreenActive(Boolean(document.fullscreenElement)) + } + + const enterMobileFullscreen = async () => { + if (!isPhoneLayout()) return + setMobileFullscreenMode(true) + if (!fullscreenSupported()) return + try { + await document.documentElement.requestFullscreen() + } catch { + // Ignore: immersive mode still works without browser fullscreen. + } + } + + const exitMobileFullscreen = async () => { + if (typeof document !== "undefined" && document.fullscreenElement && typeof document.exitFullscreen === "function") { + try { + await document.exitFullscreen() + } catch { + // Ignore + } + } + setMobileFullscreenMode(false) + } + createEffect(() => { if (typeof document === "undefined") return const shouldShow = @@ -95,6 +136,31 @@ const App: Component = () => { setInstanceTabBarHeight(element?.offsetHeight ?? 0) } + onMount(() => { + if (typeof document === "undefined") return + syncBrowserFullscreenState() + document.addEventListener("fullscreenchange", syncBrowserFullscreenState) + onCleanup(() => document.removeEventListener("fullscreenchange", syncBrowserFullscreenState)) + }) + + // If the user exits browser fullscreen via browser UI, restore chrome. + let lastBrowserFullscreen = false + createEffect(() => { + const active = browserFullscreenActive() + const mode = mobileFullscreenMode() + if (mode && lastBrowserFullscreen && !active) { + setMobileFullscreenMode(false) + } + lastBrowserFullscreen = active + }) + + // If we leave phone layout (rotation / resize), restore chrome. + createEffect(() => { + if (!isPhoneLayout() && mobileFullscreenMode()) { + void exitMobileFullscreen() + } + }) + createEffect(() => { void initMarkdown(isDark()).catch((error) => log.error("Failed to initialize markdown", error)) }) @@ -410,14 +476,16 @@ const App: Component = () => { when={!hasInstances()} fallback={ <> - setRemoteAccessOpen(true)} - /> + + setRemoteAccessOpen(true)} + /> + {(instance) => { @@ -435,7 +503,10 @@ const App: Component = () => { handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)} handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)} onExecuteCommand={executeCommand} - tabBarOffset={instanceTabBarHeight()} + tabBarOffset={isPhoneLayout() && mobileFullscreenMode() ? 0 : instanceTabBarHeight()} + mobileFullscreenMode={isPhoneLayout() && mobileFullscreenMode()} + onEnterMobileFullscreen={() => void enterMobileFullscreen()} + onExitMobileFullscreen={() => void exitMobileFullscreen()} /> diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index 5238159b..3c6fa711 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -42,7 +42,7 @@ import { useSessionSidebarRequests } from "./shell/useSessionSidebarRequests" import RightPanel from "./shell/right-panel/RightPanel" import { useDrawerChrome } from "./shell/useDrawerChrome" import { getSessionStatus } from "../../stores/session-status" -import { ShieldAlert } from "lucide-solid" +import { Maximize2, Minimize2, ShieldAlert } from "lucide-solid" import type { LayoutMode } from "./shell/types" import { @@ -70,6 +70,11 @@ interface InstanceShellProps { handleSidebarModelChange: (sessionId: string, model: { providerId: string; modelId: string }) => Promise onExecuteCommand: (command: Command) => void tabBarOffset: number + + // In-memory only: mobile immersive/fullscreen mode. + mobileFullscreenMode: boolean + onEnterMobileFullscreen: () => void + onExitMobileFullscreen: () => void } const InstanceShell2: Component = (props) => { @@ -118,6 +123,7 @@ const InstanceShell2: Component = (props) => { }) const isPhoneLayout = createMemo(() => layoutMode() === "phone") + const mobileFullscreen = createMemo(() => props.mobileFullscreenMode && isPhoneLayout()) const leftPinningSupported = createMemo(() => layoutMode() !== "phone") const rightPinningSupported = createMemo(() => layoutMode() !== "phone") @@ -585,13 +591,14 @@ const InstanceShell2: Component = (props) => { {renderLeftPanel()} - - - -
+ + + + +
= (props) => {
+ + + + + = (props) => {
- - - + + + + + + +
+ +
+
Date: Tue, 17 Feb 2026 18:00:48 +0000 Subject: [PATCH 06/37] fix(ui): avoid mobile prompt focus on switch Stops auto-focusing the prompt on phone session switches and scopes type-to-focus to the active visible prompt, disabling it on coarse pointers. --- packages/ui/src/App.tsx | 27 +++++++++++++- .../components/instance/instance-shell2.tsx | 1 + packages/ui/src/components/prompt-input.tsx | 36 ++++++++++++++----- .../ui/src/components/prompt-input/types.ts | 3 ++ .../src/components/session/session-view.tsx | 27 ++++++++------ .../ui/src/styles/messaging/prompt-input.css | 32 +++++++++++++++-- 6 files changed, 103 insertions(+), 23 deletions(-) diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index d19a5da3..64b40bae 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -143,6 +143,31 @@ const App: Component = () => { onCleanup(() => document.removeEventListener("fullscreenchange", syncBrowserFullscreenState)) }) + onMount(() => { + if (typeof window === "undefined") return + const vv = window.visualViewport + if (!vv) return + + const updateKeyboardOffset = () => { + // visualViewport shrinks when the OSK is visible. Use the delta as a bottom inset. + const inset = Math.max(0, window.innerHeight - vv.height - vv.offsetTop) + document.documentElement.style.setProperty("--keyboard-offset", `${Math.floor(inset)}px`) + } + + const schedule = () => requestAnimationFrame(updateKeyboardOffset) + schedule() + vv.addEventListener("resize", schedule) + vv.addEventListener("scroll", schedule) + window.addEventListener("orientationchange", schedule) + + onCleanup(() => { + vv.removeEventListener("resize", schedule) + vv.removeEventListener("scroll", schedule) + window.removeEventListener("orientationchange", schedule) + document.documentElement.style.removeProperty("--keyboard-offset") + }) + }) + // If the user exits browser fullscreen via browser UI, restore chrome. let lastBrowserFullscreen = false createEffect(() => { @@ -471,7 +496,7 @@ const App: Component = () => {
-
+
= (props) => { instanceId={props.instance.id} instanceFolder={props.instance.folder} escapeInDebounce={props.escapeInDebounce} + isPhoneLayout={isPhoneLayout()} showSidebarToggle={showEmbeddedSidebarToggle()} onSidebarToggle={() => setLeftOpen(true)} forceCompactStatusLayout={showEmbeddedSidebarToggle()} diff --git a/packages/ui/src/components/prompt-input.tsx b/packages/ui/src/components/prompt-input.tsx index 3277ac78..f4f834c1 100644 --- a/packages/ui/src/components/prompt-input.tsx +++ b/packages/ui/src/components/prompt-input.tsx @@ -176,15 +176,26 @@ export default function PromptInput(props: PromptInputProps) { ), ) - onMount(() => { + const isCoarsePointer = () => { + if (typeof window === "undefined") return false + return Boolean(window.matchMedia?.("(pointer: coarse)")?.matches) + } + + createEffect(() => { + // Scope global "type-to-focus" behavior to the active, visible prompt only. + if (typeof document === "undefined") return + if (isCoarsePointer()) return + if (props.isActive === false) return + if (props.disabled) return + const handleGlobalKeyDown = (e: KeyboardEvent) => { - const activeElement = document.activeElement as HTMLElement + const activeElement = document.activeElement as HTMLElement | null const isInputElement = activeElement?.tagName === "INPUT" || activeElement?.tagName === "TEXTAREA" || activeElement?.tagName === "SELECT" || - activeElement?.isContentEditable + Boolean(activeElement?.isContentEditable) if (isInputElement) return @@ -192,16 +203,25 @@ export default function PromptInput(props: PromptInputProps) { if (isModifierKey) return const isSpecialKey = - e.key === "Tab" || e.key === "Enter" || e.key.startsWith("Arrow") || e.key === "Backspace" || e.key === "Delete" + e.key === "Tab" || + e.key === "Enter" || + e.key.startsWith("Arrow") || + e.key === "Backspace" || + e.key === "Delete" if (isSpecialKey) return - if (e.key.length === 1 && textareaRef && !props.disabled) { - textareaRef.focus() + const textarea = textareaRef + if (!textarea || textarea.disabled) return + + // In session cache mode inactive panes are display:none; avoid stealing focus. + if (textarea.offsetParent === null) return + + if (e.key.length === 1) { + textarea.focus() } } document.addEventListener("keydown", handleGlobalKeyDown) - onCleanup(() => { document.removeEventListener("keydown", handleGlobalKeyDown) }) @@ -435,7 +455,7 @@ export default function PromptInput(props: PromptInputProps) { onFocus={() => setIsFocused(true)} onBlur={() => setIsFocused(false)} disabled={props.disabled} - rows={expandState() === "expanded" ? 15 : 4} + rows={expandState() === "expanded" ? 15 : 3} spellcheck={false} autocorrect="off" autoCapitalize="off" diff --git a/packages/ui/src/components/prompt-input/types.ts b/packages/ui/src/components/prompt-input/types.ts index 54757793..b4dc44c9 100644 --- a/packages/ui/src/components/prompt-input/types.ts +++ b/packages/ui/src/components/prompt-input/types.ts @@ -17,6 +17,9 @@ export interface PromptInputProps { instanceId: string instanceFolder: string sessionId: string + + // Used to scope global "type-to-focus" behavior. + isActive?: boolean onSend: (prompt: string, attachments: Attachment[]) => Promise onRunShell?: (command: string) => Promise disabled?: boolean diff --git a/packages/ui/src/components/session/session-view.tsx b/packages/ui/src/components/session/session-view.tsx index e0de2457..484546e3 100644 --- a/packages/ui/src/components/session/session-view.tsx +++ b/packages/ui/src/components/session/session-view.tsx @@ -28,6 +28,7 @@ interface SessionViewProps { instanceId: string instanceFolder: string escapeInDebounce: boolean + isPhoneLayout?: boolean showSidebarToggle?: boolean onSidebarToggle?: () => void forceCompactStatusLayout?: boolean @@ -76,6 +77,9 @@ export const SessionView: Component = (props) => { (isActive) => { if (!isActive) return + // On phones, focusing the prompt on session switch is disruptive (it raises the OSK). + if (props.isPhoneLayout) return + // Don't steal focus from other inputs (command palette, dialogs, selectors, etc.) if (typeof document === "undefined") return const activeEl = document.activeElement as HTMLElement | null @@ -314,17 +318,18 @@ export const SessionView: Component = (props) => { + instanceId={props.instanceId} + instanceFolder={props.instanceFolder} + sessionId={activeSession.id} + isActive={props.isActive} + onSend={handleSendMessage} + onRunShell={handleRunShell} + escapeInDebounce={props.escapeInDebounce} + isSessionBusy={sessionBusy()} + disabled={sessionNeedsInput()} + onAbortSession={handleAbortSession} + registerPromptInputApi={registerPromptInputApi} + />
) }} diff --git a/packages/ui/src/styles/messaging/prompt-input.css b/packages/ui/src/styles/messaging/prompt-input.css index 4797c241..8cbfb796 100644 --- a/packages/ui/src/styles/messaging/prompt-input.css +++ b/packages/ui/src/styles/messaging/prompt-input.css @@ -295,7 +295,33 @@ } .prompt-input { - padding-bottom: 1.5rem; + /* Prevent iOS Safari input zoom + keep input compact. */ + font-size: 16px; + padding-bottom: 0.75rem; + } +} + +@media (max-width: 1279px) { + :root { + --prompt-input-compact-height: 104px; + } + + .prompt-input-wrapper { + min-height: var(--prompt-input-compact-height); + } + + .prompt-input-field-container { + min-height: var(--prompt-input-compact-height); + height: var(--prompt-input-compact-height); + } + + .prompt-input-field { + height: var(--prompt-input-compact-height); + } + + .prompt-input-field-container.is-expanded, + .prompt-input-field.is-expanded { + height: auto; } } @@ -307,9 +333,9 @@ @media (max-width: 640px) { .prompt-input { - min-height: 64px; + min-height: 0; padding: 0.5rem 0.75rem; - padding-bottom: 2.25rem; + padding-bottom: 0.75rem; } .prompt-input-wrapper { From 3f82dd21fe35a763908b662b06ab7fff8e1072e5 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Tue, 17 Feb 2026 18:04:37 +0000 Subject: [PATCH 07/37] fix(ui): reduce prompt expanded height on mobile Use the existing instance shell layout mode to cap expanded prompt rows to 10 on phone/tablet while keeping 15 on desktop. --- packages/ui/src/components/instance/instance-shell2.tsx | 2 ++ packages/ui/src/components/prompt-input.tsx | 2 +- packages/ui/src/components/prompt-input/types.ts | 3 +++ packages/ui/src/components/session/session-view.tsx | 2 ++ 4 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index 0019db69..d89bf7c4 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -124,6 +124,7 @@ const InstanceShell2: Component = (props) => { const isPhoneLayout = createMemo(() => layoutMode() === "phone") const mobileFullscreen = createMemo(() => props.mobileFullscreenMode && isPhoneLayout()) + const compactPromptLayout = createMemo(() => layoutMode() !== "desktop") const leftPinningSupported = createMemo(() => layoutMode() !== "phone") const rightPinningSupported = createMemo(() => layoutMode() !== "phone") @@ -824,6 +825,7 @@ const InstanceShell2: Component = (props) => { instanceFolder={props.instance.folder} escapeInDebounce={props.escapeInDebounce} isPhoneLayout={isPhoneLayout()} + compactPromptLayout={compactPromptLayout()} showSidebarToggle={showEmbeddedSidebarToggle()} onSidebarToggle={() => setLeftOpen(true)} forceCompactStatusLayout={showEmbeddedSidebarToggle()} diff --git a/packages/ui/src/components/prompt-input.tsx b/packages/ui/src/components/prompt-input.tsx index f4f834c1..a52b0f8c 100644 --- a/packages/ui/src/components/prompt-input.tsx +++ b/packages/ui/src/components/prompt-input.tsx @@ -455,7 +455,7 @@ export default function PromptInput(props: PromptInputProps) { onFocus={() => setIsFocused(true)} onBlur={() => setIsFocused(false)} disabled={props.disabled} - rows={expandState() === "expanded" ? 15 : 3} + rows={expandState() === "expanded" ? (props.compactLayout ? 10 : 15) : 3} spellcheck={false} autocorrect="off" autoCapitalize="off" diff --git a/packages/ui/src/components/prompt-input/types.ts b/packages/ui/src/components/prompt-input/types.ts index b4dc44c9..e1452ec3 100644 --- a/packages/ui/src/components/prompt-input/types.ts +++ b/packages/ui/src/components/prompt-input/types.ts @@ -20,6 +20,9 @@ export interface PromptInputProps { // Used to scope global "type-to-focus" behavior. isActive?: boolean + + // Phone/tablet layouts should keep the expanded prompt more compact. + compactLayout?: boolean onSend: (prompt: string, attachments: Attachment[]) => Promise onRunShell?: (command: string) => Promise disabled?: boolean diff --git a/packages/ui/src/components/session/session-view.tsx b/packages/ui/src/components/session/session-view.tsx index 484546e3..58adfede 100644 --- a/packages/ui/src/components/session/session-view.tsx +++ b/packages/ui/src/components/session/session-view.tsx @@ -29,6 +29,7 @@ interface SessionViewProps { instanceFolder: string escapeInDebounce: boolean isPhoneLayout?: boolean + compactPromptLayout?: boolean showSidebarToggle?: boolean onSidebarToggle?: () => void forceCompactStatusLayout?: boolean @@ -322,6 +323,7 @@ export const SessionView: Component = (props) => { instanceFolder={props.instanceFolder} sessionId={activeSession.id} isActive={props.isActive} + compactLayout={props.compactPromptLayout} onSend={handleSendMessage} onRunShell={handleRunShell} escapeInDebounce={props.escapeInDebounce} From e8cfad1266a5f11151e573ada0d749b6720889e0 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Tue, 17 Feb 2026 18:13:44 +0000 Subject: [PATCH 08/37] fix(ui): anchor fullscreen exit button to viewport Render the mobile fullscreen exit button at the App root so fixed positioning stays pinned to the top-right regardless of instance header visibility. --- packages/ui/src/App.tsx | 14 ++++++++++++++ .../src/components/instance/instance-shell2.tsx | 16 +--------------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 64b40bae..b02861c1 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -2,6 +2,7 @@ import { Component, For, Show, createMemo, createEffect, createSignal, onMount, import { Dialog } from "@kobalte/core/dialog" import { Toaster } from "solid-toast" import useMediaQuery from "@suid/material/useMediaQuery" +import { Minimize2 } from "lucide-solid" import AlertDialog from "./components/alert-dialog" import FolderSelectionView from "./components/folder-selection-view" import { showConfirmDialog } from "./stores/alerts" @@ -497,6 +498,19 @@ const App: Component = () => {
+ +
+ +
+
= (props) => { - -
- -
-
- Date: Tue, 17 Feb 2026 18:27:41 +0000 Subject: [PATCH 09/37] fix(server): avoid back to login after auth Replace /login history entry on success and redirect authenticated /login to /, with no-store headers to prevent caching. --- .../src/server/routes/auth-pages/login.html | 3 ++- packages/server/src/server/routes/auth.ts | 19 ++++++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/server/src/server/routes/auth-pages/login.html b/packages/server/src/server/routes/auth-pages/login.html index c3f95ac8..5dea9c70 100644 --- a/packages/server/src/server/routes/auth-pages/login.html +++ b/packages/server/src/server/routes/auth-pages/login.html @@ -119,7 +119,8 @@ showError(message || `Login failed (${res.status})`) return } - window.location.href = "/" + // Replace history entry so Back doesn't return to /login. + window.location.replace("/") } catch (e) { showError(e && e.message ? e.message : String(e)) } diff --git a/packages/server/src/server/routes/auth.ts b/packages/server/src/server/routes/auth.ts index e47da74a..6bb7d3d3 100644 --- a/packages/server/src/server/routes/auth.ts +++ b/packages/server/src/server/routes/auth.ts @@ -51,7 +51,19 @@ function getTokenHtml(): string { } export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) { - app.get("/login", async (_request, reply) => { + app.get("/login", async (request, reply) => { + // If already authenticated, don't show the login page. + const session = deps.authManager.getSessionFromRequest(request) + if (session) { + reply.redirect("/") + return + } + + // Avoid caching the login page (helps with bfcache/back behavior). + reply.header("Cache-Control", "no-store") + reply.header("Pragma", "no-cache") + reply.header("Expires", "0") + const status = deps.authManager.getStatus() reply.type("text/html").send(getLoginHtml(status.username)) }) @@ -67,6 +79,11 @@ export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) { return } + // Avoid caching the token bootstrap page. + reply.header("Cache-Control", "no-store") + reply.header("Pragma", "no-cache") + reply.header("Expires", "0") + reply.type("text/html").send(getTokenHtml()) }) From 4dee154490dcdd9079ddc95a6d31163e831f4238 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Tue, 17 Feb 2026 18:43:02 +0000 Subject: [PATCH 10/37] docs: add star history chart --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index eae634f3..ea82aba7 100644 --- a/README.md +++ b/README.md @@ -123,3 +123,6 @@ To build the Desktop App from source: 1. Clone the repo. 2. Run `npm install` (requires pnpm or npm 7+ for workspaces). 3. Run `npm run build --workspace @neuralnomads/codenomad-electron-app`. + +[![Star History Chart](https://api.star-history.com/svg?repos=NeuralNomadsAI/CodeNomad&type=Date)](https://star-history.com/#NeuralNomadsAI/CodeNomad&Date) + From 6de6ef5a4ae8abeb4ab6d900af39a2fbd0e3de5e Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Tue, 17 Feb 2026 18:47:21 +0000 Subject: [PATCH 11/37] Bump to v0.11.2 --- package-lock.json | 12 +++---- package.json | 2 +- packages/electron-app/package.json | 2 +- packages/server/package-lock.json | 4 +-- packages/server/package.json | 2 +- packages/tauri-app/package.json | 2 +- packages/ui/package.json | 2 +- temp/opencode-ai-sdk-1.2.6.tgz | Bin 0 -> 72141 bytes temp/package/package.json | 51 +++++++++++++++++++++++++++++ 9 files changed, 64 insertions(+), 13 deletions(-) create mode 100644 temp/opencode-ai-sdk-1.2.6.tgz create mode 100644 temp/package/package.json diff --git a/package-lock.json b/package-lock.json index 100833b0..f2ff2632 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codenomad-workspace", - "version": "0.11.1", + "version": "0.11.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codenomad-workspace", - "version": "0.11.1", + "version": "0.11.2", "license": "MIT", "dependencies": { "7zip-bin": "^5.2.0", @@ -11985,7 +11985,7 @@ }, "packages/electron-app": { "name": "@neuralnomads/codenomad-electron-app", - "version": "0.11.1", + "version": "0.11.2", "license": "MIT", "dependencies": { "@codenomad/ui": "file:../ui", @@ -12021,7 +12021,7 @@ }, "packages/server": { "name": "@neuralnomads/codenomad", - "version": "0.11.1", + "version": "0.11.2", "license": "MIT", "dependencies": { "@fastify/cors": "^8.5.0", @@ -12062,7 +12062,7 @@ }, "packages/tauri-app": { "name": "@codenomad/tauri-app", - "version": "0.11.1", + "version": "0.11.2", "license": "MIT", "devDependencies": { "@tauri-apps/cli": "^2.9.4" @@ -12070,7 +12070,7 @@ }, "packages/ui": { "name": "@codenomad/ui", - "version": "0.11.1", + "version": "0.11.2", "license": "MIT", "dependencies": { "@git-diff-view/solid": "^0.0.8", diff --git a/package.json b/package.json index 412c0278..e806f55e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codenomad-workspace", - "version": "0.11.1", + "version": "0.11.2", "private": true, "description": "CodeNomad monorepo workspace", "license": "MIT", diff --git a/packages/electron-app/package.json b/packages/electron-app/package.json index 092e3692..12b0a260 100644 --- a/packages/electron-app/package.json +++ b/packages/electron-app/package.json @@ -1,6 +1,6 @@ { "name": "@neuralnomads/codenomad-electron-app", - "version": "0.11.1", + "version": "0.11.2", "description": "CodeNomad - AI coding assistant", "license": "MIT", "author": { diff --git a/packages/server/package-lock.json b/packages/server/package-lock.json index 038a7e79..6b8c9f13 100644 --- a/packages/server/package-lock.json +++ b/packages/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "@neuralnomads/codenomad", - "version": "0.11.1", + "version": "0.11.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@neuralnomads/codenomad", - "version": "0.11.1", + "version": "0.11.2", "dependencies": { "@fastify/cors": "^8.5.0", "@fastify/reply-from": "^9.8.0", diff --git a/packages/server/package.json b/packages/server/package.json index dd45c0a8..ac83b41e 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@neuralnomads/codenomad", - "version": "0.11.1", + "version": "0.11.2", "description": "CodeNomad Server", "license": "MIT", "author": { diff --git a/packages/tauri-app/package.json b/packages/tauri-app/package.json index adb8357f..10ce8f59 100644 --- a/packages/tauri-app/package.json +++ b/packages/tauri-app/package.json @@ -1,6 +1,6 @@ { "name": "@codenomad/tauri-app", - "version": "0.11.1", + "version": "0.11.2", "private": true, "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index e619fff6..e336388a 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@codenomad/ui", - "version": "0.11.1", + "version": "0.11.2", "private": true, "license": "MIT", "type": "module", diff --git a/temp/opencode-ai-sdk-1.2.6.tgz b/temp/opencode-ai-sdk-1.2.6.tgz new file mode 100644 index 0000000000000000000000000000000000000000..cf504faac8eea5af2105ec9aa97fd96c39a963fc GIT binary patch literal 72141 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%0A*nR%r{m&_ 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 >(K{!VL?%Pi~~ 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 vwBnB~r=$VBb4M1 Date: Tue, 17 Feb 2026 22:44:30 +0000 Subject: [PATCH 12/37] fix(ui): render user message text as markdown User text parts now use the same Markdown renderer + cache path as assistant messages, while keeping role-specific heading and accent colors. --- packages/ui/src/components/message-part.tsx | 62 +++++++++++-------- packages/ui/src/styles/markdown.css | 7 ++- .../ui/src/styles/messaging/message-base.css | 7 +++ 3 files changed, 48 insertions(+), 28 deletions(-) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 4f2ff2aa..7178eb93 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -3,7 +3,6 @@ import ToolCall from "./tool-call" import { isItemExpanded, toggleItemExpanded } from "../stores/tool-call-state" import { Markdown } from "./markdown" import { useTheme } from "../lib/theme" -import { useConfig } from "../stores/preferences" import { partHasRenderableText, SDKPart, TextPart, ClientPart } from "../types/message" type ToolCallPart = Extract @@ -17,16 +16,18 @@ interface MessagePartProps { // Other synthetic text parts (tool traces, read outputs, etc.) should be hidden. primaryUserTextPartId?: string | null onRendered?: () => void - } - export default function MessagePart(props: MessagePartProps) { +} + +export default function MessagePart(props: MessagePartProps) { const { isDark } = useTheme() - const { preferences } = useConfig() const partType = () => props.part?.type || "" const reasoningId = () => `reasoning-${props.part?.id || ""}` const isReasoningExpanded = () => isItemExpanded(reasoningId()) const isAssistantMessage = () => props.messageType === "assistant" const textContainerClass = () => (isAssistantMessage() ? "message-text message-text-assistant" : "message-text") + const markdownContainerClass = () => "message-text message-text-assistant" + const textContainerRole = () => props.messageType || "assistant" const shouldHideTextPart = () => { const part = props.part @@ -57,6 +58,11 @@ interface MessagePartProps { return "" } + const canRenderMarkdown = () => { + const id = (props.part as unknown as { id?: unknown })?.id + return typeof id === "string" && id.length > 0 + } + function reasoningSegmentHasText(segment: unknown): boolean { if (typeof segment === "string") { return segment.trim().length > 0 @@ -91,20 +97,28 @@ interface MessagePartProps { const createTextPartForMarkdown = (): TextPart => { const part = props.part - if ((part.type === "text" || part.type === "reasoning") && typeof part.text === "string") { + if (part.type === "text" && typeof part.text === "string") { + // Pass through the original part so `renderCache` updates persist. + return part as unknown as TextPart + } + + if (part.type === "reasoning" && typeof (part as any).text === "string") { + // Reasoning parts render as markdown in some views; normalize to TextPart. return { id: part.id, type: "text", - text: part.text, - synthetic: part.type === "text" ? part.synthetic : false, - version: (part as { version?: number }).version + text: (part as any).text, + synthetic: false, + version: (part as { version?: number }).version, + renderCache: (part as any).renderCache, } } + return { id: part.id, - type: "text", + type: "text", text: "", - synthetic: false + synthetic: false, } } @@ -117,22 +131,18 @@ interface MessagePartProps { -
- {plainTextContent()}} - > - - - -
+
+ {plainTextContent()}}> + + +
diff --git a/packages/ui/src/styles/markdown.css b/packages/ui/src/styles/markdown.css index 142e8e68..d8b89be7 100644 --- a/packages/ui/src/styles/markdown.css +++ b/packages/ui/src/styles/markdown.css @@ -9,6 +9,9 @@ line-height: var(--line-height-normal); font-weight: var(--font-weight-regular); color: var(--text-primary); + /* Message containers may use `whitespace-pre-wrap` for plain text. + Markdown should always match assistant rendering (normal whitespace). */ + white-space: normal; } .markdown-body p, @@ -28,7 +31,7 @@ .markdown-body h5, .markdown-body h6 { font-family: inherit; - color: inherit; + color: var(--markdown-heading-color, inherit); font-weight: var(--font-weight-semibold); line-height: 1.3; margin-top: 0.9em; @@ -71,7 +74,7 @@ .markdown-body strong { font-weight: var(--font-weight-regular); - color: var(--message-assistant-border); + color: var(--markdown-accent, var(--message-assistant-border)); } .markdown-body em { diff --git a/packages/ui/src/styles/messaging/message-base.css b/packages/ui/src/styles/messaging/message-base.css index 8064ae62..5f45939a 100644 --- a/packages/ui/src/styles/messaging/message-base.css +++ b/packages/ui/src/styles/messaging/message-base.css @@ -1,6 +1,10 @@ /* Message item base styles */ .message-item-base { @apply flex flex-col gap-2 p-3 w-full; + + /* Markdown rendering uses these to theme emphasis + headings per message role. */ + --markdown-accent: var(--message-user-border); + --markdown-heading-color: var(--message-user-border); } .message-item-header { @@ -71,6 +75,9 @@ padding: 0.6rem 0.65rem; margin-top: 0; margin-bottom: 0; + + --markdown-accent: var(--message-assistant-border); + --markdown-heading-color: var(--text-primary); } .message-item-base:not(.assistant-message) { From 4eaa711f0103c0d855803ab1c4469075b36f041e Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Wed, 18 Feb 2026 00:27:26 +0000 Subject: [PATCH 13/37] fix(ui): make alert dialog scrollable for long errors --- packages/ui/src/components/alert-dialog.tsx | 70 ++++++++++++--------- 1 file changed, 39 insertions(+), 31 deletions(-) diff --git a/packages/ui/src/components/alert-dialog.tsx b/packages/ui/src/components/alert-dialog.tsx index 8c01082c..045c239a 100644 --- a/packages/ui/src/components/alert-dialog.tsx +++ b/packages/ui/src/components/alert-dialog.tsx @@ -115,28 +115,36 @@ const AlertDialog: Component = () => { > -
- -
-
- {accent.symbol} -
-
- {title} - - {payload.message} - {payload.detail &&

{payload.detail}

} -
-
-
+
+ +
+
+ {accent.symbol} +
+
+ {title} + +
+ {payload.message} + {payload.detail &&
{payload.detail}
} +
+
+
+
@@ -185,14 +193,14 @@ const AlertDialog: Component = () => { {confirmLabel}
-
-
- - - ) - }} - - ) -} +
+
+
+ + ) + }} + + ) + } export default AlertDialog From 859312ba3b5fedc6689a123de123a18e2f3e7421 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Wed, 18 Feb 2026 01:07:52 +0000 Subject: [PATCH 14/37] feat(ui): add dispose instance and rehydrate Adds a dispose instance action to the instance info view, POSTing to /instance/dispose and rehydrating per-instance stores; also handles server.instance.disposed events and adds danger button styling. --- packages/opencode-config/package.json | 4 +- packages/ui/src/components/info-view.tsx | 6 +- packages/ui/src/components/instance-info.tsx | 64 ++++++++- packages/ui/src/lib/i18n/messages/en/logs.ts | 9 ++ packages/ui/src/lib/i18n/messages/es/logs.ts | 9 ++ packages/ui/src/lib/i18n/messages/fr/logs.ts | 9 ++ packages/ui/src/lib/i18n/messages/ja/logs.ts | 9 ++ packages/ui/src/lib/i18n/messages/ru/logs.ts | 9 ++ .../ui/src/lib/i18n/messages/zh-Hans/logs.ts | 9 ++ packages/ui/src/lib/sdk-manager.ts | 2 +- packages/ui/src/lib/sse-manager.ts | 12 ++ packages/ui/src/stores/instances.ts | 135 +++++++++++++++++- packages/ui/src/styles/components/buttons.css | 25 ++++ packages/ui/src/styles/utilities.css | 6 + 14 files changed, 296 insertions(+), 12 deletions(-) diff --git a/packages/opencode-config/package.json b/packages/opencode-config/package.json index efa583b3..2f52967b 100644 --- a/packages/opencode-config/package.json +++ b/packages/opencode-config/package.json @@ -4,6 +4,6 @@ "private": true, "license": "MIT", "dependencies": { - "@opencode-ai/plugin": "1.2.4" + "@opencode-ai/plugin": "1.2.6" } -} +} \ No newline at end of file diff --git a/packages/ui/src/components/info-view.tsx b/packages/ui/src/components/info-view.tsx index 4229b0ec..b2387f5b 100644 --- a/packages/ui/src/components/info-view.tsx +++ b/packages/ui/src/components/info-view.tsx @@ -1,5 +1,5 @@ import { Component, For, createSignal, createEffect, Show, onMount, onCleanup, createMemo } from "solid-js" -import { instances, getInstanceLogs, isInstanceLogStreaming, setInstanceLogStreaming } from "../stores/instances" +import { getInstanceLogs, instances, isInstanceLogStreaming, setInstanceLogStreaming } from "../stores/instances" import { ChevronDown } from "lucide-solid" import InstanceInfo from "./instance-info" import { useI18n } from "../lib/i18n" @@ -86,8 +86,8 @@ const InfoView: Component = (props) => { return (
-
- {(inst) => } +
+ {(inst) => }
diff --git a/packages/ui/src/components/instance-info.tsx b/packages/ui/src/components/instance-info.tsx index 9231547a..da54521b 100644 --- a/packages/ui/src/components/instance-info.tsx +++ b/packages/ui/src/components/instance-info.tsx @@ -1,14 +1,21 @@ -import { Component, For, Show, createMemo } from "solid-js" +import { Component, For, Show, createMemo, createSignal } from "solid-js" import type { Instance } from "../types/instance" import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context" import InstanceServiceStatus from "./instance-service-status" import { useI18n } from "../lib/i18n" +import { showConfirmDialog } from "../stores/alerts" +import { disposeInstance } from "../stores/instances" +import { showToastNotification } from "../lib/notifications" +import { getLogger } from "../lib/logger" interface InstanceInfoProps { instance: Instance compact?: boolean + showDisposeButton?: boolean } +const log = getLogger("actions") + const InstanceInfo: Component = (props) => { const { t } = useI18n() const metadataContext = useOptionalInstanceMetadataContext() @@ -16,6 +23,8 @@ const InstanceInfo: Component = (props) => { const instanceAccessor = metadataContext?.instance ?? (() => props.instance) const metadataAccessor = metadataContext?.metadata ?? (() => props.instance.metadata) + const [isDisposing, setIsDisposing] = createSignal(false) + const currentInstance = () => instanceAccessor() const metadata = () => metadataAccessor() const binaryVersion = () => currentInstance().binaryVersion || metadata()?.version @@ -25,6 +34,46 @@ const InstanceInfo: Component = (props) => { return env ? Object.entries(env) : [] }) + const disposeEnabled = createMemo(() => Boolean(currentInstance()?.client) && !isDisposing()) + + const handleDisposeInstance = async () => { + if (!disposeEnabled()) return + + const confirmed = await showConfirmDialog(t("infoView.dispose.confirm.message"), { + title: t("infoView.dispose.confirm.title"), + variant: "warning", + confirmLabel: t("infoView.dispose.confirm.confirmLabel"), + cancelLabel: t("infoView.dispose.confirm.cancelLabel"), + }) + + if (!confirmed) return + + setIsDisposing(true) + try { + const ok = await disposeInstance(currentInstance().id) + if (ok) { + showToastNotification({ + message: t("infoView.dispose.toast.success"), + variant: "success", + duration: 8000, + }) + } else { + showToastNotification({ + message: t("infoView.dispose.toast.error"), + variant: "error", + }) + } + } catch (error) { + log.error("Failed to dispose instance", error) + showToastNotification({ + message: t("infoView.dispose.toast.error"), + variant: "error", + }) + } finally { + setIsDisposing(false) + } + } + return (
@@ -156,6 +205,19 @@ const InstanceInfo: Component = (props) => {
+ + +
+ +
+
) diff --git a/packages/ui/src/lib/i18n/messages/en/logs.ts b/packages/ui/src/lib/i18n/messages/en/logs.ts index a38f681e..22f74872 100644 --- a/packages/ui/src/lib/i18n/messages/en/logs.ts +++ b/packages/ui/src/lib/i18n/messages/en/logs.ts @@ -15,4 +15,13 @@ export const logMessages = { "infoView.logs.paused.description": "Enable streaming to watch your OpenCode server activity.", "infoView.logs.empty.waiting": "Waiting for server output...", "infoView.logs.scrollToBottom": "Scroll to bottom", + + "infoView.dispose.actions.dispose": "Dispose instance", + "infoView.dispose.actions.disposing": "Disposing...", + "infoView.dispose.confirm.title": "Dispose instance?", + "infoView.dispose.confirm.message": "This clears cached per-project state for this directory and reloads the instance.", + "infoView.dispose.confirm.confirmLabel": "Dispose", + "infoView.dispose.confirm.cancelLabel": "Cancel", + "infoView.dispose.toast.success": "Instance disposed. Reloading...", + "infoView.dispose.toast.error": "Failed to dispose instance.", } as const diff --git a/packages/ui/src/lib/i18n/messages/es/logs.ts b/packages/ui/src/lib/i18n/messages/es/logs.ts index ab1cdc68..b9057cfb 100644 --- a/packages/ui/src/lib/i18n/messages/es/logs.ts +++ b/packages/ui/src/lib/i18n/messages/es/logs.ts @@ -15,4 +15,13 @@ export const logMessages = { "infoView.logs.paused.description": "Activa el streaming para ver la actividad de tu servidor de OpenCode.", "infoView.logs.empty.waiting": "Esperando la salida del servidor...", "infoView.logs.scrollToBottom": "Desplazarse al final", + + "infoView.dispose.actions.dispose": "Desechar instancia", + "infoView.dispose.actions.disposing": "Desechando...", + "infoView.dispose.confirm.title": "¿Desechar instancia?", + "infoView.dispose.confirm.message": "Esto borra el estado en caché por proyecto para este directorio y recarga la instancia.", + "infoView.dispose.confirm.confirmLabel": "Desechar", + "infoView.dispose.confirm.cancelLabel": "Cancelar", + "infoView.dispose.toast.success": "Instancia desechada. Recargando...", + "infoView.dispose.toast.error": "No se pudo desechar la instancia.", } as const diff --git a/packages/ui/src/lib/i18n/messages/fr/logs.ts b/packages/ui/src/lib/i18n/messages/fr/logs.ts index eda164fa..689fb58c 100644 --- a/packages/ui/src/lib/i18n/messages/fr/logs.ts +++ b/packages/ui/src/lib/i18n/messages/fr/logs.ts @@ -15,4 +15,13 @@ export const logMessages = { "infoView.logs.paused.description": "Activez le streaming pour suivre l'activité de votre serveur OpenCode.", "infoView.logs.empty.waiting": "En attente de la sortie du serveur...", "infoView.logs.scrollToBottom": "Aller en bas", + + "infoView.dispose.actions.dispose": "Réinitialiser l'instance", + "infoView.dispose.actions.disposing": "Réinitialisation...", + "infoView.dispose.confirm.title": "Réinitialiser l'instance ?", + "infoView.dispose.confirm.message": "Cela efface l'état en cache pour ce répertoire et recharge l'instance.", + "infoView.dispose.confirm.confirmLabel": "Réinitialiser", + "infoView.dispose.confirm.cancelLabel": "Annuler", + "infoView.dispose.toast.success": "Instance réinitialisée. Rechargement...", + "infoView.dispose.toast.error": "Impossible de réinitialiser l'instance.", } as const diff --git a/packages/ui/src/lib/i18n/messages/ja/logs.ts b/packages/ui/src/lib/i18n/messages/ja/logs.ts index 4498f06f..ed602609 100644 --- a/packages/ui/src/lib/i18n/messages/ja/logs.ts +++ b/packages/ui/src/lib/i18n/messages/ja/logs.ts @@ -15,4 +15,13 @@ export const logMessages = { "infoView.logs.paused.description": "ストリーミングを有効にして OpenCode サーバーの動作を監視します。", "infoView.logs.empty.waiting": "サーバー出力を待機中...", "infoView.logs.scrollToBottom": "最下部へスクロール", + + "infoView.dispose.actions.dispose": "インスタンスを破棄", + "infoView.dispose.actions.disposing": "破棄しています...", + "infoView.dispose.confirm.title": "インスタンスを破棄しますか?", + "infoView.dispose.confirm.message": "このディレクトリのプロジェクト状態キャッシュをクリアし、インスタンスを再読み込みします。", + "infoView.dispose.confirm.confirmLabel": "破棄", + "infoView.dispose.confirm.cancelLabel": "キャンセル", + "infoView.dispose.toast.success": "インスタンスを破棄しました。再読み込み中...", + "infoView.dispose.toast.error": "インスタンスの破棄に失敗しました。", } as const diff --git a/packages/ui/src/lib/i18n/messages/ru/logs.ts b/packages/ui/src/lib/i18n/messages/ru/logs.ts index e9a364b8..dd5cd39d 100644 --- a/packages/ui/src/lib/i18n/messages/ru/logs.ts +++ b/packages/ui/src/lib/i18n/messages/ru/logs.ts @@ -15,4 +15,13 @@ export const logMessages = { "infoView.logs.paused.description": "Включите стриминг, чтобы наблюдать за активностью сервера OpenCode.", "infoView.logs.empty.waiting": "Ожидание вывода сервера…", "infoView.logs.scrollToBottom": "Прокрутить вниз", + + "infoView.dispose.actions.dispose": "Сбросить инстанс", + "infoView.dispose.actions.disposing": "Сброс...", + "infoView.dispose.confirm.title": "Сбросить инстанс?", + "infoView.dispose.confirm.message": "Это очистит кэш состояния проекта для этого каталога и перезагрузит инстанс.", + "infoView.dispose.confirm.confirmLabel": "Сбросить", + "infoView.dispose.confirm.cancelLabel": "Отмена", + "infoView.dispose.toast.success": "Инстанс сброшен. Перезагрузка...", + "infoView.dispose.toast.error": "Не удалось сбросить инстанс.", } as const diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/logs.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/logs.ts index d0b4e9b8..55a3f8f7 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/logs.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/logs.ts @@ -15,4 +15,13 @@ export const logMessages = { "infoView.logs.paused.description": "启用流式输出以查看 OpenCode 服务器活动。", "infoView.logs.empty.waiting": "正在等待服务器输出...", "infoView.logs.scrollToBottom": "滚动到底部", + + "infoView.dispose.actions.dispose": "释放实例", + "infoView.dispose.actions.disposing": "正在释放...", + "infoView.dispose.confirm.title": "要释放实例吗?", + "infoView.dispose.confirm.message": "这将清除此目录的项目缓存状态,并重新加载实例。", + "infoView.dispose.confirm.confirmLabel": "释放", + "infoView.dispose.confirm.cancelLabel": "取消", + "infoView.dispose.toast.success": "实例已释放。正在重新加载...", + "infoView.dispose.toast.error": "释放实例失败。", } as const diff --git a/packages/ui/src/lib/sdk-manager.ts b/packages/ui/src/lib/sdk-manager.ts index 7df7659f..5f525eda 100644 --- a/packages/ui/src/lib/sdk-manager.ts +++ b/packages/ui/src/lib/sdk-manager.ts @@ -46,7 +46,7 @@ class SDKManager { export type { OpencodeClient } -function buildInstanceBaseUrl(proxyPath: string): string { +export function buildInstanceBaseUrl(proxyPath: string): string { const normalized = normalizeProxyPath(proxyPath) const base = stripTrailingSlashes(CODENOMAD_API_BASE) return `${base}${normalized}/` diff --git a/packages/ui/src/lib/sse-manager.ts b/packages/ui/src/lib/sse-manager.ts index 77a4fdb4..e6354e2c 100644 --- a/packages/ui/src/lib/sse-manager.ts +++ b/packages/ui/src/lib/sse-manager.ts @@ -54,6 +54,13 @@ interface BackgroundProcessRemovedEvent { } } +interface ServerInstanceDisposedEvent { + type: "server.instance.disposed" + properties: { + directory: string + } +} + type SSEEvent = | MessageUpdateEvent | MessageRemovedEvent @@ -74,6 +81,7 @@ type SSEEvent = | TuiToastEvent | BackgroundProcessUpdatedEvent | BackgroundProcessRemovedEvent + | ServerInstanceDisposedEvent | { type: string; properties?: Record } type ConnectionStatus = InstanceStreamStatus @@ -173,6 +181,9 @@ class SSEManager { case "background.process.removed": this.onBackgroundProcessRemoved?.(instanceId, event as BackgroundProcessRemovedEvent) break + case "server.instance.disposed": + this.onInstanceDisposed?.(instanceId, event as ServerInstanceDisposedEvent) + break default: log.warn("Unknown SSE event type", { type: event.type }) } @@ -205,6 +216,7 @@ class SSEManager { onLspUpdated?: (instanceId: string, event: EventLspUpdated) => void onBackgroundProcessUpdated?: (instanceId: string, event: BackgroundProcessUpdatedEvent) => void onBackgroundProcessRemoved?: (instanceId: string, event: BackgroundProcessRemovedEvent) => void + onInstanceDisposed?: (instanceId: string, event: ServerInstanceDisposedEvent) => void onConnectionLost?: (instanceId: string, reason: string) => void | Promise getStatus(instanceId: string): ConnectionStatus | null { diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index f2a39bda..66ca1f38 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -6,7 +6,7 @@ import { getPermissionCreatedAt, getPermissionSessionId } from "../types/permiss import type { QuestionRequest } from "@opencode-ai/sdk/v2" import { getQuestionSessionId } from "../types/question" import { requestData } from "../lib/opencode-api" -import { sdkManager } from "../lib/sdk-manager" +import { buildInstanceBaseUrl, sdkManager } from "../lib/sdk-manager" import { sseManager } from "../lib/sse-manager" import { serverApi } from "../lib/api-client" import { serverEvents } from "../lib/server-events" @@ -18,7 +18,14 @@ import { fetchProviders, clearInstanceDraftPrompts, } from "./sessions" -import { ensureWorktreesLoaded, ensureWorktreeMapLoaded, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "./worktrees" +import { + ensureWorktreesLoaded, + ensureWorktreeMapLoaded, + getOrCreateWorktreeClient, + getWorktreeSlugForSession, + reloadWorktreeMap, + reloadWorktrees, +} from "./worktrees" import { fetchCommands, clearCommands } from "./commands" import { serverSettings } from "./preferences" import { setSessionPendingPermission, setSessionPendingQuestion } from "./session-state" @@ -76,6 +83,9 @@ const [disconnectedInstance, setDisconnectedInstance] = createSignal>() +const pendingRehydrations = new Map>() + function workspaceDescriptorToInstance(descriptor: WorkspaceDescriptor): Instance { const existing = instances().get(descriptor.id) return { @@ -228,10 +238,15 @@ async function syncPendingQuestions(instanceId: string): Promise { } } -async function hydrateInstanceData(instanceId: string) { +async function hydrateInstanceData(instanceId: string, options?: { force?: boolean }) { try { - await ensureWorktreesLoaded(instanceId) - await ensureWorktreeMapLoaded(instanceId) + if (options?.force) { + await reloadWorktrees(instanceId) + await reloadWorktreeMap(instanceId) + } else { + await ensureWorktreesLoaded(instanceId) + await ensureWorktreeMapLoaded(instanceId) + } await fetchSessions(instanceId) await fetchAgents(instanceId) await fetchProviders(instanceId) @@ -246,6 +261,91 @@ async function hydrateInstanceData(instanceId: string) { } } +async function postInstanceDispose(instanceId: string): Promise { + const instance = instances().get(instanceId) + if (!instance?.proxyPath) { + throw new Error("Instance not ready") + } + + const baseUrl = buildInstanceBaseUrl(instance.proxyPath) + const url = new URL("instance/dispose", baseUrl) + + const response = await fetch(url.toString(), { + method: "POST", + credentials: "include", + headers: { + Accept: "application/json", + }, + }) + + if (!response.ok) { + const message = await response.text().catch(() => "") + throw new Error(message || `Dispose request failed with ${response.status}`) + } + + const contentType = response.headers.get("content-type") ?? "" + if (contentType.includes("application/json")) { + const data = await response.json().catch(() => undefined) + if (typeof data === "boolean") return data + if (data && typeof data === "object" && "data" in (data as any)) { + return Boolean((data as any).data) + } + return Boolean(data) + } + + const text = await response.text().catch(() => "") + if (text.trim() === "true") return true + if (text.trim() === "false") return false + return Boolean(text) +} + +async function rehydrateInstance(instanceId: string, options?: { reason?: string }): Promise { + if (pendingRehydrations.has(instanceId)) { + return pendingRehydrations.get(instanceId) + } + + const promise = (async () => { + const instance = instances().get(instanceId) + if (!instance?.client) { + return + } + + log.info("Rehydrating instance", { instanceId, reason: options?.reason }) + clearCacheForInstance(instanceId) + clearCommands(instanceId) + clearInstanceMetadata(instanceId) + clearInstanceDraftPrompts(instanceId) + clearPermissionQueue(instanceId) + clearQuestionQueue(instanceId) + + await hydrateInstanceData(instanceId, { force: true }) + })().finally(() => { + pendingRehydrations.delete(instanceId) + }) + + pendingRehydrations.set(instanceId, promise) + return promise +} + +async function disposeInstance(instanceId: string): Promise { + if (pendingDisposeRequests.has(instanceId)) { + return pendingDisposeRequests.get(instanceId)! + } + + const promise = (async () => { + const ok = await postInstanceDispose(instanceId) + if (ok) { + await rehydrateInstance(instanceId, { reason: "disposed" }) + } + return ok + })().finally(() => { + pendingDisposeRequests.delete(instanceId) + }) + + pendingDisposeRequests.set(instanceId, promise) + return promise +} + void (async function initializeWorkspaces() { try { const workspaces = await serverApi.fetchWorkspaces() @@ -939,6 +1039,30 @@ sseManager.onLspUpdated = async (instanceId) => { } } +sseManager.onInstanceDisposed = (sourceInstanceId, event) => { + const directory = event?.properties?.directory + if (!directory) { + void rehydrateInstance(sourceInstanceId, { reason: "disposed" }) + return + } + + const matchingInstanceIds: string[] = [] + for (const instance of instances().values()) { + if (instance.folder === directory) { + matchingInstanceIds.push(instance.id) + } + } + + if (matchingInstanceIds.length === 0) { + void rehydrateInstance(sourceInstanceId, { reason: "disposed" }) + return + } + + for (const instanceId of matchingInstanceIds) { + void rehydrateInstance(instanceId, { reason: "disposed" }) + } +} + async function acknowledgeDisconnectedInstance(): Promise { const pending = disconnectedInstance() if (!pending) { @@ -995,4 +1119,5 @@ export { disconnectedInstance, acknowledgeDisconnectedInstance, fetchLspStatus, + disposeInstance, } diff --git a/packages/ui/src/styles/components/buttons.css b/packages/ui/src/styles/components/buttons.css index 71c6c4c5..105d42a4 100644 --- a/packages/ui/src/styles/components/buttons.css +++ b/packages/ui/src/styles/components/buttons.css @@ -54,3 +54,28 @@ button.button-tertiary:hover:not(:disabled) { button.button-tertiary:focus-visible { box-shadow: 0 0 0 2px var(--focus-ring-offset), 0 0 0 4px var(--focus-ring-color); } + +.button-danger, +button.button-danger { + @apply px-6 py-3 text-base rounded-lg; + background-color: var(--button-danger-bg); + color: var(--button-danger-text); + border-color: var(--button-danger-bg); +} + +.button-danger:hover:not(:disabled), +button.button-danger:hover:not(:disabled) { + background-color: var(--button-danger-hover-bg); + border-color: var(--button-danger-hover-bg); +} + +.button-danger:focus-visible, +button.button-danger:focus-visible { + box-shadow: 0 0 0 2px var(--focus-ring-offset), 0 0 0 4px var(--focus-ring-color); +} + +/* Smaller sizing variant for destructive actions in tight spaces. */ +.button-danger.button-small, +button.button-danger.button-small { + @apply px-4 py-2 text-sm; +} diff --git a/packages/ui/src/styles/utilities.css b/packages/ui/src/styles/utilities.css index 44aaa1fd..1e387127 100644 --- a/packages/ui/src/styles/utilities.css +++ b/packages/ui/src/styles/utilities.css @@ -64,6 +64,8 @@ button.button-primary, .button-secondary, button.button-secondary, + .button-danger, + button.button-danger, .button-tertiary, button.button-tertiary) { @apply inline-flex items-center justify-center gap-2 font-medium transition-colors rounded-md; @@ -74,6 +76,8 @@ button.button-primary, .button-secondary, button.button-secondary, + .button-danger, + button.button-danger, .button-tertiary, button.button-tertiary):focus-visible { outline: none; @@ -84,6 +88,8 @@ button.button-primary, .button-secondary, button.button-secondary, + .button-danger, + button.button-danger, .button-tertiary, button.button-tertiary):disabled { @apply cursor-not-allowed opacity-50; From 127a1f628d32d73b07f0a6973f1018632dbbade5 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Wed, 18 Feb 2026 09:43:30 +0000 Subject: [PATCH 15/37] feat(server,ui): allow OpenCode directory override via proxy path --- packages/server/src/server/http-server.ts | 142 ++++++++++++++++++++-- packages/ui/src/lib/sdk-manager.ts | 16 +-- packages/ui/src/stores/worktrees.ts | 28 +++++ 3 files changed, 168 insertions(+), 18 deletions(-) diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index 3fc7106e..dd36f882 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -367,6 +367,21 @@ function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDe const INSTANCE_PROXY_HOST = "127.0.0.1" +// Special-case OpenCode directory override. +// +// UI clients may need to scope certain requests to an arbitrary directory that is not +// part of the Git worktree list. Since the OpenCode SDK does not reliably support +// injecting per-request headers, we encode an override into the *path* and strip it +// before proxying to the instance. +// +// Example proxied request path: +// /workspaces/:id/worktrees/:slug/instance/__dir//session/create +// +// The server will decode -> absolute directory, validate it, then set +// x-opencode-directory accordingly and forward the request to /session/create. +const OPENCODE_DIR_OVERRIDE_PREFIX = "__dir/" +const OPENCODE_DIR_OVERRIDE_MAX_LEN = 4096 + async function proxyWorkspaceRequest(args: { request: FastifyRequest reply: FastifyReply @@ -457,19 +472,43 @@ async function proxyWorkspaceRequest(args: { return } - const directory = await resolveWorktreeDirectory({ - workspaceId, - workspacePath: workspace.path, - worktreeSlug, - logger, - }) - - if (!directory) { - reply.code(404).send({ error: "Worktree not found" }) + let extracted: { overrideDirectory: string | null; forwardedSuffix: string | undefined } + try { + extracted = extractOpencodeDirectoryOverride(args.pathSuffix) + } catch (error) { + const message = error instanceof Error ? error.message : "Invalid directory override" + reply.code(400).send({ error: message }) return } + let directory: string | null = null + let forwardedSuffix = extracted.forwardedSuffix - const normalizedSuffix = normalizeInstanceSuffix(args.pathSuffix) + if (extracted.overrideDirectory) { + try { + directory = validateAndNormalizeOverrideDirectory({ + overrideDirectory: extracted.overrideDirectory, + workspaceRoot: workspace.path, + }) + } catch (error) { + const message = error instanceof Error ? error.message : "Invalid directory override" + reply.code(400).send({ error: message }) + return + } + } else { + directory = await resolveWorktreeDirectory({ + workspaceId, + workspacePath: workspace.path, + worktreeSlug, + logger, + }) + + if (!directory) { + reply.code(404).send({ error: "Worktree not found" }) + return + } + } + + const normalizedSuffix = normalizeInstanceSuffix(forwardedSuffix) const queryIndex = (request.raw.url ?? "").indexOf("?") const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : "" const targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}` @@ -533,6 +572,89 @@ async function proxyWorkspaceRequest(args: { }) } +function extractOpencodeDirectoryOverride(pathSuffix: string | undefined): { + overrideDirectory: string | null + forwardedSuffix: string | undefined +} { + if (!pathSuffix) { + return { overrideDirectory: null, forwardedSuffix: pathSuffix } + } + + // Fastify wildcard param does not include a leading slash. + const trimmed = pathSuffix.replace(/^\/+/, "") + if (!trimmed.startsWith(OPENCODE_DIR_OVERRIDE_PREFIX)) { + return { overrideDirectory: null, forwardedSuffix: pathSuffix } + } + + const rest = trimmed.slice(OPENCODE_DIR_OVERRIDE_PREFIX.length) + const slashIndex = rest.indexOf("/") + const encoded = (slashIndex >= 0 ? rest.slice(0, slashIndex) : rest).trim() + const remaining = slashIndex >= 0 ? rest.slice(slashIndex + 1) : "" + + if (!encoded) { + throw new Error("Missing directory override") + } + + if (encoded.length > OPENCODE_DIR_OVERRIDE_MAX_LEN) { + throw new Error("Directory override too large") + } + + let overrideDirectory = "" + try { + overrideDirectory = decodeBase64Url(encoded) + } catch { + throw new Error("Invalid directory override") + } + const forwardedSuffix = remaining + return { overrideDirectory, forwardedSuffix } +} + +function decodeBase64Url(input: string): string { + // base64url -> base64 + const normalized = input.replace(/-/g, "+").replace(/_/g, "/") + const padding = normalized.length % 4 === 0 ? "" : "=".repeat(4 - (normalized.length % 4)) + const base64 = `${normalized}${padding}` + return Buffer.from(base64, "base64").toString("utf-8") +} + +function validateAndNormalizeOverrideDirectory(params: { overrideDirectory: string; workspaceRoot: string }): string { + const raw = params.overrideDirectory.trim() + if (!raw) { + throw new Error("Override directory is empty") + } + + if (!path.isAbsolute(raw)) { + throw new Error("Override directory must be an absolute path") + } + + if (!fs.existsSync(raw)) { + throw new Error(`Override directory does not exist: ${raw}`) + } + + const stats = fs.statSync(raw) + if (!stats.isDirectory()) { + throw new Error(`Override path is not a directory: ${raw}`) + } + + const normalizedOverride = fs.realpathSync(raw) + const normalizedRoot = fs.realpathSync(params.workspaceRoot) + + if (!isSubpath(normalizedOverride, normalizedRoot)) { + throw new Error("Override directory must be within the workspace root") + } + + return normalizedOverride +} + +function isSubpath(candidate: string, root: string): boolean { + const rel = path.relative(root, candidate) + if (rel === "") return true + if (rel === "..") return false + if (rel.startsWith(`..${path.sep}`)) return false + if (path.isAbsolute(rel)) return false + return true +} + function normalizeInstanceSuffix(pathSuffix: string | undefined) { if (!pathSuffix || pathSuffix === "/") { return "/" diff --git a/packages/ui/src/lib/sdk-manager.ts b/packages/ui/src/lib/sdk-manager.ts index 5f525eda..2e4ee3c1 100644 --- a/packages/ui/src/lib/sdk-manager.ts +++ b/packages/ui/src/lib/sdk-manager.ts @@ -4,12 +4,12 @@ import { CODENOMAD_API_BASE } from "./api-client" class SDKManager { private clients = new Map() - private key(instanceId: string, worktreeSlug: string): string { - return `${instanceId}:${worktreeSlug || "root"}` + private key(instanceId: string, proxyPath: string): string { + return `${instanceId}:${normalizeProxyPath(proxyPath)}` } - createClient(instanceId: string, proxyPath: string, worktreeSlug = "root"): OpencodeClient { - const key = this.key(instanceId, worktreeSlug) + createClient(instanceId: string, proxyPath: string, _worktreeSlug = "root"): OpencodeClient { + const key = this.key(instanceId, proxyPath) const existing = this.clients.get(key) if (existing) { return existing @@ -23,12 +23,12 @@ class SDKManager { return client } - getClient(instanceId: string, worktreeSlug = "root"): OpencodeClient | null { - return this.clients.get(this.key(instanceId, worktreeSlug)) ?? null + getClient(instanceId: string, proxyPath: string): OpencodeClient | null { + return this.clients.get(this.key(instanceId, proxyPath)) ?? null } - destroyClient(instanceId: string, worktreeSlug = "root"): void { - this.clients.delete(this.key(instanceId, worktreeSlug)) + destroyClient(instanceId: string, proxyPath: string): void { + this.clients.delete(this.key(instanceId, proxyPath)) } destroyClientsForInstance(instanceId: string): void { diff --git a/packages/ui/src/stores/worktrees.ts b/packages/ui/src/stores/worktrees.ts index cc541ddc..4337d32c 100644 --- a/packages/ui/src/stores/worktrees.ts +++ b/packages/ui/src/stores/worktrees.ts @@ -329,12 +329,38 @@ function buildWorktreeProxyPath(instanceId: string, slug: string): string { return `/workspaces/${encodeURIComponent(instanceId)}/worktrees/${encodeURIComponent(normalizedSlug)}/instance` } +function encodeBase64UrlUtf8(input: string): string { + const bytes = new TextEncoder().encode(input) + // Convert bytes -> base64 (btoa expects a binary string) + let binary = "" + const chunkSize = 0x8000 + for (let i = 0; i < bytes.length; i += chunkSize) { + const chunk = bytes.subarray(i, i + chunkSize) + binary += String.fromCharCode(...chunk) + } + const base64 = btoa(binary) + // base64 -> base64url (strip padding) + return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "") +} + +function buildWorktreeProxyPathWithDirectoryOverride(instanceId: string, slug: string, directory: string): string { + const base = buildWorktreeProxyPath(instanceId, slug) + const encoded = encodeBase64UrlUtf8(directory) + return `${base}/__dir/${encoded}` +} + function getOrCreateWorktreeClient(instanceId: string, slug: string): OpencodeClient { const normalized = normalizeWorktreeSlug(instanceId, slug || "root") const proxyPath = buildWorktreeProxyPath(instanceId, normalized) return sdkManager.createClient(instanceId, proxyPath, normalized) } +function getOrCreateWorktreeClientWithDirectoryOverride(instanceId: string, slug: string, directory: string): OpencodeClient { + const normalized = normalizeWorktreeSlug(instanceId, slug || "root") + const proxyPath = buildWorktreeProxyPathWithDirectoryOverride(instanceId, normalized, directory) + return sdkManager.createClient(instanceId, proxyPath, normalized) +} + function getRootClient(instanceId: string): OpencodeClient { return getOrCreateWorktreeClient(instanceId, "root") } @@ -359,7 +385,9 @@ export { removeParentSessionMapping, getWorktreeSlugForDirectory, buildWorktreeProxyPath, + buildWorktreeProxyPathWithDirectoryOverride, getOrCreateWorktreeClient, + getOrCreateWorktreeClientWithDirectoryOverride, getRootClient, createWorktree, deleteWorktree, From f75c942162e5bc1108cf91549360f788cda43ca8 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Wed, 18 Feb 2026 16:00:58 +0000 Subject: [PATCH 16/37] fix(ui): exclude hidden agents from pickers --- packages/ui/src/components/agent-selector.tsx | 8 ++++---- packages/ui/src/components/unified-picker.tsx | 5 +++-- packages/ui/src/stores/session-api.ts | 1 + packages/ui/src/types/session.ts | 1 + 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/components/agent-selector.tsx b/packages/ui/src/components/agent-selector.tsx index a06568d9..b5c6d5da 100644 --- a/packages/ui/src/components/agent-selector.tsx +++ b/packages/ui/src/components/agent-selector.tsx @@ -31,10 +31,10 @@ export default function AgentSelector(props: AgentSelectorProps) { const availableAgents = createMemo(() => { const allAgents = instanceAgents() if (isChildSession()) { - return allAgents + return allAgents.filter((agent) => !agent.hidden) } - const filtered = allAgents.filter((agent) => agent.mode !== "subagent") + const filtered = allAgents.filter((agent) => !agent.hidden && agent.mode !== "subagent") const currentAgent = allAgents.find((a) => a.name === props.currentAgent) if (currentAgent && !filtered.find((a) => a.name === props.currentAgent)) { @@ -103,10 +103,10 @@ export default function AgentSelector(props: AgentSelectorProps) { >
> - {(state) => ( + {() => (
- {t("agentSelector.trigger.primary", { agent: state.selectedOption()?.name ?? t("agentSelector.none") })} + {t("agentSelector.trigger.primary", { agent: props.currentAgent || t("agentSelector.none") })}
)} diff --git a/packages/ui/src/components/unified-picker.tsx b/packages/ui/src/components/unified-picker.tsx index 70a0be97..76d7cb7a 100644 --- a/packages/ui/src/components/unified-picker.tsx +++ b/packages/ui/src/components/unified-picker.tsx @@ -287,13 +287,14 @@ const UnifiedPicker: Component = (props) => { if (mode() !== "mention") return const query = props.searchQuery.toLowerCase() + const visibleAgents = props.agents.filter((agent) => !agent.hidden) const filtered = query - ? props.agents.filter( + ? visibleAgents.filter( (agent) => agent.name.toLowerCase().includes(query) || (agent.description && agent.description.toLowerCase().includes(query)), ) - : props.agents + : visibleAgents setFilteredAgents(filtered) }) diff --git a/packages/ui/src/stores/session-api.ts b/packages/ui/src/stores/session-api.ts index 9fd12b2c..6c904fd7 100644 --- a/packages/ui/src/stores/session-api.ts +++ b/packages/ui/src/stores/session-api.ts @@ -526,6 +526,7 @@ async function fetchAgents(instanceId: string): Promise { name: agent.name, description: agent.description || "", mode: agent.mode, + hidden: agent.hidden, model: agent.model?.modelID ? { providerId: agent.model.providerID || "", diff --git a/packages/ui/src/types/session.ts b/packages/ui/src/types/session.ts index d0a864d5..ab7f6c89 100644 --- a/packages/ui/src/types/session.ts +++ b/packages/ui/src/types/session.ts @@ -68,6 +68,7 @@ export interface Agent { name: string description: string mode: string + hidden?: boolean model?: { providerId: string modelId: string From b4121696bbd68cc9bc56d1c91e8baa86b9fe6bbb Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Wed, 18 Feb 2026 19:56:42 +0000 Subject: [PATCH 17/37] fix(ui): track worktree context for question replies Store the originating worktree slug when questions are enqueued and use the stored worktree client when replying/rejecting from the global permission center. This ensures question responses are sent through the correct worktree, matching the behavior already implemented for permissions. --- packages/ui/src/stores/instances.ts | 32 +++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index 66ca1f38..20c63443 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -52,6 +52,8 @@ const permissionSessionCounts = new Map>() const permissionWorktreeSlugByInstance = new Map>() const [questionQueues, setQuestionQueues] = createSignal>(new Map()) +// Track which worktree a question was enqueued under (by question request id). +const questionWorktreeSlugByInstance = new Map>() const [activeQuestionId, setActiveQuestionId] = createSignal>(new Map()) const questionSessionCounts = new Map>() const questionEnqueuedAt = new Map() @@ -877,6 +879,16 @@ function addQuestionToQueue(instanceId: string, request: QuestionRequest): void if (sessionId) { incrementQuestionSessionPendingCount(instanceId, sessionId) setSessionPendingQuestion(instanceId, sessionId, true) + + // Record the worktree slug at the time the question is enqueued. + // This is used to respond in the same worktree context even from the global permission center. + const slug = getWorktreeSlugForSession(instanceId, sessionId) + let byQuestionId = questionWorktreeSlugByInstance.get(instanceId) + if (!byQuestionId) { + byQuestionId = new Map() + questionWorktreeSlugByInstance.set(instanceId, byQuestionId) + } + byQuestionId.set(request.id, slug) } } @@ -897,6 +909,7 @@ function removeQuestionFromQueue(instanceId: string, requestId: string): void { }) questionEnqueuedAt.delete(requestId) + questionWorktreeSlugByInstance.get(instanceId)?.delete(requestId) recomputeActiveInterruption(instanceId) if (removedSessionId) { @@ -909,6 +922,7 @@ function clearQuestionQueue(instanceId: string): void { for (const request of getQuestionQueue(instanceId)) { questionEnqueuedAt.delete(request.id) } + questionWorktreeSlugByInstance.delete(instanceId) setQuestionQueues((prev) => { const next = new Map(prev) @@ -934,7 +948,7 @@ function setActiveQuestionIdForInstance(instanceId: string, requestId: string): async function sendQuestionReply( instanceId: string, - _sessionId: string, + sessionId: string, requestId: string, answers: string[][], ): Promise { @@ -944,8 +958,13 @@ async function sendQuestionReply( } try { + const stored = questionWorktreeSlugByInstance.get(instanceId)?.get(requestId) + const fallback = sessionId ? getWorktreeSlugForSession(instanceId, sessionId) : "root" + const worktreeSlug = stored ?? fallback + const client = getOrCreateWorktreeClient(instanceId, worktreeSlug) + await requestData( - instance.client.question.reply({ + client.question.reply({ requestID: requestId, answers, }), @@ -959,15 +978,20 @@ async function sendQuestionReply( } } -async function sendQuestionReject(instanceId: string, _sessionId: string, requestId: string): Promise { +async function sendQuestionReject(instanceId: string, sessionId: string, requestId: string): Promise { const instance = instances().get(instanceId) if (!instance?.client) { throw new Error("Instance not ready") } try { + const stored = questionWorktreeSlugByInstance.get(instanceId)?.get(requestId) + const fallback = sessionId ? getWorktreeSlugForSession(instanceId, sessionId) : "root" + const worktreeSlug = stored ?? fallback + const client = getOrCreateWorktreeClient(instanceId, worktreeSlug) + await requestData( - instance.client.question.reject({ + client.question.reject({ requestID: requestId, }), "question.reject", From d45a1ff0785603d251f0b967d84bcadad142556c Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Wed, 18 Feb 2026 19:59:54 +0000 Subject: [PATCH 18/37] Bump to v0.11.3 --- package-lock.json | 12 ++++++------ package.json | 2 +- packages/electron-app/package.json | 2 +- packages/server/package-lock.json | 4 ++-- packages/server/package.json | 2 +- packages/tauri-app/package.json | 2 +- packages/ui/package.json | 2 +- 7 files changed, 13 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index f2ff2632..71bff3c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codenomad-workspace", - "version": "0.11.2", + "version": "0.11.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codenomad-workspace", - "version": "0.11.2", + "version": "0.11.3", "license": "MIT", "dependencies": { "7zip-bin": "^5.2.0", @@ -11985,7 +11985,7 @@ }, "packages/electron-app": { "name": "@neuralnomads/codenomad-electron-app", - "version": "0.11.2", + "version": "0.11.3", "license": "MIT", "dependencies": { "@codenomad/ui": "file:../ui", @@ -12021,7 +12021,7 @@ }, "packages/server": { "name": "@neuralnomads/codenomad", - "version": "0.11.2", + "version": "0.11.3", "license": "MIT", "dependencies": { "@fastify/cors": "^8.5.0", @@ -12062,7 +12062,7 @@ }, "packages/tauri-app": { "name": "@codenomad/tauri-app", - "version": "0.11.2", + "version": "0.11.3", "license": "MIT", "devDependencies": { "@tauri-apps/cli": "^2.9.4" @@ -12070,7 +12070,7 @@ }, "packages/ui": { "name": "@codenomad/ui", - "version": "0.11.2", + "version": "0.11.3", "license": "MIT", "dependencies": { "@git-diff-view/solid": "^0.0.8", diff --git a/package.json b/package.json index e806f55e..566968db 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codenomad-workspace", - "version": "0.11.2", + "version": "0.11.3", "private": true, "description": "CodeNomad monorepo workspace", "license": "MIT", diff --git a/packages/electron-app/package.json b/packages/electron-app/package.json index 12b0a260..740a48cd 100644 --- a/packages/electron-app/package.json +++ b/packages/electron-app/package.json @@ -1,6 +1,6 @@ { "name": "@neuralnomads/codenomad-electron-app", - "version": "0.11.2", + "version": "0.11.3", "description": "CodeNomad - AI coding assistant", "license": "MIT", "author": { diff --git a/packages/server/package-lock.json b/packages/server/package-lock.json index 6b8c9f13..b3632da1 100644 --- a/packages/server/package-lock.json +++ b/packages/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "@neuralnomads/codenomad", - "version": "0.11.2", + "version": "0.11.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@neuralnomads/codenomad", - "version": "0.11.2", + "version": "0.11.3", "dependencies": { "@fastify/cors": "^8.5.0", "@fastify/reply-from": "^9.8.0", diff --git a/packages/server/package.json b/packages/server/package.json index ac83b41e..46216b6f 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@neuralnomads/codenomad", - "version": "0.11.2", + "version": "0.11.3", "description": "CodeNomad Server", "license": "MIT", "author": { diff --git a/packages/tauri-app/package.json b/packages/tauri-app/package.json index 10ce8f59..0b822254 100644 --- a/packages/tauri-app/package.json +++ b/packages/tauri-app/package.json @@ -1,6 +1,6 @@ { "name": "@codenomad/tauri-app", - "version": "0.11.2", + "version": "0.11.3", "private": true, "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index e336388a..a6235618 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@codenomad/ui", - "version": "0.11.2", + "version": "0.11.3", "private": true, "license": "MIT", "type": "module", From e84adebe610d9f250d658e72dc5c82b6b3027398 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Thu, 19 Feb 2026 07:24:14 +0000 Subject: [PATCH 19/37] fix(server): detect OpenCode version via spawn spec --- packages/server/src/server/routes/settings.ts | 36 +----------- packages/server/src/workspaces/manager.ts | 34 +++++------ packages/server/src/workspaces/runtime.ts | 57 +++++++++++++++++++ 3 files changed, 74 insertions(+), 53 deletions(-) diff --git a/packages/server/src/server/routes/settings.ts b/packages/server/src/server/routes/settings.ts index 4f5a70eb..e5673275 100644 --- a/packages/server/src/server/routes/settings.ts +++ b/packages/server/src/server/routes/settings.ts @@ -1,7 +1,6 @@ import { FastifyInstance } from "fastify" import { z } from "zod" -import { spawnSync } from "child_process" -import { buildSpawnSpec } from "../../workspaces/runtime" +import { probeBinaryVersion } from "../../workspaces/runtime" import type { SettingsService } from "../../settings/service" import type { Logger } from "../../logger" @@ -15,37 +14,8 @@ const ValidateBinarySchema = z.object({ }) function validateBinaryPath(binaryPath: string): { valid: boolean; version?: string; error?: string } { - if (!binaryPath) { - return { valid: false, error: "Missing binary path" } - } - - const spec = buildSpawnSpec(binaryPath, ["--version"]) - try { - const result = spawnSync(spec.command, spec.args, { - encoding: "utf8", - windowsVerbatimArguments: Boolean((spec.options as { windowsVerbatimArguments?: boolean }).windowsVerbatimArguments), - }) - - if (result.error) { - return { valid: false, error: result.error.message } - } - if (result.status !== 0) { - const stderr = result.stderr?.trim() - const stdout = result.stdout?.trim() - const combined = stderr || stdout - const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}` - return { valid: false, error } - } - - const stdout = (result.stdout ?? "").trim() - const firstLine = stdout.split(/\r?\n/).find((line) => line.trim().length > 0) - const normalized = firstLine?.trim() - const versionMatch = normalized?.match(/([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/) - const version = versionMatch?.[1] - return { valid: true, version } - } catch (error) { - return { valid: false, error: error instanceof Error ? error.message : String(error) } - } + const result = probeBinaryVersion(binaryPath) + return { valid: result.valid, version: result.version, error: result.error } } export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) { diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index a4b50e06..a6d03c66 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -8,7 +8,7 @@ import { FileSystemBrowser } from "../filesystem/browser" import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search" import { clearWorkspaceSearchCache } from "../filesystem/search-cache" import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types" -import { WorkspaceRuntime, ProcessExitInfo } from "./runtime" +import { WorkspaceRuntime, ProcessExitInfo, probeBinaryVersion } from "./runtime" import { Logger } from "../logger" import { getOpencodeConfigDir } from "../opencode-config.js" import { @@ -283,28 +283,22 @@ export class WorkspaceManager { return undefined } - try { - const result = spawnSync(resolvedPath, ["--version"], { encoding: "utf8" }) - if (result.status === 0 && result.stdout) { - const line = result.stdout.split(/\r?\n/).find((entry) => entry.trim().length > 0) - if (line) { - const normalized = line.trim() - const versionMatch = normalized.match(/([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/) - if (versionMatch) { - const version = versionMatch[1] - this.options.logger.debug({ binary: resolvedPath, version }, "Detected binary version") - return version - } - this.options.logger.debug({ binary: resolvedPath, reported: normalized }, "Binary reported version string") - return normalized - } - } else if (result.error) { - this.options.logger.warn({ binary: resolvedPath, err: result.error }, "Failed to read binary version") + const result = probeBinaryVersion(resolvedPath) + if (result.valid) { + if (result.version) { + this.options.logger.debug({ binary: resolvedPath, version: result.version }, "Detected binary version") + return result.version } - } catch (error) { - this.options.logger.warn({ binary: resolvedPath, err: error }, "Failed to detect binary version") + if (result.reported) { + this.options.logger.debug({ binary: resolvedPath, reported: result.reported }, "Binary reported version string") + return result.reported + } + return undefined } + if (result.error) { + this.options.logger.warn({ binary: resolvedPath, err: result.error }, "Failed to detect binary version") + } return undefined } diff --git a/packages/server/src/workspaces/runtime.ts b/packages/server/src/workspaces/runtime.ts index a7196d60..0246fbfd 100644 --- a/packages/server/src/workspaces/runtime.ts +++ b/packages/server/src/workspaces/runtime.ts @@ -8,6 +8,8 @@ import { Logger } from "../logger" export const WINDOWS_CMD_EXTENSIONS = new Set([".cmd", ".bat"]) export const WINDOWS_POWERSHELL_EXTENSIONS = new Set([".ps1"]) +const VERSION_REGEX = /([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/ + export function buildSpawnSpec(binaryPath: string, args: string[]) { if (process.platform !== "win32") { return { command: binaryPath, args, options: {} as const } @@ -40,6 +42,61 @@ export function buildSpawnSpec(binaryPath: string, args: string[]) { return { command: binaryPath, args, options: {} as const } } +export function probeBinaryVersion(binaryPath: string): { + valid: boolean + version?: string + reported?: string + error?: string +} { + if (!binaryPath) { + return { valid: false, error: "Missing binary path" } + } + + const spec = buildSpawnSpec(binaryPath, ["--version"]) + + try { + const result = spawnSync(spec.command, spec.args, { + encoding: "utf8", + windowsVerbatimArguments: Boolean( + (spec.options as { windowsVerbatimArguments?: boolean }).windowsVerbatimArguments, + ), + }) + + if (result.error) { + return { valid: false, error: result.error.message } + } + + if (result.status !== 0) { + const stderr = result.stderr?.trim() + const stdout = result.stdout?.trim() + const combined = stderr || stdout + const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}` + return { valid: false, error } + } + + const stdoutLines = String(result.stdout ?? "") + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0) + const stderrLines = String(result.stderr ?? "") + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0) + + // Prefer stdout; fall back to stderr (some tools report version there). + const reported = stdoutLines[0] ?? stderrLines[0] + if (!reported) { + return { valid: true } + } + + const versionMatch = reported.match(VERSION_REGEX) + const version = versionMatch?.[1] + return { valid: true, version, reported } + } catch (error) { + return { valid: false, error: error instanceof Error ? error.message : String(error) } + } +} + const SENSITIVE_ENV_KEY = /(PASSWORD|TOKEN|SECRET)/i function redactEnvironment(env: Record): Record { From c639e535b5488a1b973a10c8cd55399666e5a691 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Thu, 19 Feb 2026 10:40:51 +0000 Subject: [PATCH 20/37] fix(ui): add blank line after inserted quotes --- packages/ui/src/components/prompt-input.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/prompt-input.tsx b/packages/ui/src/components/prompt-input.tsx index a52b0f8c..63c80fa1 100644 --- a/packages/ui/src/components/prompt-input.tsx +++ b/packages/ui/src/components/prompt-input.tsx @@ -351,7 +351,9 @@ export default function PromptInput(props: PromptInputProps) { const blockquote = lines.map((line) => `> ${line}`).join("\n") if (!blockquote) return - insertBlockContent(`${blockquote}\n`) + // End the blockquote with a blank line so the user's next line + // doesn't get parsed as a lazy continuation of the quote. + insertBlockContent(`${blockquote}\n\n`) } function insertCodeSelection(rawText: string) { From ce370d5100c990cd073e9ad316a15030b4c8b638 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Thu, 19 Feb 2026 14:21:13 +0000 Subject: [PATCH 21/37] fix(server): read OpenCode version from /global/health --- packages/server/src/workspaces/manager.ts | 67 ++++++++++------------- 1 file changed, 29 insertions(+), 38 deletions(-) diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index a6d03c66..602589ee 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -8,7 +8,7 @@ import { FileSystemBrowser } from "../filesystem/browser" import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search" import { clearWorkspaceSearchCache } from "../filesystem/search-cache" import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types" -import { WorkspaceRuntime, ProcessExitInfo, probeBinaryVersion } from "./runtime" +import { WorkspaceRuntime, ProcessExitInfo } from "./runtime" import { Logger } from "../logger" import { getOpencodeConfigDir } from "../opencode-config.js" import { @@ -109,10 +109,6 @@ export class WorkspaceManager { updatedAt: new Date().toISOString(), } - if (!descriptor.binaryVersion) { - descriptor.binaryVersion = this.detectBinaryVersion(resolvedBinaryPath) - } - this.workspaces.set(id, descriptor) @@ -149,7 +145,10 @@ export class WorkspaceManager { onExit: (info) => this.handleProcessExit(info.workspaceId, info), }) - await this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput }) + const runtimeVersion = await this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput }) + if (runtimeVersion) { + descriptor.binaryVersion = runtimeVersion + } descriptor.pid = pid descriptor.port = port @@ -278,36 +277,12 @@ export class WorkspaceManager { return candidates[0] ?? "" } - private detectBinaryVersion(resolvedPath: string): string | undefined { - if (!resolvedPath) { - return undefined - } - - const result = probeBinaryVersion(resolvedPath) - if (result.valid) { - if (result.version) { - this.options.logger.debug({ binary: resolvedPath, version: result.version }, "Detected binary version") - return result.version - } - if (result.reported) { - this.options.logger.debug({ binary: resolvedPath, reported: result.reported }, "Binary reported version string") - return result.reported - } - return undefined - } - - if (result.error) { - this.options.logger.warn({ binary: resolvedPath, err: result.error }, "Failed to detect binary version") - } - return undefined - } - private async waitForWorkspaceReadiness(params: { workspaceId: string port: number exitPromise: Promise getLastOutput: () => string - }) { + }): Promise { await Promise.race([ this.waitForPortAvailability(params.port), @@ -321,7 +296,7 @@ export class WorkspaceManager { }), ]) - await this.waitForInstanceHealth(params) + const version = await this.waitForInstanceHealth(params) await Promise.race([ this.delay(STARTUP_STABILITY_DELAY_MS), @@ -334,6 +309,8 @@ export class WorkspaceManager { ) }), ]) + + return version } private async waitForInstanceHealth(params: { @@ -341,7 +318,7 @@ export class WorkspaceManager { port: number exitPromise: Promise getLastOutput: () => string - }) { + }): Promise { const probeResult = await Promise.race([ this.probeInstance(params.workspaceId, params.port), params.exitPromise.then((info) => { @@ -355,7 +332,7 @@ export class WorkspaceManager { ]) if (probeResult.ok) { - return + return probeResult.version } const latestOutput = params.getLastOutput().trim() @@ -366,8 +343,11 @@ export class WorkspaceManager { throw new Error(`Workspace ${params.workspaceId} failed health check: ${reason}.`) } - private async probeInstance(workspaceId: string, port: number): Promise<{ ok: boolean; reason?: string }> { - const url = `http://127.0.0.1:${port}/project/current` + private async probeInstance( + workspaceId: string, + port: number, + ): Promise<{ ok: boolean; reason?: string; version?: string }> { + const url = `http://127.0.0.1:${port}/global/health` try { const headers: Record = {} @@ -378,11 +358,22 @@ export class WorkspaceManager { const response = await fetch(url, { headers }) if (!response.ok) { - const reason = `health probe returned HTTP ${response.status}` + const reason = `/global/health returned HTTP ${response.status}` this.options.logger.debug({ workspaceId, status: response.status }, "Health probe returned server error") return { ok: false, reason } } - return { ok: true } + + const payload = (await response.json().catch(() => null)) as null | { healthy?: unknown; version?: unknown } + const healthy = payload?.healthy === true + const version = typeof payload?.version === "string" ? payload.version.trim() : undefined + + if (!healthy) { + const reason = "Instance reported unhealthy" + this.options.logger.debug({ workspaceId, payload }, "Health probe returned unhealthy response") + return { ok: false, reason } + } + + return { ok: true, version: version || undefined } } catch (error) { const reason = error instanceof Error ? error.message : String(error) this.options.logger.debug({ workspaceId, err: error }, "Health probe failed") From f7ac30afe31fb4fd692ae4b599b27f4f02051a17 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Thu, 19 Feb 2026 15:40:55 +0000 Subject: [PATCH 22/37] revert(ui): restore compact alert dialog --- packages/ui/src/components/alert-dialog.tsx | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/packages/ui/src/components/alert-dialog.tsx b/packages/ui/src/components/alert-dialog.tsx index 045c239a..7ef34570 100644 --- a/packages/ui/src/components/alert-dialog.tsx +++ b/packages/ui/src/components/alert-dialog.tsx @@ -116,11 +116,8 @@ const AlertDialog: Component = () => {
- -
+ +
{ > {accent.symbol}
-
+
{title} - -
- {payload.message} - {payload.detail &&
{payload.detail}
} -
+ + {payload.message} + {payload.detail &&

{payload.detail}

}
From 3b73d9d5b9f11e72ab4b02c0c83a2bd5c2aa3c93 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Thu, 19 Feb 2026 15:40:58 +0000 Subject: [PATCH 23/37] fix(ui): show workspace launch errors in dialog --- packages/ui/src/App.tsx | 45 ++------------------- packages/ui/src/lib/launch-errors.ts | 29 ++++++++++++++ packages/ui/src/stores/instances.ts | 2 + packages/ui/src/stores/launch-errors.ts | 53 +++++++++++++++++++++++++ 4 files changed, 88 insertions(+), 41 deletions(-) create mode 100644 packages/ui/src/lib/launch-errors.ts create mode 100644 packages/ui/src/stores/launch-errors.ts diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index b02861c1..c36ceb1b 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -18,6 +18,8 @@ import { useTheme } from "./lib/theme" import { useCommands } from "./lib/hooks/use-commands" import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle" import { getLogger } from "./lib/logger" +import { launchError, showLaunchError, clearLaunchError } from "./stores/launch-errors" +import { formatLaunchErrorMessage, isMissingBinaryMessage } from "./lib/launch-errors" import { initReleaseNotifications } from "./stores/releases" import { runtimeEnv } from "./lib/runtime-env" import { useI18n } from "./lib/i18n" @@ -74,12 +76,6 @@ const App: Component = () => { setThinkingBlocksExpansion, } = useConfig() const [escapeInDebounce, setEscapeInDebounce] = createSignal(false) - interface LaunchErrorState { - message: string - binaryPath: string - missingBinary: boolean - } - const [launchError, setLaunchError] = createSignal(null) const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = createSignal(false) const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false) const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0) @@ -244,35 +240,6 @@ const App: Component = () => { const launchErrorMessage = () => launchError()?.message ?? "" - const formatLaunchErrorMessage = (error: unknown): string => { - if (!error) { - return t("app.launchError.fallbackMessage") - } - const raw = typeof error === "string" ? error : error instanceof Error ? error.message : String(error) - try { - const parsed = JSON.parse(raw) - if (parsed && typeof parsed.error === "string") { - return parsed.error - } - } catch { - // ignore JSON parse errors - } - return raw - } - - const isMissingBinaryMessage = (message: string): boolean => { - const normalized = message.toLowerCase() - return ( - normalized.includes("opencode binary not found") || - normalized.includes("binary not found") || - normalized.includes("no such file or directory") || - normalized.includes("binary is not executable") || - normalized.includes("enoent") - ) - } - - const clearLaunchError = () => setLaunchError(null) - async function handleSelectFolder(folderPath: string, binaryPath?: string) { if (!folderPath) { return @@ -291,13 +258,9 @@ const App: Component = () => { port: instances().get(instanceId)?.port, }) } catch (error) { - const message = formatLaunchErrorMessage(error) + const message = formatLaunchErrorMessage(error, t("app.launchError.fallbackMessage")) const missingBinary = isMissingBinaryMessage(message) - setLaunchError({ - message, - binaryPath: selectedBinary, - missingBinary, - }) + showLaunchError({ source: "create", message, binaryPath: selectedBinary, missingBinary }) log.error("Failed to create instance", error) } finally { setIsSelectingFolder(false) diff --git a/packages/ui/src/lib/launch-errors.ts b/packages/ui/src/lib/launch-errors.ts new file mode 100644 index 00000000..0d495f97 --- /dev/null +++ b/packages/ui/src/lib/launch-errors.ts @@ -0,0 +1,29 @@ +export function formatLaunchErrorMessage(error: unknown, fallbackMessage: string): string { + if (!error) { + return fallbackMessage + } + + const raw = typeof error === "string" ? error : error instanceof Error ? error.message : String(error) + + try { + const parsed = JSON.parse(raw) as unknown + if (parsed && typeof parsed === "object" && "error" in parsed && typeof (parsed as any).error === "string") { + return (parsed as any).error + } + } catch { + // ignore JSON parse errors + } + + return raw +} + +export function isMissingBinaryMessage(message: string): boolean { + const normalized = message.toLowerCase() + return ( + normalized.includes("opencode binary not found") || + normalized.includes("binary not found") || + normalized.includes("no such file or directory") || + normalized.includes("binary is not executable") || + normalized.includes("enoent") + ) +} diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index 20c63443..2d4bb6ec 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -35,6 +35,7 @@ import { upsertPermissionV2, removePermissionV2, upsertQuestionV2, removeQuestio import { clearCacheForInstance } from "../lib/global-cache" import { getLogger } from "../lib/logger" import { mergeInstanceMetadata, clearInstanceMetadata } from "./instance-metadata" +import { showWorkspaceLaunchError } from "./launch-errors" const log = getLogger("api") @@ -372,6 +373,7 @@ function handleWorkspaceEvent(event: WorkspaceEventPayload) { break case "workspace.error": upsertWorkspace(event.workspace) + showWorkspaceLaunchError(event.workspace) break case "workspace.stopped": releaseInstanceResources(event.workspaceId) diff --git a/packages/ui/src/stores/launch-errors.ts b/packages/ui/src/stores/launch-errors.ts new file mode 100644 index 00000000..814790f8 --- /dev/null +++ b/packages/ui/src/stores/launch-errors.ts @@ -0,0 +1,53 @@ +import { createSignal } from "solid-js" +import type { WorkspaceDescriptor } from "../../../server/src/api-types" +import { tGlobal } from "../lib/i18n" +import { formatLaunchErrorMessage, isMissingBinaryMessage } from "../lib/launch-errors" + +type LaunchErrorSource = "create" | "workspace" + +export interface LaunchErrorState { + source: LaunchErrorSource + message: string + binaryPath: string + missingBinary: boolean + instanceId?: string +} + +const [launchError, setLaunchError] = createSignal(null) + +// Avoid spamming the user with the same modal on repeated events. +const lastWorkspaceErrorByInstanceId = new Map() + +export function showLaunchError(next: LaunchErrorState) { + setLaunchError(next) +} + +export function clearLaunchError() { + setLaunchError(null) +} + +export function showWorkspaceLaunchError(workspace: WorkspaceDescriptor) { + const instanceId = workspace.id + const rawMessage = workspace.error + const message = formatLaunchErrorMessage(rawMessage, tGlobal("app.launchError.fallbackMessage")) + + const previous = lastWorkspaceErrorByInstanceId.get(instanceId) + if (previous && previous === message) { + return + } + + lastWorkspaceErrorByInstanceId.set(instanceId, message) + + const binaryPath = (workspace.binaryLabel || workspace.binaryId || "opencode").trim() || "opencode" + const missingBinary = isMissingBinaryMessage(message) + + showLaunchError({ + source: "workspace", + instanceId, + message, + binaryPath, + missingBinary, + }) +} + +export { launchError } From 9800afb785f0828b2f14e133bbbf24f111696448 Mon Sep 17 00:00:00 2001 From: "codenomadbot[bot]" <261069733+codenomadbot[bot]@users.noreply.github.com> Date: Thu, 19 Feb 2026 22:08:41 +0000 Subject: [PATCH 24/37] feat(ui): toggle tool call input YAML (#182) * feat(ui): toggle tool call input yaml * ui: rename tool input toggle and add IO headers * ui: add input/output accordions in tool calls * ui: refine tool IO accordion styling * ui: remove extra padding around IO sections * ui: remove semibold from IO headers * feat(ui): add tool input visibility preference * fix(ui): scope tool input toggle to current tool call * ui: left-align tool IO header text * fix(ui): let palette tool input visibility override per-call * ui: default tool input visibility to collapsed * fix(ui): expand read tool calls on error --------- Co-authored-by: Shantur Rathore --- package-lock.json | 3 +- packages/ui/package.json | 3 +- packages/ui/src/App.tsx | 2 + packages/ui/src/components/tool-call.tsx | 183 ++++++++++++++++-- packages/ui/src/lib/hooks/use-commands.ts | 26 ++- .../ui/src/lib/i18n/messages/en/commands.ts | 4 + .../ui/src/lib/i18n/messages/en/toolCall.ts | 8 + .../ui/src/lib/i18n/messages/es/commands.ts | 4 + .../ui/src/lib/i18n/messages/es/toolCall.ts | 8 + .../ui/src/lib/i18n/messages/fr/commands.ts | 4 + .../ui/src/lib/i18n/messages/fr/toolCall.ts | 8 + .../ui/src/lib/i18n/messages/ja/commands.ts | 4 + .../ui/src/lib/i18n/messages/ja/toolCall.ts | 8 + .../ui/src/lib/i18n/messages/ru/commands.ts | 4 + .../ui/src/lib/i18n/messages/ru/toolCall.ts | 8 + .../src/lib/i18n/messages/zh-Hans/commands.ts | 4 + .../src/lib/i18n/messages/zh-Hans/toolCall.ts | 8 + packages/ui/src/stores/preferences.tsx | 14 ++ .../ui/src/styles/messaging/tool-call.css | 77 ++++++++ 19 files changed, 364 insertions(+), 16 deletions(-) diff --git a/package-lock.json b/package-lock.json index 71bff3c9..8f529449 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12092,7 +12092,8 @@ "shiki": "^3.13.0", "solid-js": "^1.8.0", "solid-toast": "^0.5.0", - "tauri-plugin-keepawake-api": "^0.1.0" + "tauri-plugin-keepawake-api": "^0.1.0", + "yaml": "^2.4.2" }, "devDependencies": { "@vite-pwa/assets-generator": "^1.0.2", diff --git a/packages/ui/package.json b/packages/ui/package.json index a6235618..42aae094 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -30,7 +30,8 @@ "shiki": "^3.13.0", "solid-js": "^1.8.0", "solid-toast": "^0.5.0", - "tauri-plugin-keepawake-api": "^0.1.0" + "tauri-plugin-keepawake-api": "^0.1.0", + "yaml": "^2.4.2" }, "devDependencies": { "@vite-pwa/assets-generator": "^1.0.2", diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index b02861c1..5ad213a6 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -72,6 +72,7 @@ const App: Component = () => { setToolOutputExpansion, setDiagnosticsExpansion, setThinkingBlocksExpansion, + setToolInputsVisibility, } = useConfig() const [escapeInDebounce, setEscapeInDebounce] = createSignal(false) interface LaunchErrorState { @@ -402,6 +403,7 @@ const App: Component = () => { setToolOutputExpansion, setDiagnosticsExpansion, setThinkingBlocksExpansion, + setToolInputsVisibility, handleNewInstanceRequest, handleCloseInstance, handleNewSession, diff --git a/packages/ui/src/components/tool-call.tsx b/packages/ui/src/components/tool-call.tsx index 9b9e5c01..97164b1b 100644 --- a/packages/ui/src/components/tool-call.tsx +++ b/packages/ui/src/components/tool-call.tsx @@ -1,5 +1,6 @@ import { createSignal, Show, createEffect, createMemo, onCleanup } from "solid-js" -import { Copy } from "lucide-solid" +import { ArrowRightSquare, Copy } from "lucide-solid" +import { stringify as stringifyYaml } from "yaml" import { messageStoreBus } from "../stores/message-v2/bus" import { useTheme } from "../lib/theme" import { useGlobalCache } from "../lib/hooks/use-global-cache" @@ -27,7 +28,17 @@ import type { ToolRendererContext, ToolScrollHelpers, } from "./tool-call/types" -import { getRelativePath, getToolIcon, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, getDefaultToolAction } from "./tool-call/utils" +import { + ensureMarkdownContent, + getRelativePath, + getToolIcon, + getToolName, + isToolStateCompleted, + isToolStateError, + isToolStateRunning, + getDefaultToolAction, + readToolStatePayload, +} from "./tool-call/utils" import { resolveTitleForTool } from "./tool-call/tool-title" import { getLogger } from "../lib/logger" @@ -155,12 +166,33 @@ export default function ToolCall(props: ToolCallProps) { const prefExpanded = toolOutputDefaultExpanded() const toolName = toolCallMemo()?.tool || "" if (toolName === "read") { + const state = toolState() + if (state?.status === "error") { + return true + } return false } return prefExpanded }) const [userExpanded, setUserExpanded] = createSignal(null) + const toolInputsVisibility = createMemo(() => preferences().toolInputsVisibility || "collapsed") + const [toolInputVisibilityOverride, setToolInputVisibilityOverride] = createSignal<"hidden" | "expanded" | null>(null) + const effectiveToolInputsVisibility = createMemo(() => toolInputVisibilityOverride() ?? toolInputsVisibility()) + const isToolInputVisible = createMemo(() => effectiveToolInputsVisibility() !== "hidden") + const inputDefaultExpanded = createMemo(() => effectiveToolInputsVisibility() === "expanded") + const [inputSectionOverride, setInputSectionOverride] = createSignal(null) + const [outputSectionOverride, setOutputSectionOverride] = createSignal(null) + const inputSectionExpanded = () => { + const override = inputSectionOverride() + if (override !== null) return override + return inputDefaultExpanded() + } + const outputSectionExpanded = () => { + const override = outputSectionOverride() + if (override !== null) return override + return true + } const isPermissionActive = createMemo(() => { const pending = pendingPermission() @@ -183,6 +215,35 @@ export default function ToolCall(props: ToolCallProps) { return defaultExpandedForTool() } + const toolInput = createMemo(() => { + const state = toolState() + return readToolStatePayload(state).input + }) + + const hasToolInput = createMemo(() => { + const input = toolInput() + return input && Object.keys(input).length > 0 + }) + + const toolInputMarkdown = createMemo(() => { + const input = toolInput() + if (!input || Object.keys(input).length === 0) return null + + try { + const yamlText = stringifyYaml(input) + return ensureMarkdownContent(yamlText, "yaml", true) + } catch (error) { + log.error("Failed to convert tool call input to YAML", error) + try { + const jsonText = JSON.stringify(input, null, 2) + return ensureMarkdownContent(jsonText, "json", true) + } catch (nestedError) { + log.error("Failed to stringify tool call input", nestedError) + return null + } + } + }) + const permissionDetails = createMemo(() => pendingPermission()?.permission) const questionDetails = createMemo(() => pendingQuestion()?.request) @@ -548,6 +609,25 @@ export default function ToolCall(props: ToolCallProps) { }) } + createEffect(() => { + // When global preference changes, reset per-tool-call overrides so palette changes apply. + toolInputsVisibility() + setToolInputVisibilityOverride(null) + setInputSectionOverride(null) + setOutputSectionOverride(null) + }) + + const handleToggleInputVisibility = (event: MouseEvent) => { + event.preventDefault() + event.stopPropagation() + if (!expanded()) { + toggle() + } + + const currentlyVisible = isToolInputVisible() + setToolInputVisibilityOverride(currentlyVisible ? "hidden" : "expanded") + } + const renderer = createMemo(() => resolveToolRenderer(toolName())) const { renderAnsiContent } = createAnsiContentRenderer({ @@ -789,6 +869,23 @@ export default function ToolCall(props: ToolCallProps) { + + + + + + +
+ {(() => { + const content = toolInputMarkdown() + if (!content) return null + return renderMarkdownContent({ content, cacheKey: "input" }) + })()} +
+
+
+ +
+ + + +
+ {renderToolBody()} + {renderError()} + + +
+ + {t("toolCall.pending.waitingToRun")} +
+
+
+
+
+ + {renderPermissionBlock()} + {renderQuestionBlock()}
)} diff --git a/packages/ui/src/lib/hooks/use-commands.ts b/packages/ui/src/lib/hooks/use-commands.ts index c89a95d4..4105e274 100644 --- a/packages/ui/src/lib/hooks/use-commands.ts +++ b/packages/ui/src/lib/hooks/use-commands.ts @@ -1,6 +1,6 @@ import { createSignal, onMount } from "solid-js" import type { Accessor } from "solid-js" -import type { Preferences, ExpansionPreference } from "../../stores/preferences" +import type { Preferences, ExpansionPreference, ToolInputsVisibilityPreference } from "../../stores/preferences" import { createCommandRegistry, type Command } from "../commands" import { instances, activeInstanceId, setActiveInstanceId } from "../../stores/instances" import type { ClientPart, MessageInfo } from "../../types/message" @@ -38,6 +38,7 @@ export interface UseCommandsOptions { setToolOutputExpansion: (mode: ExpansionPreference) => void setDiagnosticsExpansion: (mode: ExpansionPreference) => void setThinkingBlocksExpansion: (mode: ExpansionPreference) => void + setToolInputsVisibility: (mode: ToolInputsVisibilityPreference) => void handleNewInstanceRequest: () => void handleCloseInstance: (instanceId: string) => Promise handleNewSession: (instanceId: string) => Promise @@ -551,6 +552,29 @@ export function useCommands(options: UseCommandsOptions) { }, }) + commandRegistry.register({ + id: "tool-inputs-visibility", + label: () => { + const mode = options.preferences().toolInputsVisibility || "hidden" + const state = + mode === "expanded" + ? tGlobal("commands.common.expanded") + : mode === "collapsed" + ? tGlobal("commands.common.collapsed") + : tGlobal("commands.common.hidden") + return tGlobal("commands.toolInputsVisibility.label", { state }) + }, + description: () => tGlobal("commands.toolInputsVisibility.description"), + category: "System", + keywords: () => splitKeywords("commands.toolInputsVisibility.keywords"), + action: () => { + const mode = options.preferences().toolInputsVisibility || "hidden" + const next: ToolInputsVisibilityPreference = + mode === "hidden" ? "collapsed" : mode === "collapsed" ? "expanded" : "hidden" + options.setToolInputsVisibility(next) + }, + }) + commandRegistry.register({ id: "token-usage-visibility", label: () => { diff --git a/packages/ui/src/lib/i18n/messages/en/commands.ts b/packages/ui/src/lib/i18n/messages/en/commands.ts index dd0d12f7..b396200c 100644 --- a/packages/ui/src/lib/i18n/messages/en/commands.ts +++ b/packages/ui/src/lib/i18n/messages/en/commands.ts @@ -130,6 +130,10 @@ export const commandMessages = { "commands.diagnosticsDefault.description": "Toggle default expansion for diagnostics output", "commands.diagnosticsDefault.keywords": "diagnostics, expand, collapse", + "commands.toolInputsVisibility.label": "Tool Inputs Visibility · {state}", + "commands.toolInputsVisibility.description": "Set default visibility for tool call input arguments", + "commands.toolInputsVisibility.keywords": "tool, inputs, arguments, visibility, hide, show, expand, collapse", + "commands.tokenUsageDisplay.label": "Token Usage Display · {state}", "commands.tokenUsageDisplay.description": "Show or hide token and cost stats for assistant messages", "commands.tokenUsageDisplay.keywords": "token, usage, cost, stats", diff --git a/packages/ui/src/lib/i18n/messages/en/toolCall.ts b/packages/ui/src/lib/i18n/messages/en/toolCall.ts index 400899fe..7b380522 100644 --- a/packages/ui/src/lib/i18n/messages/en/toolCall.ts +++ b/packages/ui/src/lib/i18n/messages/en/toolCall.ts @@ -5,6 +5,14 @@ export const toolCallMessages = { "toolCall.header.copyTitle": "Copy tool call title", "toolCall.header.copyAriaLabel": "Copy tool call title", + "toolCall.header.showInputTitle": "Show Tool Arguments", + "toolCall.header.showInputAriaLabel": "Show Tool Arguments", + "toolCall.header.hideInputTitle": "Hide Tool Arguments", + "toolCall.header.hideInputAriaLabel": "Hide Tool Arguments", + + "toolCall.io.input": "Tool Input", + "toolCall.io.output": "Tool Output", + "toolCall.diff.label": "Diff", "toolCall.diff.label.withPath": "Diff · {path}", "toolCall.diff.viewMode.ariaLabel": "Diff view mode", diff --git a/packages/ui/src/lib/i18n/messages/es/commands.ts b/packages/ui/src/lib/i18n/messages/es/commands.ts index c6a75e7e..43e1fbb1 100644 --- a/packages/ui/src/lib/i18n/messages/es/commands.ts +++ b/packages/ui/src/lib/i18n/messages/es/commands.ts @@ -130,6 +130,10 @@ export const commandMessages = { "commands.diagnosticsDefault.description": "Alternar la expansión por defecto de la salida de diagnósticos", "commands.diagnosticsDefault.keywords": "diagnósticos, expandir, colapsar", + "commands.toolInputsVisibility.label": "Visibilidad de entradas de herramientas · {state}", + "commands.toolInputsVisibility.description": "Configurar la visibilidad por defecto de los argumentos de entrada de llamadas de herramienta", + "commands.toolInputsVisibility.keywords": "herramienta, entradas, argumentos, visibilidad, ocultar, mostrar, expandir, colapsar", + "commands.tokenUsageDisplay.label": "Mostrar uso de tokens · {state}", "commands.tokenUsageDisplay.description": "Mostrar u ocultar estadísticas de tokens y costo en los mensajes del asistente", "commands.tokenUsageDisplay.keywords": "token, uso, costo, estadísticas", diff --git a/packages/ui/src/lib/i18n/messages/es/toolCall.ts b/packages/ui/src/lib/i18n/messages/es/toolCall.ts index c5a7c177..f0453187 100644 --- a/packages/ui/src/lib/i18n/messages/es/toolCall.ts +++ b/packages/ui/src/lib/i18n/messages/es/toolCall.ts @@ -5,6 +5,14 @@ export const toolCallMessages = { "toolCall.header.copyTitle": "Copy tool call title", "toolCall.header.copyAriaLabel": "Copy tool call title", + "toolCall.header.showInputTitle": "Show Tool Arguments", + "toolCall.header.showInputAriaLabel": "Show Tool Arguments", + "toolCall.header.hideInputTitle": "Hide Tool Arguments", + "toolCall.header.hideInputAriaLabel": "Hide Tool Arguments", + + "toolCall.io.input": "Tool Input", + "toolCall.io.output": "Tool Output", + "toolCall.diff.label": "Diff", "toolCall.diff.label.withPath": "Diff · {path}", "toolCall.diff.viewMode.ariaLabel": "Modo de vista de diff", diff --git a/packages/ui/src/lib/i18n/messages/fr/commands.ts b/packages/ui/src/lib/i18n/messages/fr/commands.ts index 63e7c666..505baa19 100644 --- a/packages/ui/src/lib/i18n/messages/fr/commands.ts +++ b/packages/ui/src/lib/i18n/messages/fr/commands.ts @@ -130,6 +130,10 @@ export const commandMessages = { "commands.diagnosticsDefault.description": "Choisir l'ouverture par défaut de la sortie des diagnostics", "commands.diagnosticsDefault.keywords": "diagnostics, développer, réduire", + "commands.toolInputsVisibility.label": "Visibilité des entrées d'outil · {state}", + "commands.toolInputsVisibility.description": "Définir la visibilité par défaut des arguments d'entrée des appels d'outil", + "commands.toolInputsVisibility.keywords": "outil, entrées, arguments, visibilité, masquer, afficher, développer, réduire", + "commands.tokenUsageDisplay.label": "Affichage de l'usage des tokens · {state}", "commands.tokenUsageDisplay.description": "Afficher ou masquer les stats de tokens et de coût pour les messages de l'assistant", "commands.tokenUsageDisplay.keywords": "token, usage, coût, stats", diff --git a/packages/ui/src/lib/i18n/messages/fr/toolCall.ts b/packages/ui/src/lib/i18n/messages/fr/toolCall.ts index 1af99486..75685849 100644 --- a/packages/ui/src/lib/i18n/messages/fr/toolCall.ts +++ b/packages/ui/src/lib/i18n/messages/fr/toolCall.ts @@ -5,6 +5,14 @@ export const toolCallMessages = { "toolCall.header.copyTitle": "Copy tool call title", "toolCall.header.copyAriaLabel": "Copy tool call title", + "toolCall.header.showInputTitle": "Show Tool Arguments", + "toolCall.header.showInputAriaLabel": "Show Tool Arguments", + "toolCall.header.hideInputTitle": "Hide Tool Arguments", + "toolCall.header.hideInputAriaLabel": "Hide Tool Arguments", + + "toolCall.io.input": "Tool Input", + "toolCall.io.output": "Tool Output", + "toolCall.diff.label": "Diff", "toolCall.diff.label.withPath": "Diff · {path}", "toolCall.diff.viewMode.ariaLabel": "Mode d'affichage du diff", diff --git a/packages/ui/src/lib/i18n/messages/ja/commands.ts b/packages/ui/src/lib/i18n/messages/ja/commands.ts index 75c1c5f3..de21f94c 100644 --- a/packages/ui/src/lib/i18n/messages/ja/commands.ts +++ b/packages/ui/src/lib/i18n/messages/ja/commands.ts @@ -130,6 +130,10 @@ export const commandMessages = { "commands.diagnosticsDefault.description": "診断出力を既定で展開するか切り替え", "commands.diagnosticsDefault.keywords": "診断, 展開, 折りたたみ, diagnostics, expand, collapse", + "commands.toolInputsVisibility.label": "ツール入力の表示 · {state}", + "commands.toolInputsVisibility.description": "ツール呼び出しの入力引数の既定の表示状態を設定します", + "commands.toolInputsVisibility.keywords": "ツール, 入力, 引数, 表示, 非表示, 展開, 折りたたみ, tool, inputs, arguments, visibility, hide, show, expand, collapse", + "commands.tokenUsageDisplay.label": "トークン使用量表示 · {state}", "commands.tokenUsageDisplay.description": "アシスタントメッセージのトークン/コスト統計を表示/非表示", "commands.tokenUsageDisplay.keywords": "トークン, 使用量, コスト, 統計, token, usage, cost, stats", diff --git a/packages/ui/src/lib/i18n/messages/ja/toolCall.ts b/packages/ui/src/lib/i18n/messages/ja/toolCall.ts index 2e5d036f..9251c719 100644 --- a/packages/ui/src/lib/i18n/messages/ja/toolCall.ts +++ b/packages/ui/src/lib/i18n/messages/ja/toolCall.ts @@ -5,6 +5,14 @@ export const toolCallMessages = { "toolCall.header.copyTitle": "Copy tool call title", "toolCall.header.copyAriaLabel": "Copy tool call title", + "toolCall.header.showInputTitle": "Show Tool Arguments", + "toolCall.header.showInputAriaLabel": "Show Tool Arguments", + "toolCall.header.hideInputTitle": "Hide Tool Arguments", + "toolCall.header.hideInputAriaLabel": "Hide Tool Arguments", + + "toolCall.io.input": "Tool Input", + "toolCall.io.output": "Tool Output", + "toolCall.diff.label": "Diff", "toolCall.diff.label.withPath": "Diff · {path}", "toolCall.diff.viewMode.ariaLabel": "diff 表示モード", diff --git a/packages/ui/src/lib/i18n/messages/ru/commands.ts b/packages/ui/src/lib/i18n/messages/ru/commands.ts index 068f020d..55d2a791 100644 --- a/packages/ui/src/lib/i18n/messages/ru/commands.ts +++ b/packages/ui/src/lib/i18n/messages/ru/commands.ts @@ -130,6 +130,10 @@ export const commandMessages = { "commands.diagnosticsDefault.description": "Переключить, разворачивать ли вывод диагностики по умолчанию", "commands.diagnosticsDefault.keywords": "diagnostics, развернуть, свернуть", + "commands.toolInputsVisibility.label": "Видимость входных данных инструмента · {state}", + "commands.toolInputsVisibility.description": "Установить видимость аргументов входа вызовов инструментов по умолчанию", + "commands.toolInputsVisibility.keywords": "инструмент, вход, аргументы, видимость, скрыть, показать, раскрыть, свернуть, tool, inputs, arguments, visibility, hide, show, expand, collapse", + "commands.tokenUsageDisplay.label": "Отображение token-статистики · {state}", "commands.tokenUsageDisplay.description": "Показать или скрыть статистику token и стоимости для сообщений ассистента", "commands.tokenUsageDisplay.keywords": "token, usage, cost, статистика", diff --git a/packages/ui/src/lib/i18n/messages/ru/toolCall.ts b/packages/ui/src/lib/i18n/messages/ru/toolCall.ts index 8ccc6565..6ca953df 100644 --- a/packages/ui/src/lib/i18n/messages/ru/toolCall.ts +++ b/packages/ui/src/lib/i18n/messages/ru/toolCall.ts @@ -5,6 +5,14 @@ export const toolCallMessages = { "toolCall.header.copyTitle": "Copy tool call title", "toolCall.header.copyAriaLabel": "Copy tool call title", + "toolCall.header.showInputTitle": "Show Tool Arguments", + "toolCall.header.showInputAriaLabel": "Show Tool Arguments", + "toolCall.header.hideInputTitle": "Hide Tool Arguments", + "toolCall.header.hideInputAriaLabel": "Hide Tool Arguments", + + "toolCall.io.input": "Tool Input", + "toolCall.io.output": "Tool Output", + "toolCall.diff.label": "Diff", "toolCall.diff.label.withPath": "Diff · {path}", "toolCall.diff.viewMode.ariaLabel": "Режим просмотра diff", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/commands.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/commands.ts index 85997488..69eba72f 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/commands.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/commands.ts @@ -130,6 +130,10 @@ export const commandMessages = { "commands.diagnosticsDefault.description": "切换诊断输出是否默认展开", "commands.diagnosticsDefault.keywords": "diagnostics, expand, collapse, 诊断, 展开, 折叠", + "commands.toolInputsVisibility.label": "工具输入可见性 · {state}", + "commands.toolInputsVisibility.description": "设置工具调用输入参数的默认可见性", + "commands.toolInputsVisibility.keywords": "工具, 输入, 参数, 可见性, 隐藏, 显示, 展开, 折叠, tool, inputs, arguments, visibility, hide, show, expand, collapse", + "commands.tokenUsageDisplay.label": "Token 使用显示 · {state}", "commands.tokenUsageDisplay.description": "显示或隐藏助手消息的 token 和费用统计", "commands.tokenUsageDisplay.keywords": "token, usage, cost, stats, 令牌, 用量, 费用, 统计", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/toolCall.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/toolCall.ts index 49a848c9..a0f5d30c 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/toolCall.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/toolCall.ts @@ -5,6 +5,14 @@ export const toolCallMessages = { "toolCall.header.copyTitle": "Copy tool call title", "toolCall.header.copyAriaLabel": "Copy tool call title", + "toolCall.header.showInputTitle": "Show Tool Arguments", + "toolCall.header.showInputAriaLabel": "Show Tool Arguments", + "toolCall.header.hideInputTitle": "Hide Tool Arguments", + "toolCall.header.hideInputAriaLabel": "Hide Tool Arguments", + + "toolCall.io.input": "Tool Input", + "toolCall.io.output": "Tool Output", + "toolCall.diff.label": "Diff", "toolCall.diff.label.withPath": "Diff · {path}", "toolCall.diff.viewMode.ariaLabel": "Diff 视图模式", diff --git a/packages/ui/src/stores/preferences.tsx b/packages/ui/src/stores/preferences.tsx index 00b0a04e..8ac2ead0 100644 --- a/packages/ui/src/stores/preferences.tsx +++ b/packages/ui/src/stores/preferences.tsx @@ -25,6 +25,7 @@ export interface ModelPreference { export type DiffViewMode = "split" | "unified" export type ExpansionPreference = "expanded" | "collapsed" +export type ToolInputsVisibilityPreference = "hidden" | "collapsed" | "expanded" export type ListeningMode = "local" | "all" export interface UiSettings { @@ -37,6 +38,7 @@ export interface UiSettings { diffViewMode: DiffViewMode toolOutputExpansion: ExpansionPreference diagnosticsExpansion: ExpansionPreference + toolInputsVisibility: ToolInputsVisibilityPreference showUsageMetrics: boolean autoCleanupBlankSessions: boolean @@ -108,6 +110,7 @@ const defaultUiSettings: UiSettings = { diffViewMode: "split", toolOutputExpansion: "expanded", diagnosticsExpansion: "expanded", + toolInputsVisibility: "collapsed", showUsageMetrics: true, autoCleanupBlankSessions: true, @@ -130,6 +133,10 @@ function normalizeUiSettings(input?: Partial | null): UiSettings { diffViewMode: sanitized.diffViewMode ?? defaultUiSettings.diffViewMode, toolOutputExpansion: sanitized.toolOutputExpansion ?? defaultUiSettings.toolOutputExpansion, diagnosticsExpansion: sanitized.diagnosticsExpansion ?? defaultUiSettings.diagnosticsExpansion, + toolInputsVisibility: + sanitized.toolInputsVisibility === "hidden" || sanitized.toolInputsVisibility === "collapsed" || sanitized.toolInputsVisibility === "expanded" + ? sanitized.toolInputsVisibility + : defaultUiSettings.toolInputsVisibility, showUsageMetrics: sanitized.showUsageMetrics ?? defaultUiSettings.showUsageMetrics, autoCleanupBlankSessions: sanitized.autoCleanupBlankSessions ?? defaultUiSettings.autoCleanupBlankSessions, osNotificationsEnabled: sanitized.osNotificationsEnabled ?? defaultUiSettings.osNotificationsEnabled, @@ -439,6 +446,11 @@ function setDiagnosticsExpansion(mode: ExpansionPreference): void { updateUiSettings({ diagnosticsExpansion: mode }) } +function setToolInputsVisibility(mode: ToolInputsVisibilityPreference): void { + if (preferences().toolInputsVisibility === mode) return + updateUiSettings({ toolInputsVisibility: mode }) +} + function setThinkingBlocksExpansion(mode: ExpansionPreference): void { if (preferences().thinkingBlocksExpansion === mode) return updateUiSettings({ thinkingBlocksExpansion: mode }) @@ -536,6 +548,7 @@ interface ConfigContextValue { setToolOutputExpansion: typeof setToolOutputExpansion setDiagnosticsExpansion: typeof setDiagnosticsExpansion setThinkingBlocksExpansion: typeof setThinkingBlocksExpansion + setToolInputsVisibility: typeof setToolInputsVisibility // instance scoped setAgentModelPreference: typeof setAgentModelPreference @@ -579,6 +592,7 @@ const configContextValue: ConfigContextValue = { setToolOutputExpansion, setDiagnosticsExpansion, setThinkingBlocksExpansion, + setToolInputsVisibility, setAgentModelPreference, getAgentModelPreference, } diff --git a/packages/ui/src/styles/messaging/tool-call.css b/packages/ui/src/styles/messaging/tool-call.css index 47b732fb..8997f1e8 100644 --- a/packages/ui/src/styles/messaging/tool-call.css +++ b/packages/ui/src/styles/messaging/tool-call.css @@ -87,6 +87,7 @@ @apply flex items-stretch w-full; background-color: transparent; color: var(--text-primary); + border-bottom: 1px solid var(--tool-call-border-color); } .tool-call-header:hover { @@ -127,11 +128,30 @@ cursor: pointer; } +.tool-call-header-input { + @apply inline-flex items-center justify-center; + background-color: transparent; + border: none; + color: var(--text-secondary); + padding: 0 0.5rem; + border-radius: 0; + cursor: pointer; +} + .tool-call-header-copy:hover { background-color: transparent; color: var(--text-primary); } +.tool-call-header-input:hover { + background-color: transparent; + color: var(--text-primary); +} + +.tool-call-header-input[aria-pressed="true"] { + color: var(--text-primary); +} + .tool-call-header-status { @apply inline-flex items-center justify-center; font-size: 0.95rem; @@ -213,6 +233,63 @@ font-size: var(--font-size-xs); } + +.tool-call-io-sections { + display: flex; + flex-direction: column; + gap: var(--space-xs); + padding: 0; +} + +.tool-call-io-section { + border: 1px solid var(--tool-call-border-color); + overflow: hidden; + background-color: transparent; + border-radius: 0; +} + +.tool-call-io-toggle { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 0.75rem; + padding: 0.5rem; + background-color: var(--surface-secondary); + border: none; + border-bottom: 1px solid var(--tool-call-border-color); + width: 100%; + text-align: left; + font-size: 0.875rem; + font-weight: normal; + color: var(--text-primary); + cursor: pointer; +} + +.tool-call-io-toggle::before { + content: "▶"; + font-size: 11px; + margin-right: 0.35rem; + color: var(--text-secondary); +} + +.tool-call-io-toggle[aria-expanded="true"]::before { + content: "▼"; +} + +.tool-call-io-title { + font-weight: inherit; + color: inherit; +} + +.tool-call-io-body { + background-color: var(--surface-code); +} + +/* IO sections provide the outer frame; avoid double borders on markdown frames. */ +.tool-call-io-body .tool-call-markdown { + border: none; +} + .tool-call-markdown { background-color: var(--surface-code); /* Keep a visible frame around the scroll viewport (not the content). */ From b5790998b7f66e2afbe643137570ff41d883e39c Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Thu, 19 Feb 2026 23:51:25 +0000 Subject: [PATCH 25/37] ui: use emoji status icons for tool calls --- packages/ui/src/components/tool-call.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/components/tool-call.tsx b/packages/ui/src/components/tool-call.tsx index 9b9e5c01..1f71a986 100644 --- a/packages/ui/src/components/tool-call.tsx +++ b/packages/ui/src/components/tool-call.tsx @@ -515,13 +515,13 @@ export default function ToolCall(props: ToolCallProps) { const status = toolState()?.status || "" switch (status) { case "pending": - return "⏸" - case "running": return "⏳" + case "running": + return "🔄" case "completed": - return "✓" + return "✅" case "error": - return "✗" + return "⚠️" default: return "" } From b162764ccb98b0493ea13b5c6c1b4ed8301effa1 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Fri, 20 Feb 2026 00:08:07 +0000 Subject: [PATCH 26/37] ui: use lucide status icons for tool calls --- packages/ui/src/components/tool-call.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/components/tool-call.tsx b/packages/ui/src/components/tool-call.tsx index 94560fc6..d3937ffb 100644 --- a/packages/ui/src/components/tool-call.tsx +++ b/packages/ui/src/components/tool-call.tsx @@ -1,5 +1,5 @@ import { createSignal, Show, createEffect, createMemo, onCleanup } from "solid-js" -import { ArrowRightSquare, Copy } from "lucide-solid" +import { ArrowRightSquare, CheckCircle2, Copy, Hourglass, Loader2, XCircle } from "lucide-solid" import { stringify as stringifyYaml } from "yaml" import { messageStoreBus } from "../stores/message-v2/bus" import { useTheme } from "../lib/theme" @@ -576,13 +576,13 @@ export default function ToolCall(props: ToolCallProps) { const status = toolState()?.status || "" switch (status) { case "pending": - return "⏳" + return case "running": - return "🔄" + return case "completed": - return "✅" + return case "error": - return "⚠️" + return default: return "" } From 1ccd14eae811e831d338be24051689cdbc67ff39 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Fri, 20 Feb 2026 00:14:02 +0000 Subject: [PATCH 27/37] ui: use Check icon for completed status --- packages/ui/src/components/tool-call.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/tool-call.tsx b/packages/ui/src/components/tool-call.tsx index d3937ffb..5d3af5f5 100644 --- a/packages/ui/src/components/tool-call.tsx +++ b/packages/ui/src/components/tool-call.tsx @@ -1,5 +1,5 @@ import { createSignal, Show, createEffect, createMemo, onCleanup } from "solid-js" -import { ArrowRightSquare, CheckCircle2, Copy, Hourglass, Loader2, XCircle } from "lucide-solid" +import { ArrowRightSquare, Check, Copy, Hourglass, Loader2, XCircle } from "lucide-solid" import { stringify as stringifyYaml } from "yaml" import { messageStoreBus } from "../stores/message-v2/bus" import { useTheme } from "../lib/theme" @@ -580,7 +580,7 @@ export default function ToolCall(props: ToolCallProps) { case "running": return case "completed": - return + return case "error": return default: From e8947d61b1fb9e864467bc4c8e3fe97a0efd8efb Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Fri, 20 Feb 2026 00:24:24 +0000 Subject: [PATCH 28/37] ui: emphasize command palette button --- .../ui/src/components/instance/instance-shell2.tsx | 4 ++-- packages/ui/src/components/message-list-header.tsx | 2 +- packages/ui/src/styles/messaging/message-section.css | 11 +++++++++++ 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index 70f71d75..d3c58ed8 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -625,7 +625,7 @@ const InstanceShell2: Component = (props) => {
From 64795617797a16abff2565079c4cdf320cba4172 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Sun, 22 Feb 2026 16:47:04 +0000 Subject: [PATCH 35/37] fix(ui): auto-expand session thread when child starts working --- packages/ui/src/stores/session-events.ts | 23 ++++++++++++++++++++++- packages/ui/src/stores/session-state.ts | 13 +++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/stores/session-events.ts b/packages/ui/src/stores/session-events.ts index 58d2a64a..0d6ed470 100644 --- a/packages/ui/src/stores/session-events.ts +++ b/packages/ui/src/stores/session-events.ts @@ -40,7 +40,7 @@ import { } from "./instances" import { showAlertDialog } from "./alerts" import { createClientSession, mapSdkSessionStatus, type Session, type SessionStatus } from "../types/session" -import { sessions, setSessions, syncInstanceSessionIndicator, withSession } from "./session-state" +import { ensureSessionParentExpanded, sessions, setSessions, syncInstanceSessionIndicator, withSession } from "./session-state" import { normalizeMessagePart } from "./message-v2/normalizers" import { updateSessionInfo } from "./message-v2/session-info" import { tGlobal } from "../lib/i18n" @@ -108,6 +108,8 @@ interface TuiToastEvent { const ALLOWED_TOAST_VARIANTS = new Set(["info", "success", "warning", "error"]) function applySessionStatus(instanceId: string, sessionId: string, status: SessionStatus) { + let parentToExpand: string | null = null + withSession(instanceId, sessionId, (session) => { const current = session.status ?? "idle" if (current === status) return false @@ -117,7 +119,17 @@ function applySessionStatus(instanceId: string, sessionId: string, status: Sessi } session.status = status + + // Auto-expand the parent thread when a child session starts working. + // Users can still collapse it; we only expand on the transition. + if (session.parentId && status === "working" && current !== "working") { + parentToExpand = session.parentId + } }) + + if (parentToExpand) { + ensureSessionParentExpanded(instanceId, parentToExpand) + } } async function fetchSessionInfo(instanceId: string, sessionId: string, directory?: string): Promise { @@ -158,6 +170,7 @@ async function fetchSessionInfo(instanceId: string, sessionId: string, directory const fetched = createClientSession(info, instanceId, "", { providerId: "", modelId: "" }, fetchedStatus) let updatedInstanceSessions: Map | undefined + let shouldExpandParent: string | null = null setSessions((prev) => { const next = new Map(prev) @@ -174,11 +187,19 @@ async function fetchSessionInfo(instanceId: string, sessionId: string, directory instanceSessions.set(sessionId, merged) next.set(instanceId, instanceSessions) updatedInstanceSessions = instanceSessions + + if (merged.parentId && merged.status === "working" && (existing?.status ?? "idle") !== "working") { + shouldExpandParent = merged.parentId + } return next }) syncInstanceSessionIndicator(instanceId, updatedInstanceSessions) + if (shouldExpandParent) { + ensureSessionParentExpanded(instanceId, shouldExpandParent) + } + return fetched } catch (error) { log.error("Failed to fetch session info", error) diff --git a/packages/ui/src/stores/session-state.ts b/packages/ui/src/stores/session-state.ts index 89019ce5..d0e57c37 100644 --- a/packages/ui/src/stores/session-state.ts +++ b/packages/ui/src/stores/session-state.ts @@ -347,10 +347,23 @@ function clearActiveParentSession(instanceId: string): void { } function setSessionStatus(instanceId: string, sessionId: string, status: SessionStatus): void { + let parentToExpand: string | null = null + withSession(instanceId, sessionId, (session) => { if (session.status === status) return false + const previous = session.status session.status = status + + // If a child session starts working, auto-expand its parent thread once. + // Users can still collapse it afterwards; we only expand on the transition. + if (session.parentId && status === "working" && previous !== "working") { + parentToExpand = session.parentId + } }) + + if (parentToExpand) { + ensureSessionParentExpanded(instanceId, parentToExpand) + } } function getActiveParentSession(instanceId: string): Session | null { From 62bd88f6a43e4cbb579bcf0576f2098eeb712984 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Sun, 22 Feb 2026 16:48:49 +0000 Subject: [PATCH 36/37] chore(plugin): Upgrade dependency version --- packages/opencode-config/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode-config/package.json b/packages/opencode-config/package.json index 2f52967b..4c3ce075 100644 --- a/packages/opencode-config/package.json +++ b/packages/opencode-config/package.json @@ -4,6 +4,6 @@ "private": true, "license": "MIT", "dependencies": { - "@opencode-ai/plugin": "1.2.6" + "@opencode-ai/plugin": "1.2.10" } } \ No newline at end of file From a06884ebce8be5b7b4e9c1c9931f2a8b10df17a6 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Sun, 22 Feb 2026 16:53:51 +0000 Subject: [PATCH 37/37] Bump to v0.11.4 --- package-lock.json | 12 ++++++------ package.json | 2 +- packages/electron-app/package.json | 2 +- packages/server/package-lock.json | 4 ++-- packages/server/package.json | 2 +- packages/tauri-app/package.json | 2 +- packages/ui/package.json | 2 +- 7 files changed, 13 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8f529449..99303498 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codenomad-workspace", - "version": "0.11.3", + "version": "0.11.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codenomad-workspace", - "version": "0.11.3", + "version": "0.11.4", "license": "MIT", "dependencies": { "7zip-bin": "^5.2.0", @@ -11985,7 +11985,7 @@ }, "packages/electron-app": { "name": "@neuralnomads/codenomad-electron-app", - "version": "0.11.3", + "version": "0.11.4", "license": "MIT", "dependencies": { "@codenomad/ui": "file:../ui", @@ -12021,7 +12021,7 @@ }, "packages/server": { "name": "@neuralnomads/codenomad", - "version": "0.11.3", + "version": "0.11.4", "license": "MIT", "dependencies": { "@fastify/cors": "^8.5.0", @@ -12062,7 +12062,7 @@ }, "packages/tauri-app": { "name": "@codenomad/tauri-app", - "version": "0.11.3", + "version": "0.11.4", "license": "MIT", "devDependencies": { "@tauri-apps/cli": "^2.9.4" @@ -12070,7 +12070,7 @@ }, "packages/ui": { "name": "@codenomad/ui", - "version": "0.11.3", + "version": "0.11.4", "license": "MIT", "dependencies": { "@git-diff-view/solid": "^0.0.8", diff --git a/package.json b/package.json index 566968db..e2381e85 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codenomad-workspace", - "version": "0.11.3", + "version": "0.11.4", "private": true, "description": "CodeNomad monorepo workspace", "license": "MIT", diff --git a/packages/electron-app/package.json b/packages/electron-app/package.json index 740a48cd..b1918510 100644 --- a/packages/electron-app/package.json +++ b/packages/electron-app/package.json @@ -1,6 +1,6 @@ { "name": "@neuralnomads/codenomad-electron-app", - "version": "0.11.3", + "version": "0.11.4", "description": "CodeNomad - AI coding assistant", "license": "MIT", "author": { diff --git a/packages/server/package-lock.json b/packages/server/package-lock.json index b3632da1..1755c004 100644 --- a/packages/server/package-lock.json +++ b/packages/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "@neuralnomads/codenomad", - "version": "0.11.3", + "version": "0.11.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@neuralnomads/codenomad", - "version": "0.11.3", + "version": "0.11.4", "dependencies": { "@fastify/cors": "^8.5.0", "@fastify/reply-from": "^9.8.0", diff --git a/packages/server/package.json b/packages/server/package.json index 46216b6f..02082c24 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@neuralnomads/codenomad", - "version": "0.11.3", + "version": "0.11.4", "description": "CodeNomad Server", "license": "MIT", "author": { diff --git a/packages/tauri-app/package.json b/packages/tauri-app/package.json index 0b822254..b86075f5 100644 --- a/packages/tauri-app/package.json +++ b/packages/tauri-app/package.json @@ -1,6 +1,6 @@ { "name": "@codenomad/tauri-app", - "version": "0.11.3", + "version": "0.11.4", "private": true, "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index 42aae094..50ab2cdd 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@codenomad/ui", - "version": "0.11.3", + "version": "0.11.4", "private": true, "license": "MIT", "type": "module",