From 56f31d91b6407254d68e4e895454b4f92ae984a4 Mon Sep 17 00:00:00 2001 From: pascalandr Date: Sat, 18 Apr 2026 13:59:08 +0200 Subject: [PATCH 01/32] feat(ui): show the active session title in the header Render the current session title in the instance header for both compact and regular layouts, while keeping the existing status indicators and command controls in place. --- .../components/instance/instance-shell2.tsx | 28 +++++++++++++++++-- .../ui/src/styles/panels/session-layout.css | 24 ++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index 0ead3c1aa..eda54c869 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -364,6 +364,14 @@ const InstanceShell2: Component = (props) => { ) } + const activeSessionTitle = createMemo(() => { + const activeSessionId = activeSessionIdForInstance() + if (!activeSessionId || activeSessionId === "info") return null + + const title = activeSessionForInstance()?.title?.trim() + return title || t("sessionList.session.untitled") + }) + const renderYoloModePill = () => { if (!yoloModeEnabled()) return null return ( @@ -390,6 +398,20 @@ const InstanceShell2: Component = (props) => { ) + const renderSessionHeaderMeta = (compact = false) => { + const title = activeSessionTitle() + if (!title) return renderSessionHeaderIndicators() + + return ( +
+
+ {title} +
+ {renderSessionHeaderIndicators()} +
+ ) + } + const handleCommandPaletteClick = () => { showCommandPalette(props.instance.id) } @@ -717,7 +739,7 @@ const InstanceShell2: Component = (props) => {
- {renderSessionHeaderIndicators()} + {renderSessionHeaderMeta(true)}
@@ -808,8 +830,8 @@ const InstanceShell2: Component = (props) => { /> -
- {renderSessionHeaderIndicators()} +
+ {renderSessionHeaderMeta()}
diff --git a/packages/ui/src/styles/panels/session-layout.css b/packages/ui/src/styles/panels/session-layout.css index 1ff0dc3f6..2a19f802a 100644 --- a/packages/ui/src/styles/panels/session-layout.css +++ b/packages/ui/src/styles/panels/session-layout.css @@ -144,6 +144,30 @@ session-sidebar-controls .selector-trigger-primary { @apply flex-shrink-0; } +.session-header-meta { + @apply flex items-center gap-2 min-w-0; +} + +.session-header-meta--compact { + @apply flex-col gap-1.5 w-full; +} + +.session-header-title { + color: var(--text-primary); + font-size: 0.875rem; + font-weight: 600; + line-height: 1.2; + max-width: min(32rem, 40vw); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.session-header-meta--compact .session-header-title { + max-width: 100%; + text-align: center; +} + .session-sidebar-separator { background-color: var(--border-base); height: 1px; From 2a6242106420248bd635c87b8ea22116e8cc1e02 Mon Sep 17 00:00:00 2001 From: pascalandr Date: Sat, 18 Apr 2026 15:43:10 +0200 Subject: [PATCH 02/32] feat(ui): show the session title in the tab bar Display the active session title next to the new-tab control, and only when the left session drawer is closed so the title adds context without competing with the sidebar. --- packages/ui/src/App.tsx | 21 ++++++++++++ packages/ui/src/components/instance-tabs.tsx | 7 ++++ .../components/instance/instance-shell2.tsx | 33 +++++-------------- .../ui/src/styles/panels/session-layout.css | 24 -------------- packages/ui/src/styles/panels/tabs.css | 12 +++++++ 5 files changed, 48 insertions(+), 49 deletions(-) diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 462c0c168..eb8652b23 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -93,6 +93,7 @@ const App: Component = () => { const [escapeInDebounce, setEscapeInDebounce] = createSignal(false) const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0) const [sidecarPickerOpen, setSidecarPickerOpen] = createSignal(false) + const [leftDrawerTitleVisibility, setLeftDrawerTitleVisibility] = createSignal>({}) const phoneQuery = useMediaQuery("(max-width: 767px)") const isPhoneLayout = createMemo(() => phoneQuery()) @@ -249,6 +250,22 @@ const App: Component = () => { return activeSessionId().get(instance.id) || null }) + const activeSessionTitleForInstance = createMemo(() => { + const instance = activeInstance() + const sessionId = activeSessionIdForInstance() + if (!instance || !sessionId || sessionId === "info") return null + + const session = getSessions(instance.id).find((entry) => entry.id === sessionId) + const title = session?.title?.trim() + return title || t("sessionList.session.untitled") + }) + + const showActiveSessionTitleInTabs = createMemo(() => { + const instance = activeInstance() + if (!instance) return false + return leftDrawerTitleVisibility()[instance.id] ?? false + }) + const launchErrorPath = () => { const value = launchError()?.binaryPath if (!value) return "opencode" @@ -539,6 +556,8 @@ const App: Component = () => { void handleCloseAppTab(tabId)} onNew={handleNewInstanceRequest} @@ -568,6 +587,8 @@ const App: Component = () => { handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(tab.instance.id, sessionId, agent)} handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(tab.instance.id, sessionId, model)} onExecuteCommand={executeCommand} + onLeftDrawerTitleVisibilityChange={(instanceId, visible) => + setLeftDrawerTitleVisibility((current) => ({ ...current, [instanceId]: visible }))} tabBarOffset={isPhoneLayout() && mobileFullscreenMode() ? 0 : instanceTabBarHeight()} mobileFullscreenMode={isPhoneLayout() && mobileFullscreenMode()} onEnterMobileFullscreen={() => void enterMobileFullscreen()} diff --git a/packages/ui/src/components/instance-tabs.tsx b/packages/ui/src/components/instance-tabs.tsx index 86e2c4986..7df5b7664 100644 --- a/packages/ui/src/components/instance-tabs.tsx +++ b/packages/ui/src/components/instance-tabs.tsx @@ -13,6 +13,8 @@ import type { AppTabRecord } from "../stores/app-tabs" interface InstanceTabsProps { tabs: AppTabRecord[] activeTabId: string | null + activeSessionTitle?: string | null + showActiveSessionTitle?: boolean onSelect: (tabId: string) => void onClose: (tabId: string) => void onNew: () => void @@ -70,6 +72,11 @@ const InstanceTabs: Component = (props) => { > + +
+ {props.activeSessionTitle} +
+
1}> diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index eda54c869..597ced329 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -86,6 +86,7 @@ interface InstanceShellProps { mobileFullscreenMode: boolean onEnterMobileFullscreen: () => void onExitMobileFullscreen: () => void + onLeftDrawerTitleVisibilityChange?: (instanceId: string, visible: boolean) => void } const InstanceShell2: Component = (props) => { @@ -183,6 +184,10 @@ const InstanceShell2: Component = (props) => { handleRightAppBarButtonClick, } = drawerChrome + createEffect(() => { + props.onLeftDrawerTitleVisibilityChange?.(props.instance.id, leftDrawerState() === "floating-closed") + }) + createEffect(() => { const instanceId = props.instance.id loadBackgroundProcesses(instanceId).catch((error) => { @@ -364,14 +369,6 @@ const InstanceShell2: Component = (props) => { ) } - const activeSessionTitle = createMemo(() => { - const activeSessionId = activeSessionIdForInstance() - if (!activeSessionId || activeSessionId === "info") return null - - const title = activeSessionForInstance()?.title?.trim() - return title || t("sessionList.session.untitled") - }) - const renderYoloModePill = () => { if (!yoloModeEnabled()) return null return ( @@ -398,20 +395,6 @@ const InstanceShell2: Component = (props) => {
) - const renderSessionHeaderMeta = (compact = false) => { - const title = activeSessionTitle() - if (!title) return renderSessionHeaderIndicators() - - return ( -
-
- {title} -
- {renderSessionHeaderIndicators()} -
- ) - } - const handleCommandPaletteClick = () => { showCommandPalette(props.instance.id) } @@ -739,7 +722,7 @@ const InstanceShell2: Component = (props) => {
- {renderSessionHeaderMeta(true)} + {renderSessionHeaderIndicators()}
@@ -830,8 +813,8 @@ const InstanceShell2: Component = (props) => { /> -
- {renderSessionHeaderMeta()} +
+ {renderSessionHeaderIndicators()}
diff --git a/packages/ui/src/styles/panels/session-layout.css b/packages/ui/src/styles/panels/session-layout.css index 2a19f802a..1ff0dc3f6 100644 --- a/packages/ui/src/styles/panels/session-layout.css +++ b/packages/ui/src/styles/panels/session-layout.css @@ -144,30 +144,6 @@ session-sidebar-controls .selector-trigger-primary { @apply flex-shrink-0; } -.session-header-meta { - @apply flex items-center gap-2 min-w-0; -} - -.session-header-meta--compact { - @apply flex-col gap-1.5 w-full; -} - -.session-header-title { - color: var(--text-primary); - font-size: 0.875rem; - font-weight: 600; - line-height: 1.2; - max-width: min(32rem, 40vw); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.session-header-meta--compact .session-header-title { - max-width: 100%; - text-align: center; -} - .session-sidebar-separator { background-color: var(--border-base); height: 1px; diff --git a/packages/ui/src/styles/panels/tabs.css b/packages/ui/src/styles/panels/tabs.css index 3d6c251fa..2c95c520e 100644 --- a/packages/ui/src/styles/panels/tabs.css +++ b/packages/ui/src/styles/panels/tabs.css @@ -30,6 +30,18 @@ @apply flex items-center gap-1 flex-shrink-0; } +.tab-bar-session-title { + color: var(--text-secondary); + font-size: 0.75rem; + font-weight: 400; + line-height: 1.2; + margin-left: 0.375rem; + max-width: min(24rem, 28vw); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .tab-strip-spacer { @apply flex-1; min-width: 1px; From 275851480df7539ea6fb7e24fe32823caa9ae0dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sat, 9 May 2026 13:50:30 +0200 Subject: [PATCH 03/32] fix(ui): move session title into header Show the active session title in the session header when the left sidebar is not pinned so the tab strip stays focused on workspace tabs. The header slot reserves the sidebar width and reuses existing session title styling to avoid layout jumps while preserving the compact/mobile toolbar controls. Validated with git diff --cached --check, npm run typecheck --workspace @codenomad/ui, and npm run build --workspace @codenomad/ui. --- packages/ui/src/App.tsx | 22 --- packages/ui/src/components/instance-tabs.tsx | 7 - .../components/instance/instance-shell2.tsx | 183 ++++++++++-------- .../ui/src/styles/panels/session-layout.css | 13 ++ 4 files changed, 112 insertions(+), 113 deletions(-) diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 7160ede08..6d970a3f0 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -94,8 +94,6 @@ const App: Component = () => { const [escapeInDebounce, setEscapeInDebounce] = createSignal(false) const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0) const [sidecarPickerOpen, setSidecarPickerOpen] = createSignal(false) - const [leftDrawerTitleVisibility, setLeftDrawerTitleVisibility] = createSignal>({}) - const phoneQuery = useMediaQuery("(max-width: 767px)") const isPhoneLayout = createMemo(() => phoneQuery()) @@ -250,22 +248,6 @@ const App: Component = () => { return activeSessionId().get(instance.id) || null }) - const activeSessionTitleForInstance = createMemo(() => { - const instance = activeInstance() - const sessionId = activeSessionIdForInstance() - if (!instance || !sessionId || sessionId === "info") return null - - const session = getSessions(instance.id).find((entry) => entry.id === sessionId) - const title = session?.title?.trim() - return title || t("sessionList.session.untitled") - }) - - const showActiveSessionTitleInTabs = createMemo(() => { - const instance = activeInstance() - if (!instance) return false - return leftDrawerTitleVisibility()[instance.id] ?? false - }) - const launchErrorPath = () => { const value = launchError()?.binaryPath if (!value) return "opencode" @@ -556,8 +538,6 @@ const App: Component = () => { void handleCloseAppTab(tabId)} onNew={handleNewInstanceRequest} @@ -588,8 +568,6 @@ const App: Component = () => { handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(tab.instance.id, sessionId, agent)} handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(tab.instance.id, sessionId, model)} onExecuteCommand={executeCommand} - onLeftDrawerTitleVisibilityChange={(instanceId, visible) => - setLeftDrawerTitleVisibility((current) => ({ ...current, [instanceId]: visible }))} tabBarOffset={isPhoneLayout() && mobileFullscreenMode() ? 0 : instanceTabBarHeight()} mobileFullscreenMode={isPhoneLayout() && mobileFullscreenMode()} onEnterMobileFullscreen={() => void enterMobileFullscreen()} diff --git a/packages/ui/src/components/instance-tabs.tsx b/packages/ui/src/components/instance-tabs.tsx index af8bf8c80..44528cc85 100644 --- a/packages/ui/src/components/instance-tabs.tsx +++ b/packages/ui/src/components/instance-tabs.tsx @@ -22,8 +22,6 @@ import type { AppTabRecord } from "../stores/app-tabs" interface InstanceTabsProps { tabs: AppTabRecord[] activeTabId: string | null - activeSessionTitle?: string | null - showActiveSessionTitle?: boolean onSelect: (tabId: string) => void onClose: (tabId: string) => void onNew: () => void @@ -149,11 +147,6 @@ const InstanceTabs: Component = (props) => { > - -
- {props.activeSessionTitle} -
-
1}> diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index 41c9e0286..0befbe559 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -87,7 +87,6 @@ interface InstanceShellProps { mobileFullscreenMode: boolean onEnterMobileFullscreen: () => void onExitMobileFullscreen: () => void - onLeftDrawerTitleVisibilityChange?: (instanceId: string, visible: boolean) => void } const InstanceShell2: Component = (props) => { @@ -185,10 +184,6 @@ const InstanceShell2: Component = (props) => { handleRightAppBarButtonClick, } = drawerChrome - createEffect(() => { - props.onLeftDrawerTitleVisibilityChange?.(props.instance.id, leftDrawerState() === "floating-closed") - }) - createEffect(() => { const instanceId = props.instance.id loadBackgroundProcesses(instanceId).catch((error) => { @@ -702,6 +697,48 @@ const InstanceShell2: Component = (props) => { const hasSessions = createMemo(() => activeSessions().size > 0) const showingInfoView = createMemo(() => activeSessionIdForInstance() === "info") + const activeSessionTitle = createMemo(() => { + if (showingInfoView()) return null + const title = activeSessionForInstance()?.title?.trim() + return title || t("sessionList.session.untitled") + }) + const showHeaderSessionTitle = createMemo(() => !leftPinned() && Boolean(activeSessionTitle())) + + const renderActiveSessionHeaderTitle = () => ( + +
+
+
+ {activeSessionTitle()} +
+
+
+
+ ) + + const renderHeaderLeftSlot = () => ( + +
+ + + {leftAppBarButtonIcon()} + + + {renderActiveSessionHeaderTitle()} +
+
+ ) const sessionLayout = (
= (props) => { fallback={
- - - {leftAppBarButtonIcon()} - - + {renderHeaderLeftSlot()} -
- {renderSessionHeaderIndicators()} -
+
+ {renderSessionHeaderIndicators()} +
-
- +
+ + + + + + + + +
+ +
+ + + +
+ + - - - - - -
- -
- - - -
- - - - - - - - {rightAppBarButtonIcon()} - - + + + {rightAppBarButtonIcon()} + +
@@ -815,18 +841,7 @@ const InstanceShell2: Component = (props) => { } >
- - - {leftAppBarButtonIcon()} - - + {renderHeaderLeftSlot()} Date: Sat, 9 May 2026 14:05:10 +0200 Subject: [PATCH 04/32] fix(ui): align header session title with floating sidebar Subtract the Material toolbar inset from the reserved header title slot so the active session title ends at the same edge as the floating session sidebar. This prevents the title background from peeking out beside the open drawer while keeping the closed-drawer menu button and title aligned with the sidebar width. Validated with npm run typecheck --workspace @codenomad/ui and npm run build --workspace @codenomad/ui. --- packages/ui/src/components/instance/instance-shell2.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index 0befbe559..1be5c1cd1 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -703,6 +703,8 @@ const InstanceShell2: Component = (props) => { return title || t("sessionList.session.untitled") }) const showHeaderSessionTitle = createMemo(() => !leftPinned() && Boolean(activeSessionTitle())) + const headerToolbarHorizontalInset = createMemo(() => (isPhoneLayout() ? 16 : 24)) + const headerLeftSlotWidth = createMemo(() => Math.max(0, sessionSidebarWidth() - headerToolbarHorizontalInset())) const renderActiveSessionHeaderTitle = () => ( @@ -722,7 +724,7 @@ const InstanceShell2: Component = (props) => { const renderHeaderLeftSlot = () => ( -
+
Date: Sat, 9 May 2026 17:54:27 +0200 Subject: [PATCH 05/32] fix(ui): soften header session title Keep the floating-sidebar reopen control available even when the active session title is absent, so info and empty states do not strand the user after closing the drawer. Render the header session title as a quiet two-line text treatment with subtle vertical separators instead of reusing the active session item highlight. This preserves the drawer-width alignment while reducing visual weight in the toolbar. Validated with git diff --check, npm run typecheck --workspace @codenomad/ui, npm run build --workspace @codenomad/ui, and a rebuilt raw Tauri executable for visual review. --- .../components/instance/instance-shell2.tsx | 40 +++++++++---------- .../ui/src/styles/panels/session-layout.css | 19 +++++++-- 2 files changed, 35 insertions(+), 24 deletions(-) diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index 1be5c1cd1..1bfbf8fff 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -702,41 +702,39 @@ const InstanceShell2: Component = (props) => { const title = activeSessionForInstance()?.title?.trim() return title || t("sessionList.session.untitled") }) - const showHeaderSessionTitle = createMemo(() => !leftPinned() && Boolean(activeSessionTitle())) + const showHeaderLeftSlot = createMemo(() => leftDrawerState() === "floating-closed") + const showHeaderSessionTitle = createMemo(() => showHeaderLeftSlot() && Boolean(activeSessionTitle())) const headerToolbarHorizontalInset = createMemo(() => (isPhoneLayout() ? 16 : 24)) const headerLeftSlotWidth = createMemo(() => Math.max(0, sessionSidebarWidth() - headerToolbarHorizontalInset())) const renderActiveSessionHeaderTitle = () => (
-
-
- {activeSessionTitle()} -
-
+ {activeSessionTitle()}
) const renderHeaderLeftSlot = () => ( - -
- - - {leftAppBarButtonIcon()} - - + +
+ + {leftAppBarButtonIcon()} + {renderActiveSessionHeaderTitle()}
diff --git a/packages/ui/src/styles/panels/session-layout.css b/packages/ui/src/styles/panels/session-layout.css index abd6d962a..c55b44238 100644 --- a/packages/ui/src/styles/panels/session-layout.css +++ b/packages/ui/src/styles/panels/session-layout.css @@ -96,11 +96,24 @@ } .session-header-active-title { + display: flex; + align-items: center; flex: 1 1 auto; min-width: 0; - border-bottom: 0; - border-radius: var(--radius-md); - padding-block: 0.375rem; + align-self: stretch; + padding-inline: 0.75rem; + border-inline: 1px solid color-mix(in oklab, var(--border-base) 72%, transparent); + color: var(--text-secondary); +} + +.session-header-active-title-text { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + font-size: 0.8125rem; + font-weight: 500; + line-height: 1.15; } .session-sidebar-shortcuts { From cdbe594affa47b60241fc921e9765b2deca2a18c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sat, 9 May 2026 18:05:31 +0200 Subject: [PATCH 06/32] fix(ui): preserve toolbar offset for floating sidebar Keep the header left slot mounted while the unpinned session drawer is open so toolbar controls remain offset from the floating panel instead of sliding underneath it. The slot now acts as an empty spacer in the floating-open state, while the reopen button and quiet two-line session title remain limited to the floating-closed state. Validated with git diff --check and npm run typecheck --workspace @codenomad/ui. --- .../components/instance/instance-shell2.tsx | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index 1bfbf8fff..00837c20f 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -702,10 +702,13 @@ const InstanceShell2: Component = (props) => { const title = activeSessionForInstance()?.title?.trim() return title || t("sessionList.session.untitled") }) - const showHeaderLeftSlot = createMemo(() => leftDrawerState() === "floating-closed") - const showHeaderSessionTitle = createMemo(() => showHeaderLeftSlot() && Boolean(activeSessionTitle())) + const showHeaderLeftSlot = createMemo(() => !leftPinned()) + const showHeaderSessionTitle = createMemo(() => leftDrawerState() === "floating-closed" && Boolean(activeSessionTitle())) const headerToolbarHorizontalInset = createMemo(() => (isPhoneLayout() ? 16 : 24)) const headerLeftSlotWidth = createMemo(() => Math.max(0, sessionSidebarWidth() - headerToolbarHorizontalInset())) + const headerLeftSlotStyle = createMemo(() => + leftDrawerState() === "floating-open" || showHeaderSessionTitle() ? { width: `${headerLeftSlotWidth()}px` } : undefined, + ) const renderActiveSessionHeaderTitle = () => ( @@ -723,18 +726,20 @@ const InstanceShell2: Component = (props) => {
- - {leftAppBarButtonIcon()} - + + + {leftAppBarButtonIcon()} + + {renderActiveSessionHeaderTitle()}
From 064cffdf91df195026e8d44b3ca1fe7d06cc22b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sat, 9 May 2026 18:08:13 +0200 Subject: [PATCH 07/32] fix(ui): keep header session title under floating sidebar Keep the quiet header session title mounted in the left slot for all unpinned drawer states, so opening the floating sidebar covers the title instead of pushing it to the right. The reopen control is still limited to the closed state, while the slot width remains reserved when the floating drawer is open to keep toolbar controls out from under the overlay. Validated with git diff --check and npm run typecheck --workspace @codenomad/ui. --- packages/ui/src/components/instance/instance-shell2.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index 00837c20f..310dab669 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -703,7 +703,7 @@ const InstanceShell2: Component = (props) => { return title || t("sessionList.session.untitled") }) const showHeaderLeftSlot = createMemo(() => !leftPinned()) - const showHeaderSessionTitle = createMemo(() => leftDrawerState() === "floating-closed" && Boolean(activeSessionTitle())) + const showHeaderSessionTitle = createMemo(() => showHeaderLeftSlot() && Boolean(activeSessionTitle())) const headerToolbarHorizontalInset = createMemo(() => (isPhoneLayout() ? 16 : 24)) const headerLeftSlotWidth = createMemo(() => Math.max(0, sessionSidebarWidth() - headerToolbarHorizontalInset())) const headerLeftSlotStyle = createMemo(() => @@ -724,10 +724,7 @@ const InstanceShell2: Component = (props) => { const renderHeaderLeftSlot = () => ( -
+
Date: Sat, 9 May 2026 21:47:56 +0200 Subject: [PATCH 08/32] feat(ui): show aggregated total tokens and cost for parent sessions including subagents (#415) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Adds three new chips (Total In, Total Out, Total Cost) to the `ContextUsagePanel` that aggregate usage across the parent session and all child sessions (subagents and forks) - Child session messages are proactively loaded to ensure historical data appears without manually navigating to each child - Chips respect the existing `showUsageMetrics` preference toggle and are hidden when the session has no children - Reactive computation is scoped to the current instance to avoid unnecessary re-runs ## Changes | File | Change | |------|--------| | `packages/ui/src/components/session/context-usage-panel.tsx` | Adds `threadTotals` memo, proactive `loadMessages` effect, and three total chips | | `packages/ui/src/lib/i18n/messages/*/settings.ts` (7 locales) | Adds `totalInput`, `totalOutput`, `totalCost` i18n keys | ## Screenshots When viewing a parent session with subagents, the panel shows: ``` [Input] [Output] [Cost] | [Total In] [Total Out] [Total Cost] ``` ## Notes - This PR was primarily AI-generated and has gone through several iterative review and refinement steps - Built and type-checked against `upstream/dev` - Needs review and approval from the CodeNomad team Closes #401 --------- Co-authored-by: Pascal André --- packages/ui/src/components/session-list.tsx | 2 +- .../session/context-usage-panel.tsx | 66 +++++++++- .../ui/src/lib/i18n/messages/en/settings.ts | 4 + .../ui/src/lib/i18n/messages/es/settings.ts | 4 + .../ui/src/lib/i18n/messages/fr/settings.ts | 4 + .../ui/src/lib/i18n/messages/he/settings.ts | 4 + .../ui/src/lib/i18n/messages/ja/settings.ts | 4 + .../ui/src/lib/i18n/messages/ru/settings.ts | 4 + .../src/lib/i18n/messages/zh-Hans/settings.ts | 4 + packages/ui/src/lib/thread-totals.test.ts | 124 ++++++++++++++++++ packages/ui/src/lib/thread-totals.ts | 28 ++++ packages/ui/src/stores/session-api.ts | 18 ++- packages/ui/src/stores/session-events.ts | 2 +- 13 files changed, 259 insertions(+), 9 deletions(-) create mode 100644 packages/ui/src/lib/thread-totals.test.ts create mode 100644 packages/ui/src/lib/thread-totals.ts diff --git a/packages/ui/src/components/session-list.tsx b/packages/ui/src/components/session-list.tsx index 24a6e7ab8..5a53d4b06 100644 --- a/packages/ui/src/components/session-list.tsx +++ b/packages/ui/src/components/session-list.tsx @@ -235,7 +235,7 @@ const SessionList: Component = (props) => { }) try { - await loadMessages(props.instanceId, sessionId, true) + await loadMessages(props.instanceId, sessionId, { force: true }) } catch (error) { log.error(`Failed to reload session ${sessionId}:`, error) showToastNotification({ message: t("sessionList.reload.error"), variant: "error" }) diff --git a/packages/ui/src/components/session/context-usage-panel.tsx b/packages/ui/src/components/session/context-usage-panel.tsx index ee5e419d9..3c515805b 100644 --- a/packages/ui/src/components/session/context-usage-panel.tsx +++ b/packages/ui/src/components/session/context-usage-panel.tsx @@ -1,7 +1,12 @@ -import { createMemo, type Component } from "solid-js" -import { getSessionInfo } from "../../stores/sessions" +import { createEffect, createMemo, Show, untrack, type Component } from "solid-js" +import { getChildSessions, getSessionFamily, getSessionInfo, loadMessages, sessionInfoByInstance } from "../../stores/sessions" import { formatTokenTotal } from "../../lib/formatters" +import { computeThreadTotals } from "../../lib/thread-totals" import { useI18n } from "../../lib/i18n" +import { getLogger } from "../../lib/logger" +import { useConfig } from "../../stores/preferences" + +const log = getLogger("session") interface ContextUsagePanelProps { instanceId: string @@ -14,6 +19,8 @@ const chipLabelClass = "uppercase text-[10px] tracking-wide text-muted" const ContextUsagePanel: Component = (props) => { const { t } = useI18n() + const { preferences } = useConfig() + const showUsage = createMemo(() => preferences().showUsageMetrics ?? true) const info = createMemo( () => getSessionInfo(props.instanceId, props.sessionId) ?? { @@ -29,6 +36,43 @@ const ContextUsagePanel: Component = (props) => { }, ) + const children = createMemo(() => getChildSessions(props.instanceId, props.sessionId)) + const hasChildren = createMemo(() => children().length > 0) + + const instanceInfoMap = createMemo(() => + sessionInfoByInstance().get(props.instanceId), + ) + + createEffect(() => { + if (!showUsage()) return + const instanceId = props.instanceId + const childSessions = children() + untrack(() => { + for (const child of childSessions) { + loadMessages(instanceId, child.id, { skipDiff: true }).catch((error) => + log.error("Failed to load child session messages", { + instanceId, + sessionId: child.id, + error, + }), + ) + } + }) + }) + + const threadTotals = createMemo(() => { + if (!hasChildren()) return { cost: 0, inputTokens: 0, outputTokens: 0, reasoningTokens: 0 } + const map = instanceInfoMap() + const family = getSessionFamily(props.instanceId, props.sessionId) + return computeThreadTotals(family, map) + }) + + const totalCostDisplay = createMemo(() => `$${threadTotals().cost.toFixed(2)}`) + + const totalInputTokens = createMemo(() => threadTotals().inputTokens) + const totalOutputTokens = createMemo(() => threadTotals().outputTokens) + const totalReasoningTokens = createMemo(() => threadTotals().reasoningTokens) + const inputTokens = createMemo(() => info().inputTokens ?? 0) const outputTokens = createMemo(() => info().outputTokens ?? 0) const costValue = createMemo(() => { @@ -53,6 +97,24 @@ const ContextUsagePanel: Component = (props) => { {t("contextUsagePanel.labels.cost")} {costDisplay()}
+ +
+ {t("contextUsagePanel.labels.totalInput")} + {formatTokenTotal(totalInputTokens())} +
+
+ {t("contextUsagePanel.labels.totalOutput")} + {formatTokenTotal(totalOutputTokens())} +
+
+ {t("contextUsagePanel.labels.totalCost")} + {totalCostDisplay()} +
+
+ {t("contextUsagePanel.labels.totalReasoning")} + {formatTokenTotal(totalReasoningTokens())} +
+
) diff --git a/packages/ui/src/lib/i18n/messages/en/settings.ts b/packages/ui/src/lib/i18n/messages/en/settings.ts index 53a113192..18aa670da 100644 --- a/packages/ui/src/lib/i18n/messages/en/settings.ts +++ b/packages/ui/src/lib/i18n/messages/en/settings.ts @@ -52,6 +52,10 @@ export const settingsMessages = { "contextUsagePanel.labels.input": "Input", "contextUsagePanel.labels.output": "Output", "contextUsagePanel.labels.cost": "Cost", + "contextUsagePanel.labels.totalInput": "Total In", + "contextUsagePanel.labels.totalOutput": "Total Out", + "contextUsagePanel.labels.totalCost": "Total Cost", + "contextUsagePanel.labels.totalReasoning": "Total Reasoning", "contextUsagePanel.labels.used": "Used", "contextUsagePanel.labels.available": "Avail", "contextUsagePanel.unavailable": "--", diff --git a/packages/ui/src/lib/i18n/messages/es/settings.ts b/packages/ui/src/lib/i18n/messages/es/settings.ts index 7cc845099..fc270bff7 100644 --- a/packages/ui/src/lib/i18n/messages/es/settings.ts +++ b/packages/ui/src/lib/i18n/messages/es/settings.ts @@ -52,6 +52,10 @@ export const settingsMessages = { "contextUsagePanel.labels.input": "Entrada", "contextUsagePanel.labels.output": "Salida", "contextUsagePanel.labels.cost": "Costo", + "contextUsagePanel.labels.totalInput": "Entrada total", + "contextUsagePanel.labels.totalOutput": "Salida total", + "contextUsagePanel.labels.totalCost": "Costo total", + "contextUsagePanel.labels.totalReasoning": "Razonamiento total", "contextUsagePanel.labels.used": "Usado", "contextUsagePanel.labels.available": "Disp.", "contextUsagePanel.unavailable": "--", diff --git a/packages/ui/src/lib/i18n/messages/fr/settings.ts b/packages/ui/src/lib/i18n/messages/fr/settings.ts index dcb4ef6ee..44a3057a1 100644 --- a/packages/ui/src/lib/i18n/messages/fr/settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr/settings.ts @@ -52,6 +52,10 @@ export const settingsMessages = { "contextUsagePanel.labels.input": "Entrée", "contextUsagePanel.labels.output": "Sortie", "contextUsagePanel.labels.cost": "Coût", + "contextUsagePanel.labels.totalInput": "Entrée totale", + "contextUsagePanel.labels.totalOutput": "Sortie totale", + "contextUsagePanel.labels.totalCost": "Coût total", + "contextUsagePanel.labels.totalReasoning": "Raisonnement total", "contextUsagePanel.labels.used": "Utilisé", "contextUsagePanel.labels.available": "Dispo", "contextUsagePanel.unavailable": "--", diff --git a/packages/ui/src/lib/i18n/messages/he/settings.ts b/packages/ui/src/lib/i18n/messages/he/settings.ts index 58657d0b1..a9e840b73 100644 --- a/packages/ui/src/lib/i18n/messages/he/settings.ts +++ b/packages/ui/src/lib/i18n/messages/he/settings.ts @@ -52,6 +52,10 @@ export const settingsMessages = { "contextUsagePanel.labels.input": "קלט", "contextUsagePanel.labels.output": "פלט", "contextUsagePanel.labels.cost": "עלות", + "contextUsagePanel.labels.totalInput": "קלט כולל", + "contextUsagePanel.labels.totalOutput": "פלט כולל", + "contextUsagePanel.labels.totalCost": "עלות כוללת", + "contextUsagePanel.labels.totalReasoning": "חשיבה כוללת", "contextUsagePanel.labels.used": "בשימוש", "contextUsagePanel.labels.available": "זמין", "contextUsagePanel.unavailable": "--", diff --git a/packages/ui/src/lib/i18n/messages/ja/settings.ts b/packages/ui/src/lib/i18n/messages/ja/settings.ts index 7cec7f3f1..267120a0d 100644 --- a/packages/ui/src/lib/i18n/messages/ja/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja/settings.ts @@ -52,6 +52,10 @@ export const settingsMessages = { "contextUsagePanel.labels.input": "入力", "contextUsagePanel.labels.output": "出力", "contextUsagePanel.labels.cost": "コスト", + "contextUsagePanel.labels.totalInput": "合計入力", + "contextUsagePanel.labels.totalOutput": "合計出力", + "contextUsagePanel.labels.totalCost": "合計コスト", + "contextUsagePanel.labels.totalReasoning": "合計推論", "contextUsagePanel.labels.used": "使用", "contextUsagePanel.labels.available": "残り", "contextUsagePanel.unavailable": "--", diff --git a/packages/ui/src/lib/i18n/messages/ru/settings.ts b/packages/ui/src/lib/i18n/messages/ru/settings.ts index f7f967df4..49ff80a61 100644 --- a/packages/ui/src/lib/i18n/messages/ru/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ru/settings.ts @@ -52,6 +52,10 @@ export const settingsMessages = { "contextUsagePanel.labels.input": "Ввод", "contextUsagePanel.labels.output": "Вывод", "contextUsagePanel.labels.cost": "Стоимость", + "contextUsagePanel.labels.totalInput": "Всего ввод", + "contextUsagePanel.labels.totalOutput": "Всего вывод", + "contextUsagePanel.labels.totalCost": "Общая стоимость", + "contextUsagePanel.labels.totalReasoning": "Всего рассуждений", "contextUsagePanel.labels.used": "Использовано", "contextUsagePanel.labels.available": "Доступно", "contextUsagePanel.unavailable": "--", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts index 6ad90944c..8e469e1ec 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts @@ -52,6 +52,10 @@ export const settingsMessages = { "contextUsagePanel.labels.input": "输入", "contextUsagePanel.labels.output": "输出", "contextUsagePanel.labels.cost": "费用", + "contextUsagePanel.labels.totalInput": "总输入", + "contextUsagePanel.labels.totalOutput": "总输出", + "contextUsagePanel.labels.totalCost": "总费用", + "contextUsagePanel.labels.totalReasoning": "总推理", "contextUsagePanel.labels.used": "已用", "contextUsagePanel.labels.available": "可用", "contextUsagePanel.unavailable": "--", diff --git a/packages/ui/src/lib/thread-totals.test.ts b/packages/ui/src/lib/thread-totals.test.ts new file mode 100644 index 000000000..0d143495a --- /dev/null +++ b/packages/ui/src/lib/thread-totals.test.ts @@ -0,0 +1,124 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import type { SessionInfo } from "../stores/sessions.js" +import { computeThreadTotals } from "./thread-totals.js" + +function makeInfo(overrides: Partial = {}): SessionInfo { + return { + cost: 0, + contextWindow: 0, + isSubscriptionModel: false, + inputTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + actualUsageTokens: 0, + modelOutputLimit: 0, + contextAvailableTokens: null, + ...overrides, + } +} + +describe("computeThreadTotals", () => { + it("sums cost, input, output, and reasoning tokens for normal sessions", () => { + const family = [{ id: "s1" }, { id: "s2" }] + const infoMap = new Map([ + ["s1", makeInfo({ cost: 0.15, inputTokens: 500, outputTokens: 200, reasoningTokens: 100 })], + ["s2", makeInfo({ cost: 0.35, inputTokens: 1000, outputTokens: 400, reasoningTokens: 200 })], + ]) + + const totals = computeThreadTotals(family, infoMap) + + assert.equal(totals.cost, 0.5) + assert.equal(totals.inputTokens, 1500) + assert.equal(totals.outputTokens, 600) + assert.equal(totals.reasoningTokens, 300) + }) + + it("includes tokens but not cost for subscription model sessions", () => { + const family = [{ id: "s1" }] + const infoMap = new Map([ + [ + "s1", + makeInfo({ + cost: 1.0, + inputTokens: 100, + outputTokens: 50, + reasoningTokens: 25, + isSubscriptionModel: true, + }), + ], + ]) + + const totals = computeThreadTotals(family, infoMap) + + assert.equal(totals.cost, 0) + assert.equal(totals.inputTokens, 100) + assert.equal(totals.outputTokens, 50) + assert.equal(totals.reasoningTokens, 25) + }) + + it("mixes subscription and normal sessions correctly", () => { + const family = [{ id: "normal" }, { id: "sub" }] + const infoMap = new Map([ + [ + "normal", + makeInfo({ cost: 0.1, inputTokens: 200, outputTokens: 100, reasoningTokens: 50 }), + ], + [ + "sub", + makeInfo({ + cost: 0.5, + inputTokens: 300, + outputTokens: 150, + reasoningTokens: 75, + isSubscriptionModel: true, + }), + ], + ]) + + const totals = computeThreadTotals(family, infoMap) + + assert.equal(totals.cost, 0.1) + assert.equal(totals.inputTokens, 500) + assert.equal(totals.outputTokens, 250) + assert.equal(totals.reasoningTokens, 125) + }) + + it("returns zeros for an empty family", () => { + const totals = computeThreadTotals([], undefined) + + assert.equal(totals.cost, 0) + assert.equal(totals.inputTokens, 0) + assert.equal(totals.outputTokens, 0) + assert.equal(totals.reasoningTokens, 0) + }) + + it("returns zeros when no session info is available", () => { + const family = [{ id: "missing" }] + + const totals = computeThreadTotals(family, undefined) + + assert.equal(totals.cost, 0) + assert.equal(totals.inputTokens, 0) + assert.equal(totals.outputTokens, 0) + assert.equal(totals.reasoningTokens, 0) + }) + + it("treats missing info as zeros", () => { + const family = [{ id: "present" }, { id: "missing" }] + const infoMap = new Map([ + [ + "present", + makeInfo({ cost: 0.1, inputTokens: 100, outputTokens: 50, reasoningTokens: 10 }), + ], + ]) + + const totals = computeThreadTotals(family, infoMap) + + assert.equal(totals.cost, 0.1) + assert.equal(totals.inputTokens, 100) + assert.equal(totals.outputTokens, 50) + assert.equal(totals.reasoningTokens, 10) + }) +}) diff --git a/packages/ui/src/lib/thread-totals.ts b/packages/ui/src/lib/thread-totals.ts new file mode 100644 index 000000000..db25a9e0c --- /dev/null +++ b/packages/ui/src/lib/thread-totals.ts @@ -0,0 +1,28 @@ +import type { SessionInfo } from "../stores/sessions" + +export interface ThreadTotals { + cost: number + inputTokens: number + outputTokens: number + reasoningTokens: number +} + +export function computeThreadTotals( + family: { id: string }[], + infoMap: Map | undefined, +): ThreadTotals { + let cost = 0 + let inputTokens = 0 + let outputTokens = 0 + let reasoningTokens = 0 + for (const session of family) { + const sessionInfo = infoMap?.get(session.id) + inputTokens += sessionInfo?.inputTokens ?? 0 + outputTokens += sessionInfo?.outputTokens ?? 0 + reasoningTokens += sessionInfo?.reasoningTokens ?? 0 + if (!sessionInfo?.isSubscriptionModel) { + cost += sessionInfo?.cost ?? 0 + } + } + return { cost, inputTokens, outputTokens, reasoningTokens } +} diff --git a/packages/ui/src/stores/session-api.ts b/packages/ui/src/stores/session-api.ts index 63f7a7222..dad131e43 100644 --- a/packages/ui/src/stores/session-api.ts +++ b/packages/ui/src/stores/session-api.ts @@ -595,7 +595,14 @@ async function fetchProviders(instanceId: string): Promise { } } -async function loadMessages(instanceId: string, sessionId: string, force = false): Promise { +async function loadMessages( + instanceId: string, + sessionId: string, + options?: { force?: boolean; skipDiff?: boolean }, +): Promise { + const force = options?.force ?? false + const skipDiff = options?.skipDiff ?? false + if (force) { setMessagesLoaded((prev) => { const next = new Map(prev) @@ -631,10 +638,11 @@ async function loadMessages(instanceId: string, sessionId: string, force = false throw new Error("Session not found") } - // Fetch session-level diffs in the background once the session is opened. - void loadSessionDiff(instanceId, sessionId).catch((error) => { - log.warn("Failed to load session diff", { instanceId, sessionId, error }) - }) + if (!skipDiff) { + void loadSessionDiff(instanceId, sessionId).catch((error) => { + log.warn("Failed to load session diff", { instanceId, sessionId, error }) + }) + } setLoading((prev) => { const next = { ...prev } diff --git a/packages/ui/src/stores/session-events.ts b/packages/ui/src/stores/session-events.ts index ef4764b0a..8034d0dcf 100644 --- a/packages/ui/src/stores/session-events.ts +++ b/packages/ui/src/stores/session-events.ts @@ -614,7 +614,7 @@ function handleSessionCompacted(instanceId: string, event: EventSessionCompacted ensureSessionStatus(instanceId, sessionID, "working", (event as any)?.directory) } - loadMessages(instanceId, sessionID, true).catch((error) => log.error("Failed to reload session after compaction", error)) + loadMessages(instanceId, sessionID, { force: true }).catch((error) => log.error("Failed to reload session after compaction", error)) const instanceSessions = sessions().get(instanceId) const session = instanceSessions?.get(sessionID) From a0a8da97e6c2012a29a24426a8acd83e103dbe53 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Sat, 9 May 2026 22:24:12 +0100 Subject: [PATCH 09/32] fix(ui): hydrate full child sessions for usage totals Use the dedicated session children endpoint when refreshing the session list so parent rows and aggregate usage are based on the complete child set rather than the recent /session window. Move thread usage totals into session state and refresh them whenever session info changes, including SSE-driven message updates. Parent message loading now also hydrates child messages from the store while preserving existing loaded-message guards. Validation: npm run typecheck --workspace @codenomad/ui --- .../session/context-usage-panel.tsx | 33 +---- .../ui/src/stores/message-v2/session-info.ts | 4 +- packages/ui/src/stores/session-api.ts | 115 +++++++++++++++++- packages/ui/src/stores/session-state.ts | 29 +++++ packages/ui/src/stores/sessions.ts | 4 + 5 files changed, 153 insertions(+), 32 deletions(-) diff --git a/packages/ui/src/components/session/context-usage-panel.tsx b/packages/ui/src/components/session/context-usage-panel.tsx index 3c515805b..7c69ea1fe 100644 --- a/packages/ui/src/components/session/context-usage-panel.tsx +++ b/packages/ui/src/components/session/context-usage-panel.tsx @@ -1,13 +1,9 @@ -import { createEffect, createMemo, Show, untrack, type Component } from "solid-js" -import { getChildSessions, getSessionFamily, getSessionInfo, loadMessages, sessionInfoByInstance } from "../../stores/sessions" +import { createMemo, Show, type Component } from "solid-js" +import { getChildSessions, getSessionInfo, getThreadTotals } from "../../stores/sessions" import { formatTokenTotal } from "../../lib/formatters" -import { computeThreadTotals } from "../../lib/thread-totals" import { useI18n } from "../../lib/i18n" -import { getLogger } from "../../lib/logger" import { useConfig } from "../../stores/preferences" -const log = getLogger("session") - interface ContextUsagePanelProps { instanceId: string sessionId: string @@ -39,32 +35,9 @@ const ContextUsagePanel: Component = (props) => { const children = createMemo(() => getChildSessions(props.instanceId, props.sessionId)) const hasChildren = createMemo(() => children().length > 0) - const instanceInfoMap = createMemo(() => - sessionInfoByInstance().get(props.instanceId), - ) - - createEffect(() => { - if (!showUsage()) return - const instanceId = props.instanceId - const childSessions = children() - untrack(() => { - for (const child of childSessions) { - loadMessages(instanceId, child.id, { skipDiff: true }).catch((error) => - log.error("Failed to load child session messages", { - instanceId, - sessionId: child.id, - error, - }), - ) - } - }) - }) - const threadTotals = createMemo(() => { if (!hasChildren()) return { cost: 0, inputTokens: 0, outputTokens: 0, reasoningTokens: 0 } - const map = instanceInfoMap() - const family = getSessionFamily(props.instanceId, props.sessionId) - return computeThreadTotals(family, map) + return getThreadTotals(props.instanceId, props.sessionId) ?? { cost: 0, inputTokens: 0, outputTokens: 0, reasoningTokens: 0 } }) const totalCostDisplay = createMemo(() => `$${threadTotals().cost.toFixed(2)}`) diff --git a/packages/ui/src/stores/message-v2/session-info.ts b/packages/ui/src/stores/message-v2/session-info.ts index 50bbf6976..fc1575102 100644 --- a/packages/ui/src/stores/message-v2/session-info.ts +++ b/packages/ui/src/stores/message-v2/session-info.ts @@ -1,6 +1,6 @@ import type { Provider } from "../../types/session" import { DEFAULT_MODEL_OUTPUT_LIMIT } from "../session-models" -import { providers, sessions, sessionInfoByInstance, setSessionInfoByInstance } from "../session-state" +import { providers, sessions, sessionInfoByInstance, setSessionInfoByInstance, updateThreadTotalsForSession } from "../session-state" import { messageStoreBus } from "./bus" import type { SessionUsageState } from "./types" @@ -140,4 +140,6 @@ export function updateSessionInfo(instanceId: string, sessionId: string): void { next.set(instanceId, instanceInfo) return next }) + + updateThreadTotalsForSession(instanceId, sessionId) } diff --git a/packages/ui/src/stores/session-api.ts b/packages/ui/src/stores/session-api.ts index dad131e43..bf88185ec 100644 --- a/packages/ui/src/stores/session-api.ts +++ b/packages/ui/src/stores/session-api.ts @@ -25,6 +25,7 @@ import { setLoading, cleanupBlankSessions, syncInstanceSessionIndicator, + updateThreadTotalsForParent, } from "./session-state" import { DEFAULT_MODEL_OUTPUT_LIMIT, getDefaultModel, isModelValid } from "./session-models" import { normalizeMessagePart } from "./message-v2/normalizers" @@ -45,6 +46,7 @@ import { const log = getLogger("api") const pendingSessionDiffFetches = new Map>() +const pendingSessionChildrenFetches = new Map>() async function loadSessionDiff(instanceId: string, sessionId: string, force = false): Promise { if (!instanceId || !sessionId) return @@ -218,6 +220,12 @@ async function fetchSessions(instanceId: string): Promise { pruneDraftPrompts(instanceId, new Set(sessionMap.keys())) + + const parentIds = Array.from(sessionMap.values()) + .filter((session) => session.parentId === null) + .map((session) => session.id) + + await Promise.all(parentIds.map((parentId) => fetchSessionChildren(instanceId, parentId))) } catch (error) { log.error("Failed to fetch sessions:", error) throw error @@ -230,6 +238,96 @@ async function fetchSessions(instanceId: string): Promise { } } +function toClientSession(instanceId: string, apiSession: any, existingSession?: Session): Session { + return { + id: apiSession.id, + instanceId, + title: apiSession.title || existingSession?.title || "Untitled", + parentId: apiSession.parentID || null, + agent: existingSession?.agent ?? "", + model: existingSession?.model ?? { providerId: "", modelId: "" }, + status: existingSession?.status ?? "idle", + retry: existingSession?.retry ?? null, + idleSince: existingSession?.idleSince ?? null, + version: apiSession.version, + time: { + ...apiSession.time, + }, + revert: apiSession.revert + ? { + messageID: apiSession.revert.messageID, + partID: apiSession.revert.partID, + snapshot: apiSession.revert.snapshot, + diff: apiSession.revert.diff, + } + : existingSession?.revert, + diff: existingSession?.diff, + pendingPermission: existingSession?.pendingPermission, + pendingQuestion: existingSession?.pendingQuestion, + } +} + +async function fetchSessionChildren(instanceId: string, parentSessionId: string): Promise { + if (!instanceId || !parentSessionId) return [] + + const key = `${instanceId}:${parentSessionId}` + const pending = pendingSessionChildrenFetches.get(key) + if (pending) return pending + + const promise = (async () => { + const instance = instances().get(instanceId) + if (!instance || !instance.client) { + throw new Error("Instance not ready") + } + + const worktreeSlug = getWorktreeSlugForSession(instanceId, parentSessionId) + const client = getOrCreateWorktreeClient(instanceId, worktreeSlug) + + log.info(`[HTTP] GET /session/{sessionID}/children for instance ${instanceId}`, { sessionId: parentSessionId }) + const apiChildren = await requestData( + client.session.children({ sessionID: parentSessionId }), + "session.children", + ) + + if (!Array.isArray(apiChildren)) return [] + + const currentSessions = sessions().get(instanceId) + const children = apiChildren.map((apiSession) => toClientSession(instanceId, apiSession, currentSessions?.get(apiSession.id))) + + setSessions((prev) => { + const next = new Map(prev) + const instanceSessions = new Map(next.get(instanceId)) + for (const child of children) { + instanceSessions.set(child.id, child) + } + next.set(instanceId, instanceSessions) + return next + }) + + syncInstanceSessionIndicator(instanceId) + updateThreadTotalsForParent(instanceId, parentSessionId) + + if (messagesLoaded().get(instanceId)?.has(parentSessionId)) { + for (const child of children) { + void loadMessages(instanceId, child.id, { skipDiff: true, skipChildren: true }).catch((error) => + log.error("Failed to load child session messages", { + instanceId, + sessionId: child.id, + parentSessionId, + error, + }), + ) + } + } + + return children + })() + + pendingSessionChildrenFetches.set(key, promise) + void promise.finally(() => pendingSessionChildrenFetches.delete(key)) + return promise +} + async function createSession(instanceId: string, agent?: string): Promise { const instance = instances().get(instanceId) if (!instance || !instance.client) { @@ -598,10 +696,11 @@ async function fetchProviders(instanceId: string): Promise { async function loadMessages( instanceId: string, sessionId: string, - options?: { force?: boolean; skipDiff?: boolean }, + options?: { force?: boolean; skipDiff?: boolean; skipChildren?: boolean }, ): Promise { const force = options?.force ?? false const skipDiff = options?.skipDiff ?? false + const skipChildren = options?.skipChildren ?? false if (force) { setMessagesLoaded((prev) => { @@ -783,6 +882,19 @@ async function loadMessages( } updateSessionInfo(instanceId, sessionId) + + if (!skipChildren && session.parentId === null) { + for (const child of getChildSessions(instanceId, sessionId)) { + void loadMessages(instanceId, child.id, { skipDiff: true, skipChildren: true }).catch((error) => + log.error("Failed to load child session messages", { + instanceId, + sessionId: child.id, + parentSessionId: sessionId, + error, + }), + ) + } + } } export { @@ -792,6 +904,7 @@ export { fetchProviders, fetchSessions, + fetchSessionChildren, forkSession, loadMessages, } diff --git a/packages/ui/src/stores/session-state.ts b/packages/ui/src/stores/session-state.ts index f5e82cbd6..726f7cb84 100644 --- a/packages/ui/src/stores/session-state.ts +++ b/packages/ui/src/stores/session-state.ts @@ -10,6 +10,7 @@ import { getLogger } from "../lib/logger" import { requestData } from "../lib/opencode-api" import { getOrCreateWorktreeClient, getWorktreeSlugForSession } from "./worktrees" import { tGlobal } from "../lib/i18n" +import { computeThreadTotals, type ThreadTotals } from "../lib/thread-totals" const log = getLogger("session") @@ -47,6 +48,7 @@ const [loading, setLoading] = createSignal({ const [messagesLoaded, setMessagesLoaded] = createSignal>>(new Map()) const [sessionInfoByInstance, setSessionInfoByInstance] = createSignal>>(new Map()) +const [threadTotalsByInstance, setThreadTotalsByInstance] = createSignal>>(new Map()) const [expandedSessionParents, setExpandedSessionParents] = createSignal>>(new Map()) @@ -648,6 +650,29 @@ function getSessionInfo(instanceId: string, sessionId: string): SessionInfo | un return sessionInfoByInstance().get(instanceId)?.get(sessionId) } +function getThreadTotals(instanceId: string, parentSessionId: string): ThreadTotals | undefined { + return threadTotalsByInstance().get(instanceId)?.get(parentSessionId) +} + +function updateThreadTotalsForParent(instanceId: string, parentSessionId: string): void { + const family = getSessionFamily(instanceId, parentSessionId) + const totals = computeThreadTotals(family, sessionInfoByInstance().get(instanceId)) + + setThreadTotalsByInstance((prev) => { + const next = new Map(prev) + const instanceTotals = new Map(next.get(instanceId)) + instanceTotals.set(parentSessionId, totals) + next.set(instanceId, instanceTotals) + return next + }) +} + +function updateThreadTotalsForSession(instanceId: string, sessionId: string): void { + const session = sessions().get(instanceId)?.get(sessionId) + if (!session) return + updateThreadTotalsForParent(instanceId, session.parentId ?? session.id) +} + async function isBlankSession(session: Session, instanceId: string, fetchIfNeeded = false): Promise { const created = session.time?.created || 0 const updated = session.time?.updated || 0 @@ -774,6 +799,10 @@ export { setMessagesLoaded, sessionInfoByInstance, setSessionInfoByInstance, + threadTotalsByInstance, + getThreadTotals, + updateThreadTotalsForParent, + updateThreadTotalsForSession, getSessionDraftPrompt, setSessionDraftPrompt, clearSessionDraftPrompt, diff --git a/packages/ui/src/stores/sessions.ts b/packages/ui/src/stores/sessions.ts index 96ac43e6c..b64b04a7f 100644 --- a/packages/ui/src/stores/sessions.ts +++ b/packages/ui/src/stores/sessions.ts @@ -18,6 +18,7 @@ import { getSessionFamily, getSessionInfo, getSessionThreads, + getThreadTotals, getSessions, getVisibleSessionIds, isSessionBusy, @@ -45,6 +46,7 @@ import { fetchAgents, fetchProviders, fetchSessions, + fetchSessionChildren, forkSession, loadMessages, } from "./session-api" @@ -109,6 +111,7 @@ export { fetchAgents, fetchProviders, fetchSessions, + fetchSessionChildren, forkSession, getActiveParentSession, getActiveSession, @@ -119,6 +122,7 @@ export { getSessionFamily, getSessionInfo, getSessionThreads, + getThreadTotals, getSessions, getVisibleSessionIds, isSessionBusy, From 6d120b0d84b3e5130668da8ec62bba51a3ad49d3 Mon Sep 17 00:00:00 2001 From: Yao Jianxuan <1378319314@qq.com> Date: Sun, 10 May 2026 05:46:58 +0800 Subject: [PATCH 10/32] fix(ui): align primary agent selection with OpenCode (#409) ## Summary This PR keeps only the UI-side fix for primary agent selection in CodeNomad. It aligns the primary agent selector with OpenCode's behavior by: - filtering primary-session agents through a shared selectable-primary rule - avoiding re-inserting an invalid current agent back into the primary selector - using the same primary-agent rule when choosing the default agent for a newly created session ## Why The earlier investigation showed that the config-loading problem was actually rooted in OMO's handling of `OPENCODE_CONFIG_DIR`, not in CodeNomad itself. However, there was still an independent UI issue in CodeNomad: - the primary selector could keep showing an agent that should no longer be selectable - new sessions could derive their default agent from a looser filter than the one used by the selector This PR keeps only that UI-side fix. ## Changes - `packages/ui/src/components/agent-selector.tsx` - use a shared `isSelectablePrimaryAgent` helper for primary-session filtering - stop force-inserting the current invalid agent back into the primary selector list - `packages/ui/src/stores/session-api.ts` - use the same `isSelectablePrimaryAgent` helper when selecting the default agent for a new session - `packages/ui/src/types/session.ts` - add `isSelectablePrimaryAgent(agent)` helper ## Scope This PR intentionally does **not** include any config merge / config copy workaround related to `OPENCODE_CONFIG_DIR`. That part was removed so this PR stays narrowly focused on the primary-agent selection behavior only. ## Validation - verified changed files are clean in editor diagnostics - confirmed this is a minimal UI-only diff --------- Co-authored-by: Sisyphus --- packages/ui/src/components/agent-selector.tsx | 14 +++----------- packages/ui/src/components/session-picker.tsx | 4 ++-- packages/ui/src/stores/session-api.ts | 13 ++++++++++--- packages/ui/src/types/session.ts | 7 +++++++ 4 files changed, 22 insertions(+), 16 deletions(-) diff --git a/packages/ui/src/components/agent-selector.tsx b/packages/ui/src/components/agent-selector.tsx index b5c6d5da5..b3ef06b35 100644 --- a/packages/ui/src/components/agent-selector.tsx +++ b/packages/ui/src/components/agent-selector.tsx @@ -1,8 +1,8 @@ import { Select } from "@kobalte/core/select" -import { For, Show, createEffect, createMemo } from "solid-js" +import { Show, createEffect, createMemo } from "solid-js" import { agents, fetchAgents, sessions } from "../stores/sessions" import { ChevronDown } from "lucide-solid" -import type { Agent } from "../types/session" +import { isSelectablePrimaryAgent, type Agent } from "../types/session" import { useI18n } from "../lib/i18n" import { getLogger } from "../lib/logger" const log = getLogger("session") @@ -34,14 +34,7 @@ export default function AgentSelector(props: AgentSelectorProps) { return allAgents.filter((agent) => !agent.hidden) } - 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)) { - return [currentAgent, ...filtered] - } - - return filtered + return allAgents.filter(isSelectablePrimaryAgent) }) createEffect(() => { @@ -58,7 +51,6 @@ export default function AgentSelector(props: AgentSelectorProps) { } }) - const handleChange = async (value: Agent | null) => { if (value && value.name !== props.currentAgent) { await props.onAgentChange(value.name) diff --git a/packages/ui/src/components/session-picker.tsx b/packages/ui/src/components/session-picker.tsx index b67aee8e1..54de9c03f 100644 --- a/packages/ui/src/components/session-picker.tsx +++ b/packages/ui/src/components/session-picker.tsx @@ -1,6 +1,6 @@ import { Component, createSignal, Show, For, createEffect } from "solid-js" import { Dialog } from "@kobalte/core/dialog" -import type { Session, Agent } from "../types/session" +import { isSelectablePrimaryAgent, type Session, type Agent } from "../types/session" import { getParentSessions, createSession, setActiveParentSession } from "../stores/sessions" import { instances, stopInstance } from "../stores/instances" import { agents } from "../stores/sessions" @@ -22,7 +22,7 @@ const SessionPicker: Component = (props) => { const instance = () => instances().get(props.instanceId) const parentSessions = () => getParentSessions(props.instanceId) - const agentList = () => agents().get(props.instanceId) || [] + const agentList = () => (agents().get(props.instanceId) || []).filter(isSelectablePrimaryAgent) createEffect(() => { const list = agentList() diff --git a/packages/ui/src/stores/session-api.ts b/packages/ui/src/stores/session-api.ts index bf88185ec..36609380f 100644 --- a/packages/ui/src/stores/session-api.ts +++ b/packages/ui/src/stores/session-api.ts @@ -1,4 +1,11 @@ -import { getIdleSinceForStatusTransition, mapSdkSessionRetry, mapSdkSessionStatus, type Session, type SessionStatus } from "../types/session" +import { + getIdleSinceForStatusTransition, + isSelectablePrimaryAgent, + mapSdkSessionRetry, + mapSdkSessionStatus, + type Session, + type SessionStatus, +} from "../types/session" import type { Message } from "../types/message" import type { FileDiff } from "@opencode-ai/sdk/v2/client" @@ -341,8 +348,8 @@ async function createSession(instanceId: string, agent?: string): Promise a.mode !== "subagent") - const selectedAgent = agent || (nonSubagents.length > 0 ? nonSubagents[0].name : "") + const primaryAgents = instanceAgents.filter(isSelectablePrimaryAgent) + const selectedAgent = agent || (primaryAgents.length > 0 ? primaryAgents[0].name : "") const defaultModel = await getDefaultModel(instanceId, selectedAgent) diff --git a/packages/ui/src/types/session.ts b/packages/ui/src/types/session.ts index 7f1829fea..58cfa1e9d 100644 --- a/packages/ui/src/types/session.ts +++ b/packages/ui/src/types/session.ts @@ -112,6 +112,13 @@ export interface Agent { } } +/** + * Matches OpenCode TUI's primary-agent visibility rule: visible iff not a subagent and not hidden. + */ +export function isSelectablePrimaryAgent(agent: Agent): boolean { + return !agent.hidden && agent.mode !== "subagent" +} + // Our client-specific Provider interface (simplified version of SDK Provider) export interface Provider { id: string From 8976d9985bb91041008ea03099a6f51aaa0797f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 10 May 2026 17:48:04 +0200 Subject: [PATCH 11/32] fix(ui): drain yolo permissions outside shell render (#424) ## Summary - Split out from #422 as the YOLO-only permission fix. - Moves YOLO auto-accept draining out of `InstanceShell` render effects and into the permission queue flow. - Keeps behavior unchanged: YOLO remains per-session and replies with `once` only. - Keeps in-flight cleanup for auto-accept attempts so duplicate sends are guarded outside UI render timing. ## Why In YOLO mode, the app could still appear to wait on a permission or block the UI, even though auto-accept was enabled and the permission was already queued. The auto-accept drain was tied to an `InstanceShell` render effect, so the reply path depended on a specific UI shell rendering. Draining from permission sync/enqueue and from the YOLO toggle makes auto-accept run from the permission queue itself instead of UI render timing. ## Validation - `git diff --check` - `npm run typecheck --workspace @codenomad/ui` --- .../components/instance/instance-shell2.tsx | 31 +------- .../shell/right-panel/tabs/StatusTab.tsx | 5 +- packages/ui/src/stores/instances.ts | 25 ++++++- .../ui/src/stores/permission-auto-accept.ts | 72 +++++++++++++++---- 4 files changed, 86 insertions(+), 47 deletions(-) diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index bdfed7f3b..1f857591d 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -36,7 +36,7 @@ import { serverApi } from "../../lib/api-client" import { loadBackgroundProcesses } from "../../stores/background-processes" import { BackgroundProcessOutputDialog } from "../background-process-output-dialog" import { useI18n } from "../../lib/i18n" -import { getPermissionQueue, getPermissionQueueLength, getQuestionQueueLength, sendPermissionResponse } from "../../stores/instances" +import { getPermissionQueueLength, getQuestionQueueLength } from "../../stores/instances" import SessionSidebar from "./shell/SessionSidebar" import { useSessionSidebarRequests } from "./shell/useSessionSidebarRequests" import RightPanel from "./shell/right-panel/RightPanel" @@ -58,13 +58,7 @@ import { useDrawerHostMeasure } from "./shell/useDrawerHostMeasure" import { useDrawerResize } from "./shell/useDrawerResize" import { useSessionCache } from "./shell/useSessionCache" import { useInstanceSessionContext } from "./shell/useInstanceSessionContext" -import { getPermissionSessionId } from "../../types/permission" -import { - canAutoRespondPermission, - finishAutoRespondPermission, - getPermissionAutoAcceptInFlightVersion, - isPermissionAutoAcceptEnabled, -} from "../../stores/permission-auto-accept" +import { isPermissionAutoAcceptEnabled } from "../../stores/permission-auto-accept" const log = getLogger("session") const OPEN_SESSION_SEARCH_EVENT = "codenomad:open-session-search" @@ -269,8 +263,6 @@ const InstanceShell2: Component = (props) => { return permissions + questions > 0 }) - const permissionQueue = createMemo(() => getPermissionQueue(props.instance.id)) - const activePromptInputApi = createMemo(() => { const sessionId = activeSessionIdForInstance() if (!sessionId || sessionId === "info") return null @@ -284,25 +276,6 @@ const InstanceShell2: Component = (props) => { })) } - createEffect(() => { - getPermissionAutoAcceptInFlightVersion() - - for (const permission of permissionQueue()) { - const sessionId = getPermissionSessionId(permission) - if (!sessionId) continue - if (!permission?.id) continue - if (!canAutoRespondPermission(props.instance.id, sessionId, permission.id)) continue - - void sendPermissionResponse(props.instance.id, sessionId, permission.id, "once") - .catch((error) => { - log.error("Failed to auto-accept permission", error) - }) - .finally(() => { - finishAutoRespondPermission(props.instance.id, sessionId, permission.id) - }) - } - }) - const yoloModeEnabled = createMemo(() => { const session = activeSessionForInstance() if (!session) return false diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx index f618bf03e..7be3284c4 100644 --- a/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx @@ -13,7 +13,8 @@ import type { Session } from "../../../../../types/session" import ContextUsagePanel from "../../../../session/context-usage-panel" import { TodoListView } from "../../../../tool-call/renderers/todo" import InstanceServiceStatus from "../../../../instance-service-status" -import { isPermissionAutoAcceptEnabled, togglePermissionAutoAccept } from "../../../../../stores/permission-auto-accept" +import { togglePermissionAutoAcceptForSession } from "../../../../../stores/instances" +import { isPermissionAutoAcceptEnabled } from "../../../../../stores/permission-auto-accept" interface StatusTabProps { t: (key: string, vars?: Record) => string @@ -63,7 +64,7 @@ const StatusTab: Component = (props) => { color="warning" size="small" inputProps={{ "aria-label": props.t("instanceShell.yoloMode.title") }} - onChange={() => togglePermissionAutoAccept(props.instanceId, session.id)} + onChange={() => togglePermissionAutoAcceptForSession(props.instanceId, session.id)} />
diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index c6b494827..b4d8878d6 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -32,6 +32,7 @@ import { setSessionPendingPermission, setSessionPendingQuestion } from "./sessio import { setHasInstances } from "./ui" import { messageStoreBus } from "./message-v2/bus" import { upsertPermissionV2, removePermissionV2, upsertQuestionV2, removeQuestionV2 } from "./message-v2/bridge" +import { clearAutoAcceptPermission, drainAutoAcceptPermissions, isPermissionAutoAcceptEnabled, togglePermissionAutoAccept } from "./permission-auto-accept" import { clearCacheForInstance } from "../lib/global-cache" import { getLogger } from "../lib/logger" import { mergeInstanceMetadata, clearInstanceMetadata } from "./instance-metadata" @@ -207,6 +208,7 @@ async function syncPendingPermissions(instanceId: string): Promise { addPermissionToQueue(instanceId, permission) upsertPermissionV2(instanceId, permission) } + drainAutoAcceptPermissions(instanceId, getPermissionQueue(instanceId), sendPermissionResponse, hasPendingPermission) } catch (error) { log.warn("Failed to sync pending permissions", { instanceId, error }) } @@ -615,6 +617,10 @@ function getPermissionQueueLength(instanceId: string): number { return getPermissionQueue(instanceId).length } +function hasPendingPermission(instanceId: string, permissionId: string): boolean { + return getPermissionQueue(instanceId).some((permission) => permission.id === permissionId) +} + function getQuestionQueue(instanceId: string): QuestionRequest[] { const queue = questionQueues().get(instanceId) if (!queue) { @@ -796,6 +802,8 @@ function addPermissionToQueue(instanceId: string, permission: PermissionRequestL } byPermissionId.set(permission.id, slug) } + + drainAutoAcceptPermissions(instanceId, [permission], sendPermissionResponse, hasPendingPermission) } function removePermissionFromQueue(instanceId: string, permissionId: string): void { @@ -822,8 +830,6 @@ function removePermissionFromQueue(instanceId: string, permissionId: string): vo return next }) - const updatedQueue = getPermissionQueue(instanceId) - recomputeActiveInterruption(instanceId) const removed = removedPermission @@ -832,13 +838,27 @@ function removePermissionFromQueue(instanceId: string, permissionId: string): vo permissionWorktreeSlugByInstance.get(instanceId)?.delete(permissionId) const removedSessionId = getPermissionSessionId(removed) if (removedSessionId) { + clearAutoAcceptPermission(instanceId, removedSessionId, permissionId) const remaining = decrementSessionPendingCount(instanceId, removedSessionId) setSessionPendingPermission(instanceId, removedSessionId, remaining > 0) } } } +function togglePermissionAutoAcceptForSession(instanceId: string, sessionId: string): void { + const willEnable = !isPermissionAutoAcceptEnabled(instanceId, sessionId) + togglePermissionAutoAccept(instanceId, sessionId) + if (!willEnable) return + drainAutoAcceptPermissions(instanceId, getPermissionQueue(instanceId), sendPermissionResponse, hasPendingPermission) +} + function clearPermissionQueue(instanceId: string): void { + for (const permission of getPermissionQueue(instanceId)) { + const sessionId = getPermissionSessionId(permission) + if (sessionId) { + clearAutoAcceptPermission(instanceId, sessionId, permission.id) + } + } setPermissionQueues((prev) => { const next = new Map(prev) next.delete(instanceId) @@ -1130,6 +1150,7 @@ export { getPermissionQueueLength, addPermissionToQueue, removePermissionFromQueue, + togglePermissionAutoAcceptForSession, clearPermissionQueue, sendPermissionResponse, setActivePermissionIdForInstance, diff --git a/packages/ui/src/stores/permission-auto-accept.ts b/packages/ui/src/stores/permission-auto-accept.ts index 6607d3a8b..1b1ea402c 100644 --- a/packages/ui/src/stores/permission-auto-accept.ts +++ b/packages/ui/src/stores/permission-auto-accept.ts @@ -1,7 +1,15 @@ import { createSignal } from "solid-js" +import type { PermissionReply, PermissionRequestLike } from "../types/permission" +import { getPermissionSessionId } from "../types/permission" +import { getLogger } from "../lib/logger" const STORAGE_KEY = "codenomad:permission-auto-accept:v1" +const log = getLogger("api") + +type AutoAcceptResponder = (instanceId: string, sessionId: string, requestId: string, reply: PermissionReply) => Promise +type PendingPermissionChecker = (instanceId: string, requestId: string) => boolean + function makeKey(instanceId: string, sessionId: string) { return `${instanceId}:${sessionId}` } @@ -34,7 +42,6 @@ function persist(next: Map) { } const [autoAcceptState, setAutoAcceptState] = createSignal(readInitialState()) -const [inFlightVersion, setInFlightVersion] = createSignal(0) const inFlight = new Set() @@ -54,28 +61,65 @@ export function setPermissionAutoAcceptEnabled(instanceId: string, sessionId: st persist(next) return next }) + if (!enabled) { + clearAutoAcceptSession(instanceId, sessionId) + } } export function togglePermissionAutoAccept(instanceId: string, sessionId: string) { setPermissionAutoAcceptEnabled(instanceId, sessionId, !isPermissionAutoAcceptEnabled(instanceId, sessionId)) } -export function canAutoRespondPermission(instanceId: string, sessionId: string, requestId: string) { - const key = makeKey(instanceId, sessionId) - if (!autoAcceptState().get(key)) return false - const requestKey = `${key}:${requestId}` - if (inFlight.has(requestKey)) return false - inFlight.add(requestKey) - return true +function makeRequestKey(instanceId: string, sessionId: string, requestId: string) { + return `${makeKey(instanceId, sessionId)}:${requestId}` } -export function getPermissionAutoAcceptInFlightVersion() { - return inFlightVersion() +export function clearAutoAcceptPermission(instanceId: string, sessionId: string, requestId: string) { + const requestKey = makeRequestKey(instanceId, sessionId, requestId) + inFlight.delete(requestKey) } -export function finishAutoRespondPermission(instanceId: string, sessionId: string, requestId: string) { - if (!inFlight.delete(`${makeKey(instanceId, sessionId)}:${requestId}`)) { - return +export function clearAutoAcceptSession(instanceId: string, sessionId: string) { + const prefix = `${makeKey(instanceId, sessionId)}:` + for (const requestKey of Array.from(inFlight)) { + if (requestKey.startsWith(prefix)) { + inFlight.delete(requestKey) + } + } +} + +export function drainAutoAcceptPermission( + instanceId: string, + permission: PermissionRequestLike, + responder: AutoAcceptResponder, + isPending: PendingPermissionChecker, +) { + const sessionId = getPermissionSessionId(permission) + if (!sessionId || !permission?.id) return + if (!isPermissionAutoAcceptEnabled(instanceId, sessionId)) return + if (!isPending(instanceId, permission.id)) return + + const requestKey = makeRequestKey(instanceId, sessionId, permission.id) + if (inFlight.has(requestKey)) return + + inFlight.add(requestKey) + + void responder(instanceId, sessionId, permission.id, "once") + .catch((error) => { + log.error("Failed to auto-accept permission", error) + }) + .finally(() => { + inFlight.delete(requestKey) + }) +} + +export function drainAutoAcceptPermissions( + instanceId: string, + permissions: PermissionRequestLike[], + responder: AutoAcceptResponder, + isPending: PendingPermissionChecker, +) { + for (const permission of permissions) { + drainAutoAcceptPermission(instanceId, permission, responder, isPending) } - setInFlightVersion((value) => value + 1) } From 4090989d12a67911db824bd51580bb3443276ace Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 10 May 2026 18:11:07 +0200 Subject: [PATCH 12/32] fix(ui): ignore stale permission events after reply (#425) ## Summary - Split out from #422 as the stale-permission-events fix. - Clears permission UI state immediately after a successful local permission reply instead of waiting for `permission.replied` SSE. - Tracks replied permission IDs until a newer pending-permission sync observes that the server no longer reports them pending. - Marks SSE `permission.replied` events into the same replied-ID path so delayed pending events/sync results cannot resurrect prompts that were just answered. ## Why A permission prompt could remain on screen after clicking Allow/Deny, or clicking Allow could look like it did not take effect and require another click. Reloading could fix the UI, which pointed to stale local permission state rather than the server still waiting. The UI receives permission state from local replies, SSE events, and pending-permission sync. If an older pending event or sync result is processed after a confirmed reply, the UI can re-add a permission that was already answered. Replied IDs stay suppressed until a sync started after the local reply proves the server has dropped that permission from the pending list. ## Validation - `git diff --check` - `node --test packages/ui/src/stores/permission-replies.test.ts` - `npm run typecheck --workspace @codenomad/ui` --- packages/ui/src/stores/instances.ts | 24 ++++++++-- .../ui/src/stores/permission-replies.test.ts | 44 +++++++++++++++++++ packages/ui/src/stores/permission-replies.ts | 38 ++++++++++++++++ packages/ui/src/stores/session-events.ts | 10 ++++- 4 files changed, 112 insertions(+), 4 deletions(-) create mode 100644 packages/ui/src/stores/permission-replies.test.ts create mode 100644 packages/ui/src/stores/permission-replies.ts diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index b4d8878d6..168a9a1ca 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -32,6 +32,12 @@ import { setSessionPendingPermission, setSessionPendingQuestion } from "./sessio import { setHasInstances } from "./ui" import { messageStoreBus } from "./message-v2/bus" import { upsertPermissionV2, removePermissionV2, upsertQuestionV2, removeQuestionV2 } from "./message-v2/bridge" +import { + clearRepliedPermissions, + hasRepliedPermission, + markPermissionReplied, + pruneRepliedPermissions, +} from "./permission-replies" import { clearAutoAcceptPermission, drainAutoAcceptPermissions, isPermissionAutoAcceptEnabled, togglePermissionAutoAccept } from "./permission-auto-accept" import { clearCacheForInstance } from "../lib/global-cache" import { getLogger } from "../lib/logger" @@ -79,6 +85,7 @@ function syncHasInstancesFlag() { const readyExists = Array.from(instances().values()).some((instance) => instance.status === "ready") setHasInstances(readyExists) } + interface DisconnectedInstanceInfo { id: string folder: string @@ -187,12 +194,17 @@ async function syncPendingPermissions(instanceId: string): Promise { if (!instance?.client) return try { + const syncStartedAt = Date.now() const remote = await requestData( instance.client.permission.list(), "permission.list", ) - const remoteIds = new Set(remote.map((item) => item.id)) + const remotePendingIds = new Set(remote.map((item) => item.id)) + pruneRepliedPermissions(instanceId, remotePendingIds, syncStartedAt) + + const pendingRemote = remote.filter((item) => !hasRepliedPermission(instanceId, item.id)) + const remoteIds = new Set(pendingRemote.map((item) => item.id)) const local = getPermissionQueue(instanceId) // Remove any stale local permissions missing from server. @@ -204,7 +216,7 @@ async function syncPendingPermissions(instanceId: string): Promise { } // Upsert all server-side pending permissions. - for (const permission of remote) { + for (const permission of pendingRemote) { addPermissionToQueue(instanceId, permission) upsertPermissionV2(instanceId, permission) } @@ -514,6 +526,7 @@ function removeInstance(id: string) { removeLogContainer(id) clearCommands(id) clearPermissionQueue(id) + clearRepliedPermissions(id) clearQuestionQueue(id) clearInstanceMetadata(id) @@ -1054,8 +1067,11 @@ async function sendPermissionResponse( "permission.reply", ) - // Remove from queue after successful response + markPermissionReplied(instanceId, requestId) + // Remove from both local queues after successful response; the SSE replied event + // is still accepted, but the UI no longer depends on receiving it. removePermissionFromQueue(instanceId, requestId) + removePermissionV2(instanceId, requestId) } catch (error) { log.error("Failed to send permission response", error) throw error @@ -1150,6 +1166,8 @@ export { getPermissionQueueLength, addPermissionToQueue, removePermissionFromQueue, + markPermissionReplied, + hasRepliedPermission, togglePermissionAutoAcceptForSession, clearPermissionQueue, sendPermissionResponse, diff --git a/packages/ui/src/stores/permission-replies.test.ts b/packages/ui/src/stores/permission-replies.test.ts new file mode 100644 index 000000000..1079b7207 --- /dev/null +++ b/packages/ui/src/stores/permission-replies.test.ts @@ -0,0 +1,44 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { + clearRepliedPermissions, + hasRepliedPermission, + markPermissionReplied, + pruneRepliedPermissions, +} from "./permission-replies.ts" + +describe("replied permission tracking", () => { + it("keeps replied ids when an older sync does not include them", () => { + const instanceId = "instance-old-sync" + const permissionId = "permission-1" + + markPermissionReplied(instanceId, permissionId, 1_000) + pruneRepliedPermissions(instanceId, new Set(), 900) + + assert.equal(hasRepliedPermission(instanceId, permissionId), true) + clearRepliedPermissions(instanceId) + }) + + it("keeps replied ids while the server still reports them pending", () => { + const instanceId = "instance-still-pending" + const permissionId = "permission-1" + + markPermissionReplied(instanceId, permissionId, 1_000) + pruneRepliedPermissions(instanceId, new Set([permissionId]), 1_100) + + assert.equal(hasRepliedPermission(instanceId, permissionId), true) + clearRepliedPermissions(instanceId) + }) + + it("clears replied ids once a newer sync observes them missing", () => { + const instanceId = "instance-new-sync" + const permissionId = "permission-1" + + markPermissionReplied(instanceId, permissionId, 1_000) + pruneRepliedPermissions(instanceId, new Set(), 1_100) + + assert.equal(hasRepliedPermission(instanceId, permissionId), false) + clearRepliedPermissions(instanceId) + }) +}) diff --git a/packages/ui/src/stores/permission-replies.ts b/packages/ui/src/stores/permission-replies.ts new file mode 100644 index 000000000..c354c63b9 --- /dev/null +++ b/packages/ui/src/stores/permission-replies.ts @@ -0,0 +1,38 @@ +const repliedPermissionIdsByInstance = new Map>() + +function pruneRepliedPermissions(instanceId: string, remotePendingIds: Set, syncStartedAt: number): void { + const replied = repliedPermissionIdsByInstance.get(instanceId) + if (!replied) return + for (const [permissionId, repliedAt] of replied) { + // Only a sync started after the local reply can prove the server no longer + // considers this permission pending. + if (!remotePendingIds.has(permissionId) && syncStartedAt >= repliedAt) { + replied.delete(permissionId) + } + } + if (replied.size === 0) { + repliedPermissionIdsByInstance.delete(instanceId) + } +} + +function markPermissionReplied(instanceId: string, permissionId: string, repliedAt = Date.now()): void { + if (!permissionId) return + let replied = repliedPermissionIdsByInstance.get(instanceId) + if (!replied) { + replied = new Map() + repliedPermissionIdsByInstance.set(instanceId, replied) + } + replied.set(permissionId, repliedAt) +} + +function hasRepliedPermission(instanceId: string, permissionId: string): boolean { + const replied = repliedPermissionIdsByInstance.get(instanceId) + if (!replied) return false + return replied.has(permissionId) +} + +function clearRepliedPermissions(instanceId: string): void { + repliedPermissionIdsByInstance.delete(instanceId) +} + +export { clearRepliedPermissions, hasRepliedPermission, markPermissionReplied, pruneRepliedPermissions } diff --git a/packages/ui/src/stores/session-events.ts b/packages/ui/src/stores/session-events.ts index 8034d0dcf..5f3a480d0 100644 --- a/packages/ui/src/stores/session-events.ts +++ b/packages/ui/src/stores/session-events.ts @@ -35,6 +35,8 @@ import { instances, addPermissionToQueue, removePermissionFromQueue, + markPermissionReplied, + hasRepliedPermission, addQuestionToQueue, removeQuestionFromQueue, } from "./instances" @@ -689,8 +691,13 @@ function handleTuiToast(_instanceId: string, event: TuiToastEvent): void { function handlePermissionUpdated(instanceId: string, event: { type: string; properties?: PermissionRequestLike } | any): void { const permission = event?.properties as PermissionRequestLike | undefined if (!permission) return + const permissionId = getPermissionId(permission) + if (permissionId && hasRepliedPermission(instanceId, permissionId)) { + log.info(`[SSE] Ignoring stale permission request after local reply: ${permissionId}`) + return + } - log.info(`[SSE] Permission request: ${getPermissionId(permission)} (${getPermissionKind(permission)})`) + log.info(`[SSE] Permission request: ${permissionId} (${getPermissionKind(permission)})`) addPermissionToQueue(instanceId, permission) upsertPermissionV2(instanceId, permission) @@ -710,6 +717,7 @@ function handlePermissionReplied(instanceId: string, event: { type: string; prop if (!requestId) return log.info(`[SSE] Permission replied: ${requestId}`) + markPermissionReplied(instanceId, requestId) removePermissionFromQueue(instanceId, requestId) removePermissionV2(instanceId, requestId) } From 342531090e2bebdb5a17c33f1ec41cf89dbe4d91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 10 May 2026 18:44:43 +0200 Subject: [PATCH 13/32] feat(ui): make message timeline hideable (#428) ## Summary - Add a behavior setting to show or hide the message timeline sidebar. - Remove the timeline layout gutter when the sidebar is disabled. - Add setting labels for all supported locales. ## Why user wants a more minimal workspace and a little more horizontal room without losing the default timeline experience for everyone else. ## Validation - npm run typecheck --workspace @codenomad/ui - git diff --check - npm run build:tauri, then launched the raw release exe Fixes #418 --- packages/ui/src/App.tsx | 2 ++ .../ui/src/components/message-section.tsx | 7 +++++-- .../settings/appearance-settings-section.tsx | 2 ++ packages/ui/src/lib/hooks/use-commands.ts | 2 ++ .../ui/src/lib/i18n/messages/en/settings.ts | 2 ++ .../ui/src/lib/i18n/messages/es/settings.ts | 2 ++ .../ui/src/lib/i18n/messages/fr/settings.ts | 2 ++ .../ui/src/lib/i18n/messages/he/settings.ts | 2 ++ .../ui/src/lib/i18n/messages/ja/settings.ts | 2 ++ .../ui/src/lib/i18n/messages/ru/settings.ts | 2 ++ .../src/lib/i18n/messages/zh-Hans/settings.ts | 2 ++ .../ui/src/lib/settings/behavior-registry.ts | 19 +++++++++++++++++++ packages/ui/src/stores/preferences.tsx | 9 +++++++++ 13 files changed, 53 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 774d16b84..d32a3a805 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -80,6 +80,7 @@ const App: Component = () => { recordWorkspaceLaunch, toggleShowThinkingBlocks, toggleKeyboardShortcutHints, + toggleShowMessageTimeline, toggleShowTimelineTools, toggleAutoCleanupBlankSessions, toggleUsageMetrics, @@ -411,6 +412,7 @@ const App: Component = () => { toggleAutoCleanupBlankSessions, toggleShowThinkingBlocks, toggleKeyboardShortcutHints, + toggleShowMessageTimeline, toggleShowTimelineTools, toggleUsageMetrics, togglePromptSubmitOnEnter, diff --git a/packages/ui/src/components/message-section.tsx b/packages/ui/src/components/message-section.tsx index f2cc7abbb..c2f3bebb6 100644 --- a/packages/ui/src/components/message-section.tsx +++ b/packages/ui/src/components/message-section.tsx @@ -50,6 +50,7 @@ export default function MessageSection(props: MessageSectionProps) { const { preferences, updatePreferences } = useConfig() const { t } = useI18n() const showUsagePreference = () => preferences().showUsageMetrics ?? true + const showMessageTimelinePreference = () => preferences().showMessageTimeline ?? true const showTimelineToolsPreference = () => preferences().showTimelineTools ?? true const holdLongAssistantRepliesEnabled = () => preferences().holdLongAssistantReplies ?? true const store = createMemo(() => messageStoreBus.getOrCreate(props.instanceId)) @@ -1226,6 +1227,8 @@ export default function MessageSection(props: MessageSectionProps) { clearQuoteSelection() }) + const showTimeline = createMemo(() => showMessageTimelinePreference() && hasTimelineSegments()) + return (
- +
{ updatePreferences, toggleShowThinkingBlocks, toggleKeyboardShortcutHints, + toggleShowMessageTimeline, toggleShowTimelineTools, toggleUsageMetrics, toggleAutoCleanupBlankSessions, @@ -38,6 +39,7 @@ export const AppearanceSettingsSection: Component = () => { updatePreferences, toggleShowThinkingBlocks, toggleKeyboardShortcutHints, + toggleShowMessageTimeline, toggleShowTimelineTools, toggleUsageMetrics, toggleAutoCleanupBlankSessions, diff --git a/packages/ui/src/lib/hooks/use-commands.ts b/packages/ui/src/lib/hooks/use-commands.ts index d6a7d6702..160cd87ff 100644 --- a/packages/ui/src/lib/hooks/use-commands.ts +++ b/packages/ui/src/lib/hooks/use-commands.ts @@ -31,6 +31,7 @@ export interface UseCommandsOptions { preferences: Accessor toggleShowThinkingBlocks: () => void toggleKeyboardShortcutHints: () => void + toggleShowMessageTimeline: () => void toggleShowTimelineTools: () => void toggleUsageMetrics: () => void toggleAutoCleanupBlankSessions: () => void @@ -420,6 +421,7 @@ export function useCommands(options: UseCommandsOptions) { preferences: options.preferences, toggleShowThinkingBlocks: options.toggleShowThinkingBlocks, toggleKeyboardShortcutHints: options.toggleKeyboardShortcutHints, + toggleShowMessageTimeline: options.toggleShowMessageTimeline, toggleShowTimelineTools: options.toggleShowTimelineTools, toggleUsageMetrics: options.toggleUsageMetrics, toggleAutoCleanupBlankSessions: options.toggleAutoCleanupBlankSessions, diff --git a/packages/ui/src/lib/i18n/messages/en/settings.ts b/packages/ui/src/lib/i18n/messages/en/settings.ts index 18aa670da..35f3999c0 100644 --- a/packages/ui/src/lib/i18n/messages/en/settings.ts +++ b/packages/ui/src/lib/i18n/messages/en/settings.ts @@ -135,6 +135,8 @@ export const settingsMessages = { "settings.behavior.thinking.subtitle": "Show or hide AI thinking sections in messages.", "settings.behavior.thinkingDefault.title": "Thinking default", "settings.behavior.thinkingDefault.subtitle": "Choose whether thinking sections start expanded or collapsed.", + "settings.behavior.messageTimeline.title": "Message timeline", + "settings.behavior.messageTimeline.subtitle": "Show or hide the message timeline sidebar.", "settings.behavior.timelineTools.title": "Timeline tool calls", "settings.behavior.timelineTools.subtitle": "Show or hide tool call entries in the message timeline.", "settings.behavior.diffView.title": "Diff view", diff --git a/packages/ui/src/lib/i18n/messages/es/settings.ts b/packages/ui/src/lib/i18n/messages/es/settings.ts index fc270bff7..a158ec511 100644 --- a/packages/ui/src/lib/i18n/messages/es/settings.ts +++ b/packages/ui/src/lib/i18n/messages/es/settings.ts @@ -134,6 +134,8 @@ export const settingsMessages = { "settings.behavior.thinking.subtitle": "Muestra u oculta las secciones de pensamiento de la IA en los mensajes.", "settings.behavior.thinkingDefault.title": "Pensamiento por defecto", "settings.behavior.thinkingDefault.subtitle": "Elige si las secciones de pensamiento comienzan expandidas o contraidas.", + "settings.behavior.messageTimeline.title": "Linea de tiempo de mensajes", + "settings.behavior.messageTimeline.subtitle": "Muestra u oculta la barra lateral de la linea de tiempo de mensajes.", "settings.behavior.timelineTools.title": "Llamadas de herramientas en la linea de tiempo", "settings.behavior.timelineTools.subtitle": "Muestra u oculta entradas de llamadas de herramientas en la linea de tiempo de mensajes.", "settings.behavior.diffView.title": "Vista de diferencias", diff --git a/packages/ui/src/lib/i18n/messages/fr/settings.ts b/packages/ui/src/lib/i18n/messages/fr/settings.ts index 44a3057a1..722292878 100644 --- a/packages/ui/src/lib/i18n/messages/fr/settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr/settings.ts @@ -134,6 +134,8 @@ export const settingsMessages = { "settings.behavior.thinking.subtitle": "Afficher ou masquer les sections de reflexion de l'IA dans les messages.", "settings.behavior.thinkingDefault.title": "Etat initial de la reflexion", "settings.behavior.thinkingDefault.subtitle": "Choisir si les sections de reflexion commencent developpees ou reduites.", + "settings.behavior.messageTimeline.title": "Chronologie des messages", + "settings.behavior.messageTimeline.subtitle": "Afficher ou masquer la barre laterale de chronologie des messages.", "settings.behavior.timelineTools.title": "Appels d'outils dans la chronologie", "settings.behavior.timelineTools.subtitle": "Afficher ou masquer les entrees d'appels d'outils dans la chronologie des messages.", "settings.behavior.diffView.title": "Vue du diff", diff --git a/packages/ui/src/lib/i18n/messages/he/settings.ts b/packages/ui/src/lib/i18n/messages/he/settings.ts index a9e840b73..cc8b59044 100644 --- a/packages/ui/src/lib/i18n/messages/he/settings.ts +++ b/packages/ui/src/lib/i18n/messages/he/settings.ts @@ -133,6 +133,8 @@ export const settingsMessages = { "settings.behavior.thinking.subtitle": "הצג או הסתר קטעי חשיבה של ה-AI בהודעות.", "settings.behavior.thinkingDefault.title": "ברירת מחדל לחשיבה", "settings.behavior.thinkingDefault.subtitle": "בחר האם קטעי חשיבה מתחילים פרוסים או מכווצים.", + "settings.behavior.messageTimeline.title": "ציר זמן הודעות", + "settings.behavior.messageTimeline.subtitle": "הצג או הסתר את סרגל הצד של ציר זמן ההודעות.", "settings.behavior.timelineTools.title": "קריאות כלי בציר הזמן", "settings.behavior.timelineTools.subtitle": "הצג או הסתר קריאות כלי בציר הודעות.", "settings.behavior.diffView.title": "תצוגת diff", diff --git a/packages/ui/src/lib/i18n/messages/ja/settings.ts b/packages/ui/src/lib/i18n/messages/ja/settings.ts index 267120a0d..f764acbfc 100644 --- a/packages/ui/src/lib/i18n/messages/ja/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja/settings.ts @@ -134,6 +134,8 @@ export const settingsMessages = { "settings.behavior.thinking.subtitle": "メッセージ内のAIの思考セクションを表示/非表示にします。", "settings.behavior.thinkingDefault.title": "思考の既定", "settings.behavior.thinkingDefault.subtitle": "思考セクションを最初に展開/折りたたみのどちらで表示するかを選びます。", + "settings.behavior.messageTimeline.title": "メッセージタイムライン", + "settings.behavior.messageTimeline.subtitle": "メッセージタイムラインのサイドバーを表示/非表示にします。", "settings.behavior.timelineTools.title": "タイムラインのツール呼び出し", "settings.behavior.timelineTools.subtitle": "メッセージタイムラインでツール呼び出しを表示/非表示にします。", "settings.behavior.diffView.title": "差分表示", diff --git a/packages/ui/src/lib/i18n/messages/ru/settings.ts b/packages/ui/src/lib/i18n/messages/ru/settings.ts index 49ff80a61..2b9dc2f2b 100644 --- a/packages/ui/src/lib/i18n/messages/ru/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ru/settings.ts @@ -134,6 +134,8 @@ export const settingsMessages = { "settings.behavior.thinking.subtitle": "Показывать или скрывать разделы размышлений ИИ в сообщениях.", "settings.behavior.thinkingDefault.title": "Размышления по умолчанию", "settings.behavior.thinkingDefault.subtitle": "Выберите, начинать ли разделы размышлений развернутыми или свернутыми.", + "settings.behavior.messageTimeline.title": "Таймлайн сообщений", + "settings.behavior.messageTimeline.subtitle": "Показывать или скрывать боковую панель таймлайна сообщений.", "settings.behavior.timelineTools.title": "Вызовы инструментов в таймлайне", "settings.behavior.timelineTools.subtitle": "Показывать или скрывать записи вызовов инструментов в таймлайне сообщений.", "settings.behavior.diffView.title": "Вид диффа", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts index 8e469e1ec..beae0a580 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts @@ -134,6 +134,8 @@ export const settingsMessages = { "settings.behavior.thinking.subtitle": "在消息中显示或隐藏AI的思考区块。", "settings.behavior.thinkingDefault.title": "思考默认状态", "settings.behavior.thinkingDefault.subtitle": "选择思考区块默认是展开还是折叠。", + "settings.behavior.messageTimeline.title": "消息时间线", + "settings.behavior.messageTimeline.subtitle": "显示或隐藏消息时间线侧边栏。", "settings.behavior.timelineTools.title": "时间线工具调用", "settings.behavior.timelineTools.subtitle": "在消息时间线中显示或隐藏工具调用条目。", "settings.behavior.diffView.title": "差异视图", diff --git a/packages/ui/src/lib/settings/behavior-registry.ts b/packages/ui/src/lib/settings/behavior-registry.ts index 4b1abd965..25bd13e4b 100644 --- a/packages/ui/src/lib/settings/behavior-registry.ts +++ b/packages/ui/src/lib/settings/behavior-registry.ts @@ -38,6 +38,7 @@ export type BehaviorRegistryActions = { updatePreferences?: (updates: Partial) => void toggleShowThinkingBlocks: () => void toggleKeyboardShortcutHints: () => void + toggleShowMessageTimeline: () => void toggleShowTimelineTools: () => void toggleUsageMetrics: () => void toggleAutoCleanupBlankSessions: () => void @@ -122,6 +123,24 @@ export function getBehaviorSettings(actions: BehaviorRegistryActions): BehaviorS { value: "collapsed", labelKey: "commands.common.collapsed" }, ], }, + { + kind: "toggle", + id: "behavior.messageTimeline", + titleKey: "settings.behavior.messageTimeline.title", + subtitleKey: "settings.behavior.messageTimeline.subtitle", + get: (p) => Boolean(p.showMessageTimeline ?? true), + set: (next) => { + if (updatePreferences) { + updatePreferences({ showMessageTimeline: next }) + return + } + setBooleanByToggle( + () => Boolean(prefs().showMessageTimeline ?? true), + actions.toggleShowMessageTimeline, + next, + ) + }, + }, { kind: "toggle", id: "behavior.timelineToolCalls", diff --git a/packages/ui/src/stores/preferences.tsx b/packages/ui/src/stores/preferences.tsx index 7fb9310e7..a39b07969 100644 --- a/packages/ui/src/stores/preferences.tsx +++ b/packages/ui/src/stores/preferences.tsx @@ -54,6 +54,7 @@ export interface UiSettings { showThinkingBlocks: boolean showKeyboardShortcutHints: boolean thinkingBlocksExpansion: ExpansionPreference + showMessageTimeline: boolean showTimelineTools: boolean holdLongAssistantReplies: boolean promptSubmitOnEnter: boolean @@ -134,6 +135,7 @@ const defaultUiSettings: UiSettings = { showThinkingBlocks: false, showKeyboardShortcutHints: true, thinkingBlocksExpansion: "expanded", + showMessageTimeline: true, showTimelineTools: true, holdLongAssistantReplies: true, promptSubmitOnEnter: false, @@ -169,6 +171,7 @@ function normalizeUiSettings(input?: Partial | null): UiSettings { showKeyboardShortcutHints: sanitized.showKeyboardShortcutHints ?? defaultUiSettings.showKeyboardShortcutHints, thinkingBlocksExpansion: sanitized.thinkingBlocksExpansion ?? defaultUiSettings.thinkingBlocksExpansion, + showMessageTimeline: sanitized.showMessageTimeline ?? defaultUiSettings.showMessageTimeline, showTimelineTools: sanitized.showTimelineTools ?? defaultUiSettings.showTimelineTools, holdLongAssistantReplies: sanitized.holdLongAssistantReplies ?? defaultUiSettings.holdLongAssistantReplies, promptSubmitOnEnter: sanitized.promptSubmitOnEnter ?? defaultUiSettings.promptSubmitOnEnter, @@ -661,6 +664,10 @@ function toggleShowTimelineTools(): void { updateUiSettings({ showTimelineTools: !preferences().showTimelineTools }) } +function toggleShowMessageTimeline(): void { + updateUiSettings({ showMessageTimeline: !(preferences().showMessageTimeline ?? true) }) +} + function toggleUsageMetrics(): void { updateUiSettings({ showUsageMetrics: !preferences().showUsageMetrics }) } @@ -743,6 +750,7 @@ interface ConfigContextValue { // ui settings helpers toggleShowThinkingBlocks: typeof toggleShowThinkingBlocks toggleKeyboardShortcutHints: typeof toggleKeyboardShortcutHints + toggleShowMessageTimeline: typeof toggleShowMessageTimeline toggleShowTimelineTools: typeof toggleShowTimelineTools toggleUsageMetrics: typeof toggleUsageMetrics toggleAutoCleanupBlankSessions: typeof toggleAutoCleanupBlankSessions @@ -794,6 +802,7 @@ const configContextValue: ConfigContextValue = { setModelThinkingSelection, toggleShowThinkingBlocks, toggleKeyboardShortcutHints, + toggleShowMessageTimeline, toggleShowTimelineTools, toggleUsageMetrics, toggleAutoCleanupBlankSessions, From e0194ece050466637b4a05733122805afad03cca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 10 May 2026 18:54:21 +0200 Subject: [PATCH 14/32] fix(ui): reconcile permission tool attachments (#426) ## Summary - Split out from #422 as the permission/tool-call reconciliation fix. - Reconciles pending permissions whenever live tool parts update, matching the existing question re-link path. - Keeps one message-v2 attachment per server permission ID and recalculates the active permission from queue order. - Conservatively merges duplicate or out-of-order permission updates so known session/message/tool routing metadata is not lost. - Fixes #290 ## Why The observed failure shape is that permission prompts can appear missing, frozen, or attached in unexpected places when permission events and tool-call parts are observed in different orders. In those cases, the server-side permission may exist, but the UI can temporarily attach it globally, attach it to the wrong tool location, or fail to move it when the matching tool part arrives later. This PR focuses on the UI-side attachment/order problem: one UI attachment per server permission ID, re-linking permissions when tool parts arrive, and preserving known routing metadata across duplicate/out-of-order updates. It does not attempt semantic deduplication across different permission IDs that happen to ask for the same logical approval. ## Validation - `git diff --check` - `npm exec --no -- tsx --test packages/ui/src/types/permission.test.ts packages/ui/src/stores/message-v2/instance-store.test.ts` - `node --test packages/ui/src/stores/permission-replies.test.ts` - `npm run typecheck --workspace @codenomad/ui` --- packages/ui/src/stores/instances.ts | 48 ++++++++++++------ packages/ui/src/stores/message-v2/bridge.ts | 7 ++- .../stores/message-v2/instance-store.test.ts | 37 ++++++++++++++ .../src/stores/message-v2/instance-store.ts | 33 +++++++++++-- packages/ui/src/stores/session-events.ts | 10 ++-- packages/ui/src/types/permission.test.ts | 49 +++++++++++++++++++ packages/ui/src/types/permission.ts | 49 +++++++++++++++++++ 7 files changed, 209 insertions(+), 24 deletions(-) create mode 100644 packages/ui/src/stores/message-v2/instance-store.test.ts create mode 100644 packages/ui/src/types/permission.test.ts diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index 168a9a1ca..110c39f55 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -2,7 +2,7 @@ import { createSignal } from "solid-js" import type { Instance, LogEntry } from "../types/instance" import type { LspStatus } from "@opencode-ai/sdk/v2" import type { PermissionReply, PermissionRequestLike } from "../types/permission" -import { getPermissionCreatedAt, getPermissionSessionId } from "../types/permission" +import { getPermissionCreatedAt, getPermissionSessionId, mergePermissionRequest } from "../types/permission" import type { QuestionRequest } from "@opencode-ai/sdk/v2" import { getQuestionSessionId } from "../types/question" import { requestData } from "../lib/opencode-api" @@ -217,8 +217,8 @@ async function syncPendingPermissions(instanceId: string): Promise { // Upsert all server-side pending permissions. for (const permission of pendingRemote) { - addPermissionToQueue(instanceId, permission) - upsertPermissionV2(instanceId, permission) + const queuedPermission = addPermissionToQueue(instanceId, permission) ?? permission + upsertPermissionV2(instanceId, queuedPermission) } drainAutoAcceptPermissions(instanceId, getPermissionQueue(instanceId), sendPermissionResponse, hasPendingPermission) } catch (error) { @@ -777,46 +777,64 @@ function clearQuestionSessionPendingCounts(instanceId: string): void { questionSessionCounts.delete(instanceId) } -function addPermissionToQueue(instanceId: string, permission: PermissionRequestLike): void { +function addPermissionToQueue(instanceId: string, permission: PermissionRequestLike): PermissionRequestLike | undefined { let inserted = false + let updated = false + let previousPermission: PermissionRequestLike | undefined + let queuedPermission = permission setPermissionQueues((prev) => { const next = new Map(prev) const queue = next.get(instanceId) ?? [] - - if (queue.some((p) => p.id === permission.id)) { + const existingIndex = queue.findIndex((p) => p.id === permission.id) + + if (existingIndex !== -1) { + previousPermission = queue[existingIndex] + queuedPermission = mergePermissionRequest(previousPermission, permission) + const updatedQueue = queue.slice() + updatedQueue[existingIndex] = queuedPermission + next.set(instanceId, updatedQueue.sort((a, b) => getPermissionCreatedAt(a) - getPermissionCreatedAt(b))) + updated = true return next } - const updatedQueue = [...queue, permission].sort((a, b) => getPermissionCreatedAt(a) - getPermissionCreatedAt(b)) + const updatedQueue = [...queue, queuedPermission].sort((a, b) => getPermissionCreatedAt(a) - getPermissionCreatedAt(b)) next.set(instanceId, updatedQueue) inserted = true return next }) - if (!inserted) { - return + if (!inserted && !updated) { + return undefined } recomputeActiveInterruption(instanceId) - const sessionId = getPermissionSessionId(permission) + const previousSessionId = previousPermission ? getPermissionSessionId(previousPermission) : undefined + const sessionId = getPermissionSessionId(queuedPermission) + if (previousSessionId && previousSessionId !== sessionId) { + const remaining = decrementSessionPendingCount(instanceId, previousSessionId) + setSessionPendingPermission(instanceId, previousSessionId, remaining > 0) + } + if (sessionId) { - incrementSessionPendingCount(instanceId, sessionId) + if (inserted || previousSessionId !== sessionId) { + incrementSessionPendingCount(instanceId, sessionId) + } setSessionPendingPermission(instanceId, sessionId, true) - // Record the worktree slug at the time the permission is enqueued. - // This is used to respond in the same worktree context even from the global permission center. + // Refresh this when duplicate permission events carry better session/worktree hydration. const slug = getWorktreeSlugForSession(instanceId, sessionId) let byPermissionId = permissionWorktreeSlugByInstance.get(instanceId) if (!byPermissionId) { byPermissionId = new Map() permissionWorktreeSlugByInstance.set(instanceId, byPermissionId) } - byPermissionId.set(permission.id, slug) + byPermissionId.set(queuedPermission.id, slug) } - drainAutoAcceptPermissions(instanceId, [permission], sendPermissionResponse, hasPendingPermission) + drainAutoAcceptPermissions(instanceId, [queuedPermission], sendPermissionResponse, hasPendingPermission) + return queuedPermission } function removePermissionFromQueue(instanceId: string, permissionId: string): void { diff --git a/packages/ui/src/stores/message-v2/bridge.ts b/packages/ui/src/stores/message-v2/bridge.ts index 7b1b35bec..ef62f3d7d 100644 --- a/packages/ui/src/stores/message-v2/bridge.ts +++ b/packages/ui/src/stores/message-v2/bridge.ts @@ -189,7 +189,7 @@ export function reconcilePendingPermissionsV2(instanceId: string, sessionId?: st if (!pending || pending.length === 0) return for (const entry of pending) { - if (!entry || entry.partId) continue + if (!entry) continue const permission = entry.permission if (!permission) continue @@ -201,6 +201,11 @@ export function reconcilePendingPermissionsV2(instanceId: string, sessionId?: st const messageId = entry.messageId ?? extractPermissionMessageId(permission) const callId = extractPermissionCallId(permission) const resolvedPartId = resolvePartIdFromCallId(store, messageId, callId) + if (entry.partId && messageId && store.getPermissionState(messageId, entry.partId)) { + if (!resolvedPartId || resolvedPartId === entry.partId) { + continue + } + } if (!resolvedPartId) continue store.upsertPermission({ diff --git a/packages/ui/src/stores/message-v2/instance-store.test.ts b/packages/ui/src/stores/message-v2/instance-store.test.ts new file mode 100644 index 000000000..ee9f36e0d --- /dev/null +++ b/packages/ui/src/stores/message-v2/instance-store.test.ts @@ -0,0 +1,37 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { createInstanceMessageStore } from "./instance-store.ts" + +describe("message-v2 permission state", () => { + it("keeps one permission attachment when a duplicate moves from global to a tool part", () => { + const store = createInstanceMessageStore("instance-1") + + store.upsertPermission({ + permission: { id: "permission-1", callID: "call-1", time: { created: 1_000 } }, + enqueuedAt: 1_000, + }) + store.upsertPermission({ + permission: { id: "permission-1", tool: { callID: "call-1", messageID: "message-1" } }, + messageId: "message-1", + partId: "part-1", + enqueuedAt: 2_000, + }) + + assert.equal(store.state.permissions.queue.length, 1) + assert.equal(store.getPermissionState(undefined, "permission-1"), null) + assert.equal(store.getPermissionState("message-1", "part-1")?.entry.permission.callID, "call-1") + assert.equal(store.getPermissionState("message-1", "part-1")?.active, true) + }) + + it("recalculates the active permission after removing the first queue entry", () => { + const store = createInstanceMessageStore("instance-1") + + store.upsertPermission({ permission: { id: "permission-1" }, enqueuedAt: 1_000 }) + store.upsertPermission({ permission: { id: "permission-2" }, enqueuedAt: 2_000 }) + store.removePermission("permission-1") + + assert.equal(store.state.permissions.active?.permission.id, "permission-2") + assert.equal(store.getPermissionState(undefined, "permission-2")?.active, true) + }) +}) diff --git a/packages/ui/src/stores/message-v2/instance-store.ts b/packages/ui/src/stores/message-v2/instance-store.ts index ae2a3b3ce..3373a4cdf 100644 --- a/packages/ui/src/stores/message-v2/instance-store.ts +++ b/packages/ui/src/stores/message-v2/instance-store.ts @@ -3,6 +3,8 @@ import { createStore, produce, reconcile } from "solid-js/store" import type { SetStoreFunction } from "solid-js/store" import { getLogger } from "../../lib/logger" import type { ClientPart, MessageInfo } from "../../types/message" +import type { PermissionRequestLike } from "../../types/permission" +import { mergePermissionRequest } from "../../types/permission" import { clearRecordDisplayCacheForMessages } from "./record-display-cache" import type { InstanceMessageState, @@ -904,13 +906,37 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt return messageInfoCache.get(messageId) } - function upsertPermission(entry: PermissionEntry) { + function mergePermissionEntry(entry: PermissionEntry): PermissionEntry { + const existing = state.permissions.queue.find((item) => item.permission.id === entry.permission.id) + if (!existing) return entry + return { + ...entry, + permission: mergePermissionRequest(existing.permission, entry.permission), + messageId: entry.messageId ?? existing.messageId, + partId: entry.partId ?? existing.partId, + enqueuedAt: Math.min(existing.enqueuedAt, entry.enqueuedAt), + } + } + + function upsertPermission(input: PermissionEntry) { + const entry = mergePermissionEntry(input) const messageKey = entry.messageId ?? "__global__" const partKey = entry.partId ?? entry.permission?.id ?? "__global__" setState( "permissions", produce((draft) => { + Object.keys(draft.byMessage).forEach((existingMessageKey) => { + const partEntries = draft.byMessage[existingMessageKey] + Object.keys(partEntries).forEach((existingPartKey) => { + if (partEntries[existingPartKey].permission.id === entry.permission.id) { + delete partEntries[existingPartKey] + } + }) + if (Object.keys(partEntries).length === 0) { + delete draft.byMessage[existingMessageKey] + } + }) draft.byMessage[messageKey] = draft.byMessage[messageKey] ?? {} draft.byMessage[messageKey][partKey] = entry const existingIndex = draft.queue.findIndex((item) => item.permission.id === entry.permission.id) @@ -919,9 +945,8 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt } else { draft.queue[existingIndex] = entry } - if (!draft.active || draft.active.permission.id === entry.permission.id) { - draft.active = entry - } + draft.queue.sort((left, right) => left.enqueuedAt - right.enqueuedAt) + draft.active = draft.queue[0] ?? null }), ) } diff --git a/packages/ui/src/stores/session-events.ts b/packages/ui/src/stores/session-events.ts index 5f3a480d0..fcb253bf9 100644 --- a/packages/ui/src/stores/session-events.ts +++ b/packages/ui/src/stores/session-events.ts @@ -61,6 +61,7 @@ import { applyPartUpdateV2, applyPartDeltaV2, replaceMessageIdV2, + reconcilePendingPermissionsV2, reconcilePendingQuestionsV2, upsertMessageInfoV2, upsertPermissionV2, @@ -372,8 +373,9 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes applyPartUpdateV2(instanceId, { ...part, sessionID: sessionId, messageID: messageId }) handleConversationAssistantPartUpdated(instanceId, { ...part, sessionID: sessionId, messageID: messageId }, messageInfo) - if (part.type === "tool" && part.tool === "question") { - // Questions can arrive before their tool part exists; re-link now. + if (part.type === "tool") { + // Interruptions can arrive before their tool part exists; re-link now. + reconcilePendingPermissionsV2(instanceId, sessionId) reconcilePendingQuestionsV2(instanceId, sessionId) } @@ -698,8 +700,8 @@ function handlePermissionUpdated(instanceId: string, event: { type: string; prop } log.info(`[SSE] Permission request: ${permissionId} (${getPermissionKind(permission)})`) - addPermissionToQueue(instanceId, permission) - upsertPermissionV2(instanceId, permission) + const queuedPermission = addPermissionToQueue(instanceId, permission) ?? permission + upsertPermissionV2(instanceId, queuedPermission) const sessionId = getPermissionSessionId(permission) diff --git a/packages/ui/src/types/permission.test.ts b/packages/ui/src/types/permission.test.ts new file mode 100644 index 000000000..e82151259 --- /dev/null +++ b/packages/ui/src/types/permission.test.ts @@ -0,0 +1,49 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { mergePermissionRequest, type PermissionRequestLike } from "./permission.ts" + +describe("mergePermissionRequest", () => { + it("preserves known routing metadata when duplicate payloads are sparse", () => { + const previous: PermissionRequestLike = { + id: "permission-1", + sessionID: "session-1", + messageID: "message-1", + callID: "call-1", + metadata: { + callID: "metadata-call-1", + messageID: "metadata-message-1", + }, + tool: { + callID: "tool-call-1", + messageID: "tool-message-1", + }, + time: { created: 1_000 }, + } + + const next: PermissionRequestLike = { + id: "permission-1", + sessionID: undefined, + messageID: undefined, + callID: undefined, + metadata: { + callID: undefined, + }, + tool: { + callID: undefined, + }, + time: { created: undefined }, + } as PermissionRequestLike + + const merged = mergePermissionRequest(previous, next) + + assert.equal(merged.sessionID, "session-1") + assert.equal(merged.messageID, "message-1") + assert.equal(merged.callID, "call-1") + assert.equal(merged.metadata?.callID, "metadata-call-1") + assert.equal(merged.metadata?.messageID, "metadata-message-1") + assert.equal(merged.tool?.callID, "tool-call-1") + assert.equal(merged.tool?.messageID, "tool-message-1") + assert.equal(merged.time?.created, 1_000) + }) +}) diff --git a/packages/ui/src/types/permission.ts b/packages/ui/src/types/permission.ts index 84f4cda71..5d873460d 100644 --- a/packages/ui/src/types/permission.ts +++ b/packages/ui/src/types/permission.ts @@ -17,10 +17,15 @@ export interface PermissionRequestLike { pattern?: string title?: string sessionID?: string + sessionId?: string messageID?: string messageId?: string callID?: string callId?: string + partID?: string + partId?: string + toolCallID?: string + toolCallId?: string metadata?: Record time?: { created?: number } @@ -42,6 +47,50 @@ export interface PermissionReplyEventPropertiesLike { reply?: PermissionReply } +// Permission payloads can come from legacy/new SDK shapes. Preserve known +// top-level routing aliases when an out-of-order duplicate omits them. +const TOP_LEVEL_ROUTING_ALIAS_KEYS = [ + "sessionID", + "sessionId", + "messageID", + "messageId", + "callID", + "callId", + "partID", + "partId", + "toolCallID", + "toolCallId", +] as const satisfies ReadonlyArray + +function mergeRecordPreservingKnown>(previous: T | undefined, next: T | undefined): T | undefined { + if (!previous) return next + if (!next) return previous + const merged: Record = { ...previous, ...next } + for (const key of Object.keys(previous)) { + if (next[key] == null && previous[key] != null) { + merged[key] = previous[key] + } + } + return merged as T +} + +export function mergePermissionRequest(previous: PermissionRequestLike | undefined, next: PermissionRequestLike): PermissionRequestLike { + if (!previous) return next + const merged = { + ...previous, + ...next, + metadata: mergeRecordPreservingKnown(previous.metadata, next.metadata), + time: mergeRecordPreservingKnown(previous.time as Record | undefined, next.time as Record | undefined) as PermissionRequestLike["time"], + tool: mergeRecordPreservingKnown(previous.tool as Record | undefined, next.tool as Record | undefined) as PermissionRequestLike["tool"], + } + for (const key of TOP_LEVEL_ROUTING_ALIAS_KEYS) { + if ((next as any)[key] == null && (previous as any)[key] != null) { + ;(merged as any)[key] = (previous as any)[key] + } + } + return merged +} + export function getPermissionId(permission: PermissionRequestLike | null | undefined): string { return permission?.id ?? "" } From 9543292b83ef1cf05e0e9a43fcb23b60d8da07cd Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Sun, 10 May 2026 18:20:11 +0100 Subject: [PATCH 15/32] fix(desktop): keep remote window server titles (#429) ## Summary - Lock Electron remote BrowserWindow titles to the generated saved-server title. - Lock Tauri remote webview document titles through the remote initialization script so native window titles remain distinguishable. - Preserve the existing title format that includes the saved server name and host. Closes #427 ## Validation - npm run typecheck --workspace @neuralnomads/codenomad-electron-app - cargo check (packages/tauri-app/src-tauri) --- packages/electron-app/electron/main/main.ts | 16 ++-- packages/tauri-app/src-tauri/src/main.rs | 96 ++++++++++++++------- 2 files changed, 76 insertions(+), 36 deletions(-) diff --git a/packages/electron-app/electron/main/main.ts b/packages/electron-app/electron/main/main.ts index 4b31cc9a1..c4e01e295 100644 --- a/packages/electron-app/electron/main/main.ts +++ b/packages/electron-app/electron/main/main.ts @@ -474,12 +474,15 @@ function finalizeCliSwap(url: string) { } function buildRemoteWindowTitle(name: string, baseUrl: string) { - try { - const parsed = new URL(baseUrl) - return `${name} - ${parsed.host}` - } catch { - return `${name} - ${baseUrl}` - } + return `${name} - ${baseUrl}` +} + +function lockWindowTitle(window: BrowserWindow, title: string) { + window.setTitle(title) + window.webContents.on("page-title-updated", (event) => { + event.preventDefault() + window.setTitle(title) + }) } function buildRemoteErrorHtml(name: string, baseUrl: string, message: string) { @@ -508,6 +511,7 @@ async function openRemoteWindow(payload: { id: string; name: string; baseUrl: st additionalArguments: ["--codenomad-window-context=remote"], }, }) + lockWindowTitle(window, title) setWindowAllowedOrigin(window, targetUrl.toString()) if (payload.skipTlsVerify) { diff --git a/packages/tauri-app/src-tauri/src/main.rs b/packages/tauri-app/src-tauri/src/main.rs index fd05dbb89..43fccc43a 100644 --- a/packages/tauri-app/src-tauri/src/main.rs +++ b/packages/tauri-app/src-tauri/src/main.rs @@ -3,9 +3,9 @@ #[allow(dead_code)] mod cert_manager; mod cli_manager; -mod managed_node; #[cfg(target_os = "linux")] mod linux_tls; +mod managed_node; use cli_manager::{CliProcessManager, CliStatus}; use keepawake::KeepAwake; @@ -17,7 +17,7 @@ use std::sync::Mutex; use std::time::{SystemTime, UNIX_EPOCH}; use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder}; use tauri::plugin::{Builder as PluginBuilder, TauriPlugin}; -use tauri::webview::Webview; +use tauri::webview::{PageLoadEvent, Webview}; use tauri::{ AppHandle, Emitter, Manager, Runtime, WebviewUrl, WebviewWindowBuilder, WindowEvent, Wry, }; @@ -55,6 +55,7 @@ pub struct AppState { pub remote_proxy_sessions: Mutex>, pub remote_skip_tls_verify: Mutex>, pub remote_tls_handlers: Mutex>, + pub remote_titles: Mutex>, } #[derive(Debug, Deserialize)] @@ -227,26 +228,38 @@ fn intercept_navigation(webview: &Webview, url: &Url) -> bool { false } +fn apply_remote_window_title(app_handle: &AppHandle, window_label: &str) { + let Some(title) = app_handle + .state::() + .remote_titles + .lock() + .ok() + .and_then(|titles| titles.get(window_label).cloned()) + else { + return; + }; + + if let Some(window) = app_handle.get_webview_window(window_label) { + let _ = window.set_title(&title); + } +} + async fn open_remote_window_impl( app: AppHandle, payload: RemoteWindowPayload, ) -> Result<(), String> { - let entry_url = payload.entry_url.as_deref().unwrap_or(payload.base_url.as_str()); + let entry_url = payload + .entry_url + .as_deref() + .unwrap_or(payload.base_url.as_str()); let parsed = Url::parse(entry_url).map_err(|err| err.to_string())?; let label = format!("remote-{}", payload.id); - let title = format!( - "{} - {}", - payload.name, - Url::parse(&payload.base_url) - .ok() - .and_then(|url| url.host_str().map(str::to_string)) - .unwrap_or_else(|| payload.base_url.clone()) - ); + let title = format!("{} - {}", payload.name, payload.base_url); let window_url = parsed.clone(); - let allow_linux_tls_certificate = - parsed.scheme() == "https" && (payload.proxy_session_id.is_some() || payload.skip_tls_verify); + let allow_linux_tls_certificate = parsed.scheme() == "https" + && (payload.proxy_session_id.is_some() || payload.skip_tls_verify); app.state::() .remote_origins @@ -258,6 +271,11 @@ async fn open_remote_window_impl( .lock() .map_err(|err| err.to_string())? .insert(label.clone(), allow_linux_tls_certificate); + app.state::() + .remote_titles + .lock() + .map_err(|err| err.to_string())? + .insert(label.clone(), title.clone()); let replaced_session = { let state = app.state::(); @@ -281,8 +299,9 @@ async fn open_remote_window_impl( #[cfg(target_os = "linux")] linux_tls::ensure_remote_window_tls_handler(&existing, &app, &label)?; - let _ = existing.navigate(window_url.clone()); let _ = existing.set_title(&title); + let _ = existing.navigate(window_url.clone()); + apply_remote_window_title(&app, &label); let _ = existing.show(); let _ = existing.unminimize(); let _ = existing.set_focus(); @@ -290,25 +309,27 @@ async fn open_remote_window_impl( } #[cfg(target_os = "linux")] - let initial_url = if linux_tls::should_bootstrap_tls_navigation( - &window_url, - allow_linux_tls_certificate, - ) { - Url::parse("about:blank").map_err(|err| err.to_string())? - } else { - window_url.clone() - }; + let initial_url = + if linux_tls::should_bootstrap_tls_navigation(&window_url, allow_linux_tls_certificate) { + Url::parse("about:blank").map_err(|err| err.to_string())? + } else { + window_url.clone() + }; #[cfg(not(target_os = "linux"))] let initial_url = window_url.clone(); - let window = WebviewWindowBuilder::new(&app, label.clone(), WebviewUrl::External(initial_url.clone())) - .initialization_script(REMOTE_WINDOW_CONTEXT_SCRIPT) - .title(title) - .inner_size(1400.0, 900.0) - .min_inner_size(800.0, 600.0) - .build() - .map_err(|err| err.to_string())?; + let window = WebviewWindowBuilder::new( + &app, + label.clone(), + WebviewUrl::External(initial_url.clone()), + ) + .initialization_script(REMOTE_WINDOW_CONTEXT_SCRIPT) + .title(title) + .inner_size(1400.0, 900.0) + .min_inner_size(800.0, 600.0) + .build() + .map_err(|err| err.to_string())?; #[cfg(target_os = "linux")] { @@ -336,6 +357,9 @@ async fn open_remote_window_impl( if let Ok(mut handlers) = app_handle.state::().remote_tls_handlers.lock() { handlers.remove(&label_for_cleanup); } + if let Ok(mut titles) = app_handle.state::().remote_titles.lock() { + titles.remove(&label_for_cleanup); + } } }); @@ -364,7 +388,10 @@ fn needs_local_certificate_install() -> Result { async fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<(), String> { #[cfg(not(target_os = "linux"))] { - let entry_url = payload.entry_url.as_deref().unwrap_or(payload.base_url.as_str()); + let entry_url = payload + .entry_url + .as_deref() + .unwrap_or(payload.base_url.as_str()); let parsed = Url::parse(entry_url).map_err(|err| err.to_string())?; if payload.proxy_session_id.is_some() && parsed.scheme() == "https" { let local_cert = cert_manager::ensure_local_cert().map_err(|err| { @@ -542,6 +569,15 @@ fn main() { remote_proxy_sessions: Mutex::new(HashMap::new()), remote_skip_tls_verify: Mutex::new(HashMap::new()), remote_tls_handlers: Mutex::new(HashSet::new()), + remote_titles: Mutex::new(HashMap::new()), + }) + .on_page_load(|webview, payload| { + if matches!( + payload.event(), + PageLoadEvent::Started | PageLoadEvent::Finished + ) { + apply_remote_window_title(&webview.app_handle(), webview.label()); + } }) .setup(|app| { set_windows_app_user_model_id(); From 562c6325c77ef6eb532c6a50e96a0e70759e43dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 10 May 2026 19:27:05 +0200 Subject: [PATCH 16/32] fix(ui): follow up idle badge behavior (#423) ## Summary - Follow-up to #395 after the idle badge persistence behavior regressed around `keepUnseenSubagentIdleStatus`. - Restores `keepUnseenSubagentIdleStatus` handling across session, active-session, and instance-tab badge helpers. - Starts a 5s fade when a viewed idle session/thread is seen, then clears each exact idle transition after the animation even if focus moves away. - Keeps unseen subagent idle badges visible when the preference is enabled, and keeps aggregate instance badges solid when any other visible idle contributor is still unseen. ## Validation - `git diff --check` - `npm run typecheck --workspace @codenomad/ui` - `npm run build --workspace @codenomad/ui` - Gatekeeper re-review: no blocking findings remain for the prior preference/race findings. ## Notes - `node --test packages/ui/src/stores/session-status.test.ts` was attempted, but the repo's extensionless TypeScript imports are not directly resolvable by Node's test runner. --- packages/ui/src/components/instance-tab.tsx | 22 +++++- .../components/instance/instance-shell2.tsx | 16 +++- packages/ui/src/components/session-list.tsx | 20 ++++- .../src/components/session/session-view.tsx | 46 ++++++----- packages/ui/src/stores/session-status.test.ts | 78 +++++++++++++++++-- packages/ui/src/stores/session-status.ts | 73 ++++++++++++++++- .../ui/src/styles/panels/session-layout.css | 14 ++++ 7 files changed, 231 insertions(+), 38 deletions(-) diff --git a/packages/ui/src/components/instance-tab.tsx b/packages/ui/src/components/instance-tab.tsx index d9681e67c..7a8948fd5 100644 --- a/packages/ui/src/components/instance-tab.tsx +++ b/packages/ui/src/components/instance-tab.tsx @@ -1,8 +1,9 @@ -import { Component, Show, createMemo } from "solid-js" +import { Component, Show, createMemo, createSignal, onCleanup } from "solid-js" import type { Instance } from "../types/instance" -import { getInstanceSessionIndicatorStatus } from "../stores/session-status" +import { getInstanceIdleFadeClass, getInstanceSessionIndicatorStatus } from "../stores/session-status" import { FolderOpen, ShieldAlert, X } from "lucide-solid" import { useI18n } from "../lib/i18n" +import { useConfig } from "../stores/preferences" interface InstanceTabProps { instance: Instance @@ -20,12 +21,25 @@ function getPathBasename(path: string): string { const InstanceTab: Component = (props) => { const { t } = useI18n() + const { preferences } = useConfig() + const [now, setNow] = createSignal(Date.now()) - const aggregatedStatus = createMemo(() => getInstanceSessionIndicatorStatus(props.instance.id)) + if (typeof window !== "undefined") { + const timer = window.setInterval(() => setNow(Date.now()), 1000) + onCleanup(() => window.clearInterval(timer)) + } + + const aggregatedStatus = createMemo(() => + getInstanceSessionIndicatorStatus(props.instance.id, now(), preferences().keepUnseenSubagentIdleStatus), + ) const statusClassName = createMemo(() => { const status = aggregatedStatus() if (!status) return null - return status === "permission" ? "session-permission" : `session-${status}` + if (status === "permission") return "session-permission" + const base = `session-${status}` + const fadeClass = + status === "idle" ? getInstanceIdleFadeClass(props.instance.id, now(), preferences().keepUnseenSubagentIdleStatus) : "" + return fadeClass ? `${base} ${fadeClass}` : base }) const statusTitle = createMemo(() => { switch (aggregatedStatus()) { diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index 1f857591d..74b1dc81c 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -41,9 +41,10 @@ import SessionSidebar from "./shell/SessionSidebar" import { useSessionSidebarRequests } from "./shell/useSessionSidebarRequests" import RightPanel from "./shell/right-panel/RightPanel" import { useDrawerChrome } from "./shell/useDrawerChrome" -import { getRetrySeconds, getSessionRetry, getSessionStatus, shouldShowSessionStatus } from "../../stores/session-status" +import { getRetrySeconds, getSessionIdleFadeClass, getSessionRetry, getSessionStatus, shouldShowSessionStatus } from "../../stores/session-status" import { Maximize2, Search, ShieldAlert } from "lucide-solid" import type { PromptInputApi } from "../prompt-input/types" +import { useConfig } from "../../stores/preferences" import type { LayoutMode } from "./shell/types" import { @@ -85,6 +86,7 @@ interface InstanceShellProps { const InstanceShell2: Component = (props) => { const { t, locale } = useI18n() + const { preferences } = useConfig() const isRTL = () => locale() === "he" const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH) @@ -303,7 +305,12 @@ const InstanceShell2: Component = (props) => { const status = getSessionStatus(props.instance.id, activeSessionId) const retry = getSessionRetry(props.instance.id, activeSessionId) - const showStatus = shouldShowSessionStatus(props.instance.id, activeSessionId) + const showStatus = shouldShowSessionStatus( + props.instance.id, + activeSessionId, + now(), + preferences().keepUnseenSubagentIdleStatus, + ) if (!showStatus) { return null } @@ -318,8 +325,11 @@ const InstanceShell2: Component = (props) => { ? t("sessionList.status.compacting") : t("sessionList.status.idle") + const baseClassName = `session-${retry ? "retrying" : status}` + const fadeClassName = getSessionIdleFadeClass(props.instance.id, activeSessionId) + return { - className: `session-${retry ? "retrying" : status}`, + className: fadeClassName ? `${baseClassName} ${fadeClassName}` : baseClassName, text, showAlertIcon: false, title: retry diff --git a/packages/ui/src/components/session-list.tsx b/packages/ui/src/components/session-list.tsx index 5a53d4b06..c113a9dd7 100644 --- a/packages/ui/src/components/session-list.tsx +++ b/packages/ui/src/components/session-list.tsx @@ -1,7 +1,7 @@ import { Component, For, Show, createSignal, createMemo, createEffect, JSX, onCleanup } from "solid-js" import type { SessionStatus } from "../types/session" import type { SessionThread } from "../stores/session-state" -import { getRetrySeconds, getSessionRetry, getSessionStatus, shouldShowSessionStatus } from "../stores/session-status" +import { getRetrySeconds, getSessionIdleFadeClass, getSessionRetry, getSessionStatus, shouldShowSessionStatus } from "../stores/session-status" import { Bot, User, Copy, Trash2, Pencil, ShieldAlert, ChevronDown, Search, Square, CheckSquare, MinusSquare, Split, RotateCw } from "lucide-solid" import KeyboardHint from "./keyboard-hint" import SessionRenameDialog from "./session-rename-dialog" @@ -24,6 +24,7 @@ import { import { getGitRepoStatus, getWorktreeSlugForParentSession } from "../stores/worktrees" import { getLogger } from "../lib/logger" import { copyToClipboard } from "../lib/clipboard" +import { useConfig } from "../stores/preferences" const log = getLogger("session") @@ -47,6 +48,7 @@ function formatSessionStatus(status: SessionStatus): string { const SessionList: Component = (props) => { const { t } = useI18n() + const { preferences } = useConfig() const [renameTarget, setRenameTarget] = createSignal<{ id: string; title: string; label: string } | null>(null) const [isRenaming, setIsRenaming] = createSignal(false) @@ -426,8 +428,20 @@ const SessionList: Component = (props) => { const needsPermission = () => Boolean(session()?.pendingPermission) const needsQuestion = () => Boolean((session() as any)?.pendingQuestion) const needsInput = () => needsPermission() || needsQuestion() - const statusClassName = () => (needsInput() ? "session-permission" : `session-${retry() ? "retrying" : status()}`) - const showStatus = () => needsInput() || shouldShowSessionStatus(props.instanceId, rowProps.sessionId) + const statusClassName = () => { + if (needsInput()) return "session-permission" + const base = `session-${retry() ? "retrying" : status()}` + const fadeClass = getSessionIdleFadeClass(props.instanceId, rowProps.sessionId) + return fadeClass ? `${base} ${fadeClass}` : base + } + const showStatus = () => + needsInput() || + shouldShowSessionStatus( + props.instanceId, + rowProps.sessionId, + now(), + preferences().keepUnseenSubagentIdleStatus, + ) const statusText = () => needsPermission() ? t("sessionList.status.needsPermission") diff --git a/packages/ui/src/components/session/session-view.tsx b/packages/ui/src/components/session/session-view.tsx index 0ce385cd8..1b70efb33 100644 --- a/packages/ui/src/components/session/session-view.tsx +++ b/packages/ui/src/components/session/session-view.tsx @@ -1,4 +1,4 @@ -import { Show, createMemo, createEffect, on, onCleanup, type Component } from "solid-js" +import { Show, createMemo, createEffect, on, type Component } from "solid-js" import type { Session } from "../../types/session" import type { Attachment } from "../../types/attachment" import type { ClientPart } from "../../types/message" @@ -8,8 +8,8 @@ import PromptInput from "../prompt-input" import PromptAttachmentsBar from "../prompt-input/PromptAttachmentsBar" import { getAttachments, removeAttachment } from "../../stores/attachments" import { instances } from "../../stores/instances" -import { loadMessages, sendMessage, forkSession, renameSession, isSessionMessagesLoading, markViewedSessionIdleSeen, setActiveParentSession, setActiveSession, runShellCommand, abortSession } from "../../stores/sessions" -import { IDLE_STATUS_VISIBILITY_MS, isSessionBusy as getSessionBusyStatus } from "../../stores/session-status" +import { loadMessages, sendMessage, forkSession, renameSession, isSessionMessagesLoading, markSessionIdleSeen, setActiveParentSession, setActiveSession, runShellCommand, abortSession } from "../../stores/sessions" +import { clearSessionIdleFade, IDLE_STATUS_VISIBILITY_MS, isSessionBusy as getSessionBusyStatus, markSessionIdleFadeStarted } from "../../stores/session-status" import { deleteMessage } from "../../stores/session-actions" import { showAlertDialog } from "../../stores/alerts" import { getLogger } from "../../lib/logger" @@ -68,6 +68,7 @@ export const SessionView: Component = (props) => { let scrollToBottomHandle: (() => void) | undefined let rootRef: HTMLDivElement | undefined + const pendingIdleSeenTimers = new Set() function shouldScrollToBottomOnActivate() { const current = session() @@ -83,11 +84,11 @@ export const SessionView: Component = (props) => { }) } - function getSeenIdleSignature(currentSession: Session, keepUnseenSubagentIdleStatus: boolean): string { - const ids: string[] = [] + function getSeenIdleEntries(currentSession: Session, keepUnseenSubagentIdleStatus: boolean): Array<{ id: string; idleSince: number }> { + const entries: Array<{ id: string; idleSince: number }> = [] if (currentSession.status === "idle" && typeof currentSession.idleSince === "number") { - ids.push(`${currentSession.id}:${currentSession.idleSince}`) + entries.push({ id: currentSession.id, idleSince: currentSession.idleSince }) } if (currentSession.parentId === null && !keepUnseenSubagentIdleStatus) { @@ -95,11 +96,11 @@ export const SessionView: Component = (props) => { if (child.parentId !== currentSession.id) continue if (child.status !== "idle") continue if (typeof child.idleSince !== "number") continue - ids.push(`${child.id}:${child.idleSince}`) + entries.push({ id: child.id, idleSince: child.idleSince }) } } - return ids.sort().join("|") + return entries } createEffect( @@ -118,19 +119,22 @@ export const SessionView: Component = (props) => { const currentSession = session() if (!props.isActive || !currentSession) return - const keepUnseenSubagentIdleStatus = preferences().keepUnseenSubagentIdleStatus - const seenIdleSignature = getSeenIdleSignature(currentSession, keepUnseenSubagentIdleStatus) - if (!seenIdleSignature) return - - const timeout = window.setTimeout(() => { - const latestSession = session() - if (!props.isActive || !latestSession) return - const latestKeepUnseenSubagentIdleStatus = preferences().keepUnseenSubagentIdleStatus - if (getSeenIdleSignature(latestSession, latestKeepUnseenSubagentIdleStatus) !== seenIdleSignature) return - markViewedSessionIdleSeen(props.instanceId, latestSession.id, latestKeepUnseenSubagentIdleStatus) - }, IDLE_STATUS_VISIBILITY_MS) - - onCleanup(() => window.clearTimeout(timeout)) + const seenIdleEntries = getSeenIdleEntries(currentSession, preferences().keepUnseenSubagentIdleStatus) + for (const entry of seenIdleEntries) { + const timerKey = `${props.instanceId}:${entry.id}:${entry.idleSince}` + if (pendingIdleSeenTimers.has(timerKey)) continue + pendingIdleSeenTimers.add(timerKey) + markSessionIdleFadeStarted(props.instanceId, entry.id) + + window.setTimeout(() => { + pendingIdleSeenTimers.delete(timerKey) + const latestEntry = props.activeSessions.get(entry.id) + if (latestEntry?.status === "idle" && latestEntry.idleSince === entry.idleSince) { + markSessionIdleSeen(props.instanceId, entry.id) + } + clearSessionIdleFade(props.instanceId, entry.id, entry.idleSince) + }, IDLE_STATUS_VISIBILITY_MS) + } }) createEffect( diff --git a/packages/ui/src/stores/session-status.test.ts b/packages/ui/src/stores/session-status.test.ts index 29d7329b6..aee67714c 100644 --- a/packages/ui/src/stores/session-status.test.ts +++ b/packages/ui/src/stores/session-status.test.ts @@ -2,7 +2,8 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" import { getIdleSinceForStatusTransition } from "../types/session.ts" -import { IDLE_STATUS_VISIBILITY_MS, shouldShowIdleStatus } from "./session-status.ts" +import { clearSessionIdleFade, getSessionIdleFadeClass, IDLE_STATUS_VISIBILITY_MS, markSessionIdleFadeStarted, shouldShowIdleStatus, shouldShowSessionStatus } from "./session-status.ts" +import { setSessions } from "./session-state.ts" import { shouldSessionHoldWakeLock } from "./wake-lock-eligibility.ts" describe("shouldSessionHoldWakeLock", () => { @@ -37,14 +38,15 @@ describe("idle status visibility", () => { it("keeps subagent idle visible until the parent or child session is seen", () => { const idleSince = getIdleSinceForStatusTransition("working", "idle", null, 1_000) - assert.equal(shouldShowIdleStatus({ status: "idle", idleSince, parentId: "parent" }), true) - assert.equal(shouldShowIdleStatus({ status: "idle", idleSince, parentId: "parent" }), true) + assert.equal(shouldShowIdleStatus({ status: "idle", idleSince, parentId: "parent" }, 2_000, true), true) + assert.equal(shouldShowIdleStatus({ status: "idle", idleSince, parentId: "parent" }, 10_000, true), true) }) - it("does not use the keep-unseen setting to age out visible idle markers", () => { + it("ages out subagent idle markers unless keep-unseen is enabled", () => { const idleSince = getIdleSinceForStatusTransition("working", "idle", null, 1_000) - assert.equal(shouldShowIdleStatus({ status: "idle", idleSince, parentId: "parent" }), true) + assert.equal(shouldShowIdleStatus({ status: "idle", idleSince, parentId: "parent" }, 2_000, false), true) + assert.equal(shouldShowIdleStatus({ status: "idle", idleSince, parentId: "parent" }, 7_000, false), false) }) it("does not show idle for sessions that started idle", () => { @@ -60,4 +62,70 @@ describe("idle status visibility", () => { assert.equal(idleSince, null) assert.equal(shouldShowIdleStatus({ status: "working", idleSince, parentId: null }), false) }) + + it("clears idle fade state for a specific idle transition", () => { + const instanceId = "instance" + const sessionId = "session" + const idleSince = 1_000 + + setSessions( + new Map([ + [ + instanceId, + new Map([ + [ + sessionId, + { + id: sessionId, + status: "idle", + idleSince, + parentId: null, + } as any, + ], + ]), + ], + ]), + ) + + markSessionIdleFadeStarted(instanceId, sessionId) + assert.equal(getSessionIdleFadeClass(instanceId, sessionId), "session-status-fading") + + clearSessionIdleFade(instanceId, sessionId, idleSince) + assert.equal(getSessionIdleFadeClass(instanceId, sessionId), "") + + setSessions(new Map()) + }) + + it("keeps a default-mode subagent visible while its parent-triggered fade runs", () => { + const instanceId = "instance" + const sessionId = "subsession" + const idleSince = 1_000 + + setSessions( + new Map([ + [ + instanceId, + new Map([ + [ + sessionId, + { + id: sessionId, + status: "idle", + idleSince, + parentId: "parent", + } as any, + ], + ]), + ], + ]), + ) + + assert.equal(shouldShowSessionStatus(instanceId, sessionId, 7_000, false), false) + + markSessionIdleFadeStarted(instanceId, sessionId) + assert.equal(shouldShowSessionStatus(instanceId, sessionId, 7_000, false), true) + + clearSessionIdleFade(instanceId, sessionId, idleSince) + setSessions(new Map()) + }) }) diff --git a/packages/ui/src/stores/session-status.ts b/packages/ui/src/stores/session-status.ts index 503b7fdea..1566077fa 100644 --- a/packages/ui/src/stores/session-status.ts +++ b/packages/ui/src/stores/session-status.ts @@ -1,9 +1,16 @@ import type { Session, SessionRetryState, SessionStatus } from "../types/session" import { getInstanceSessionIndicatorStatusCached, sessions } from "./session-state" import { shouldSessionHoldWakeLock } from "./wake-lock-eligibility" +import { createSignal } from "solid-js" export const IDLE_STATUS_VISIBILITY_MS = 5000 +const [idleFadeStarts, setIdleFadeStarts] = createSignal>(new Map()) + +function idleFadeKey(instanceId: string, sessionId: string, idleSince: number): string { + return `${instanceId}:${sessionId}:${idleSince}` +} + function getSession(instanceId: string, sessionId: string): Session | null { const instanceSessions = sessions().get(instanceId) return instanceSessions?.get(sessionId) ?? null @@ -37,8 +44,55 @@ export function getSessionRetry(instanceId: string, sessionId: string): SessionR return session?.retry ?? null } +export function markSessionIdleFadeStarted(instanceId: string, sessionId: string): void { + const session = getSession(instanceId, sessionId) + if (!session || session.status !== "idle" || typeof session.idleSince !== "number") return + const key = idleFadeKey(instanceId, sessionId, session.idleSince) + setIdleFadeStarts((prev) => { + if (prev.has(key)) return prev + const next = new Map(prev) + next.set(key, Date.now()) + return next + }) +} + +export function clearSessionIdleFade(instanceId: string, sessionId: string, idleSince: number): void { + const key = idleFadeKey(instanceId, sessionId, idleSince) + setIdleFadeStarts((prev) => { + if (!prev.has(key)) return prev + const next = new Map(prev) + next.delete(key) + return next + }) +} + +export function getSessionIdleFadeClass(instanceId: string, sessionId: string): string { + const session = getSession(instanceId, sessionId) + if (!session || session.status !== "idle" || typeof session.idleSince !== "number") return "" + const startedAt = idleFadeStarts().get(idleFadeKey(instanceId, sessionId, session.idleSince)) + return typeof startedAt === "number" ? "session-status-fading" : "" +} + +export function getInstanceIdleFadeClass(instanceId: string, now = Date.now(), keepUnseenSubagentIdleStatus = false): string { + const instanceSessions = sessions().get(instanceId) + if (!instanceSessions) return "" + + let hasVisibleIdle = false + for (const session of instanceSessions.values()) { + if (!session.id) continue + const isFading = Boolean(getSessionIdleFadeClass(instanceId, session.id)) + if (!isFading && !shouldShowIdleStatus(session, now, keepUnseenSubagentIdleStatus)) continue + hasVisibleIdle = true + if (!isFading) return "" + } + + return hasVisibleIdle ? "session-status-fading" : "" +} + export function shouldShowIdleStatus( session: Pick | null | undefined, + now = Date.now(), + keepUnseenSubagentIdleStatus = false, ): boolean { if (!session || session.status !== "idle") { return false @@ -48,12 +102,18 @@ export function shouldShowIdleStatus( return false } + if (session.parentId && !keepUnseenSubagentIdleStatus) { + return now - session.idleSince < IDLE_STATUS_VISIBILITY_MS + } + return true } export function shouldShowSessionStatus( instanceId: string, sessionId: string, + now = Date.now(), + keepUnseenSubagentIdleStatus = false, ): boolean { const session = getSession(instanceId, sessionId) if (!session) { @@ -64,7 +124,11 @@ export function shouldShowSessionStatus( return true } - return session.status !== "idle" || shouldShowIdleStatus(session) + if (session.status === "idle" && getSessionIdleFadeClass(instanceId, sessionId)) { + return true + } + + return session.status !== "idle" || shouldShowIdleStatus(session, now, keepUnseenSubagentIdleStatus) } export function getRetrySeconds(next: number, now = Date.now()): number { @@ -75,6 +139,8 @@ export type InstanceSessionIndicatorStatus = "permission" | SessionStatus export function getInstanceSessionIndicatorStatus( instanceId: string, + now = Date.now(), + keepUnseenSubagentIdleStatus = false, ): InstanceSessionIndicatorStatus | null { const aggregated = getInstanceSessionIndicatorStatusCached(instanceId) if (aggregated !== "idle") { @@ -87,7 +153,10 @@ export function getInstanceSessionIndicatorStatus( } for (const session of instanceSessions.values()) { - if (shouldShowIdleStatus(session)) { + if (session.id && getSessionIdleFadeClass(instanceId, session.id)) { + return "idle" + } + if (shouldShowIdleStatus(session, now, keepUnseenSubagentIdleStatus)) { return "idle" } } diff --git a/packages/ui/src/styles/panels/session-layout.css b/packages/ui/src/styles/panels/session-layout.css index 1ff0dc3f6..9daa9388a 100644 --- a/packages/ui/src/styles/panels/session-layout.css +++ b/packages/ui/src/styles/panels/session-layout.css @@ -442,6 +442,10 @@ session-sidebar-controls .selector-trigger-primary { --session-status-dot: var(--session-status-idle-fg); } +.status-indicator.session-status.session-idle.session-status-fading { + animation: session-status-fade-out 5s ease-out forwards; +} + .status-indicator.session-status.session-permission { color: var(--session-status-permission-fg); --session-status-dot: var(--session-status-permission-fg); @@ -490,6 +494,16 @@ session-sidebar-controls .selector-trigger-primary { border: 1px solid transparent; } +@keyframes session-status-fade-out { + from { + opacity: 1; + } + + to { + opacity: 0; + } +} + .status-indicator.session-yolo-mode { color: var(--accent-primary); background-color: color-mix(in oklab, var(--accent-primary) 14%, transparent); From a3231c3da565e2ecab64102632fb68ef88e2c3d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 10 May 2026 19:36:49 +0200 Subject: [PATCH 17/32] fix(ui): return to active projects from home (#411) ## Summary - Return to an existing open project when users choose that from the already-open folder dialog. - When users click a recent folder that is already open, ask whether to switch to the open project or intentionally open another instance. - Surface a prominent Return to Active Project action so leaving the home screen no longer depends on the subtle top-right close affordance. - Keep multi-instance workflows available without requiring users to remember a separate row-level button. Fixes #281 ## Why Users can accidentally open the same workspace several times from the add-project screen. The app now detects that ambiguity when the already-open folder is clicked and asks what should happen, instead of making the user remember a special alternate button ahead of time. The dialog keeps the copy short: the title states that the project is already open, and the buttons carry the actual choices. ## Verification - `git diff --check` - `npm run typecheck --workspace @codenomad/ui` - `npm run build --workspace @codenomad/ui` - `npm run build:tauri` --- packages/ui/src/App.tsx | 68 +++++++++++++++++-- .../src/components/folder-selection-view.tsx | 43 ++++++++++-- .../lib/i18n/messages/en/folderSelection.ts | 7 ++ .../lib/i18n/messages/es/folderSelection.ts | 7 ++ .../lib/i18n/messages/fr/folderSelection.ts | 7 ++ .../lib/i18n/messages/he/folderSelection.ts | 7 ++ .../lib/i18n/messages/ja/folderSelection.ts | 7 ++ .../lib/i18n/messages/ru/folderSelection.ts | 7 ++ .../i18n/messages/zh-Hans/folderSelection.ts | 7 ++ packages/ui/src/stores/instances.ts | 25 +++++++ 10 files changed, 175 insertions(+), 10 deletions(-) diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index d32a3a805..44f0d23fd 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -34,6 +34,7 @@ import { import { useConfig } from "./stores/preferences" import { createInstance, + getExistingInstanceForFolder, instances, stopInstance, disconnectedInstance, @@ -95,6 +96,11 @@ const App: Component = () => { const [escapeInDebounce, setEscapeInDebounce] = createSignal(false) const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0) const [sidecarPickerOpen, setSidecarPickerOpen] = createSignal(false) + const [alreadyOpenFolderChoice, setAlreadyOpenFolderChoice] = createSignal<{ + folderPath: string + binaryPath: string + instanceId: string + } | null>(null) const phoneQuery = useMediaQuery("(max-width: 767px)") const isPhoneLayout = createMemo(() => phoneQuery()) @@ -258,15 +264,24 @@ const App: Component = () => { const launchErrorMessage = () => launchError()?.message ?? "" - async function handleSelectFolder(folderPath: string, binaryPath?: string) { + async function handleSelectFolder(folderPath: string, binaryPath?: string, options?: { forceNew?: boolean }) { if (!folderPath) { return } - setIsSelectingFolder(true) const selectedBinary = binaryPath || serverSettings().opencodeBinary || "opencode" + recordWorkspaceLaunch(folderPath, selectedBinary) + clearLaunchError() + + if (!options?.forceNew) { + const existingInstance = getExistingInstanceForFolder(folderPath) + if (existingInstance) { + setAlreadyOpenFolderChoice({ folderPath, binaryPath: selectedBinary, instanceId: existingInstance.id }) + return + } + } + + setIsSelectingFolder(true) try { - recordWorkspaceLaunch(folderPath, selectedBinary) - clearLaunchError() const instanceId = await createInstance(folderPath, selectedBinary) selectInstanceTab(instanceId) setShowFolderSelection(false) @@ -285,6 +300,26 @@ const App: Component = () => { } } + function dismissAlreadyOpenFolderChoice() { + setAlreadyOpenFolderChoice(null) + } + + function switchToAlreadyOpenFolder() { + const choice = alreadyOpenFolderChoice() + if (!choice) return + setAlreadyOpenFolderChoice(null) + selectInstanceTab(choice.instanceId) + setShowFolderSelection(false) + log.info("Selected existing instance", { instanceId: choice.instanceId, folderPath: choice.folderPath }) + } + + function openAnotherFolderInstance() { + const choice = alreadyOpenFolderChoice() + if (!choice) return + setAlreadyOpenFolderChoice(null) + void handleSelectFolder(choice.folderPath, choice.binaryPath, { forceNew: true }) + } + function handleLaunchErrorClose() { clearLaunchError() } @@ -609,6 +644,7 @@ const App: Component = () => { onSelectFolder={handleSelectFolder} isLoading={isSelectingFolder()} onOpenSidecar={handleOpenSidecarPicker} + activeProjectLabel={activeInstance()?.folder ?? null} onClose={() => { setShowFolderSelection(false) clearLaunchError() @@ -620,6 +656,30 @@ const App: Component = () => { setSidecarPickerOpen(false)} onOpenSidecar={handleOpenSidecar} /> + + !open && dismissAlreadyOpenFolderChoice()}> + + + + + {t("folderSelection.recent.alreadyOpenTitle")} + + + {t("folderSelection.recent.alreadyOpenMessage")} + + +
+ + +
+
+
+
+
diff --git a/packages/ui/src/components/folder-selection-view.tsx b/packages/ui/src/components/folder-selection-view.tsx index 35c984def..286327b39 100644 --- a/packages/ui/src/components/folder-selection-view.tsx +++ b/packages/ui/src/components/folder-selection-view.tsx @@ -1,7 +1,7 @@ import { Dialog } from "@kobalte/core/dialog" import { Select } from "@kobalte/core/select" -import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js" -import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown, X, Globe, Loader2, GitBranch } from "lucide-solid" +import { Component, createMemo, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js" +import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown, X, Globe, Loader2, GitBranch, ArrowLeft } from "lucide-solid" import { useConfig } from "../stores/preferences" import DirectoryBrowserDialog from "./directory-browser-dialog" import Kbd from "./kbd" @@ -18,6 +18,7 @@ import { openExternalUrl } from "../lib/external-url" import { serverApi } from "../lib/api-client" import { canOpenRemoteWindows, isTauriHost } from "../lib/runtime-env" import { openRemoteServerWindow } from "../lib/native/remote-window" +import { getExistingInstanceForFolder } from "../stores/instances" const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href const GITHUB_URL = "https://github.com/NeuralNomadsAI/CodeNomad" @@ -27,10 +28,11 @@ type HomeTab = "local" | "servers" interface FolderSelectionViewProps { - onSelectFolder: (folder: string, binaryPath?: string) => void + onSelectFolder: (folder: string, binaryPath?: string, options?: { forceNew?: boolean }) => void onOpenSidecar?: () => void isLoading?: boolean onClose?: () => void + activeProjectLabel?: string | null } const FolderSelectionView: Component = (props) => { @@ -85,6 +87,11 @@ const FolderSelectionView: Component = (props) => { const serverList = () => remoteServers() const isLoading = () => Boolean(props.isLoading) const canUseRemoteServerWindows = () => canOpenRemoteWindows() + const activeProjectName = createMemo(() => { + const label = props.activeProjectLabel?.trim() + if (!label) return null + return splitFolderPath(label).baseName + }) function getActiveListLength() { return activeTab() === "local" ? folders().length : serverList().length @@ -862,8 +869,10 @@ const FolderSelectionView: Component = (props) => { ref={(el) => (recentListRef = el)} > - {(folder, index) => ( -
{ + const existingInstance = () => getExistingInstanceForFolder(folder.path) + + return
= (props) => { {splitFolderPath(folder.path).baseName} + + + {t("folderSelection.recent.openBadge")} + +
@@ -912,7 +926,7 @@ const FolderSelectionView: Component = (props) => {
- )} + }}
@@ -947,6 +961,23 @@ const FolderSelectionView: Component = (props) => { + + + + + + {(name) =>

{t("folderSelection.actions.returnToProjectSubtitle", { name: name() })}

} +
+ + +
+ {props.lockedBaseLabel} +
+
handleGo(event)}> + setPathInput(event.currentTarget.value)} + spellcheck={false} + autocomplete="off" + autocorrect="off" + autocapitalize="off" + aria-label={props.labels.path} + /> + +
+
+ + + + +
+ + + +
+
+
+