From 1e0d7abb6e5c935bdbc1deac296350172418f695 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sat, 5 Sep 2026 15:08:15 +0300 Subject: [PATCH] fix(sidebar): poll project actions only while one is running The sidebar indicator kept the terminal listing loop going for as long as the sidebar was visible, in every client, whether or not anything was running. It now lists once on mount, to pick up runs another client started, and keeps the loop only while a project action is known to be running anywhere; an idle sidebar costs no polling. Claude-Session: https://claude.ai/code/session_01VqV56Hez25hTxXH4ipJfzH --- .../session/sidebar/DOCUMENTATION.md | 4 +- .../sidebar/list/SidebarTerminalActivity.tsx | 38 +++++++++++++++---- .../sessions/DirectoryActionIndicator.tsx | 5 +-- packages/ui/src/stores/useTerminalStore.ts | 6 +++ 4 files changed, 41 insertions(+), 12 deletions(-) diff --git a/packages/ui/src/components/session/sidebar/DOCUMENTATION.md b/packages/ui/src/components/session/sidebar/DOCUMENTATION.md index a9ec584a..5f2e8cb4 100644 --- a/packages/ui/src/components/session/sidebar/DOCUMENTATION.md +++ b/packages/ui/src/components/session/sidebar/DOCUMENTATION.md @@ -81,7 +81,9 @@ make every row observe unrelated streaming updates. `SidebarTerminalActivity` shares terminal discovery with the action header and terminal panel while the sidebar is visible. One server listing covers all directories, including -collapsed projects. It preserves local mutations newer than the listing and keeps known +collapsed projects. The sidebar keeps that loop running only while a project action is +known to be running anywhere; with nothing running it lists once on mount, to pick up +runs another client started, and then stays quiet so an idle sidebar costs no polling. It preserves local mutations newer than the listing and keeps known state on failure. Terminal discovery is separate from OpenCode session bootstrap. `DirectoryActionIndicator` reads only its directory's terminal metadata. Output chunks and diff --git a/packages/ui/src/components/session/sidebar/list/SidebarTerminalActivity.tsx b/packages/ui/src/components/session/sidebar/list/SidebarTerminalActivity.tsx index 192db7fd..7f27ce4d 100644 --- a/packages/ui/src/components/session/sidebar/list/SidebarTerminalActivity.tsx +++ b/packages/ui/src/components/session/sidebar/list/SidebarTerminalActivity.tsx @@ -2,15 +2,29 @@ import React from 'react'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { groupTerminalSessionsByDirectory } from '@/lib/projectActionTerminal'; import { observeTerminalSessions } from '@/lib/terminalSessionObserver'; -import { useTerminalStore } from '@/stores/useTerminalStore'; +import { isActiveProjectActionTab, useTerminalStore } from '@/stores/useTerminalStore'; -/** Mounted with the visible sidebar, independently of row count and grouping. */ +const selectHasActiveProjectAction = (state: ReturnType): boolean => { + for (const directory of state.sessions.values()) { + if (directory.tabs.some(isActiveProjectActionTab)) return true; + } + return false; +}; + +/** + * Mounted with the visible sidebar, independently of row count and grouping. + * + * The listing loop runs only while a project action is known to be running, + * because that is the only time the indicator can change on its own. With + * nothing running the sidebar lists once on mount, to pick up runs another + * client started, and then stays quiet; the terminal panel and the actions + * header keep their own loops while open. + */ export const SidebarTerminalActivity = () => { const { terminal } = useRuntimeAPIs(); - React.useEffect(() => observeTerminalSessions( - terminal, '', - () => new Map(useTerminalStore.getState().actionMutationRevisions), - result => { + const hasActiveProjectAction = useTerminalStore(selectHasActiveProjectAction); + React.useEffect(() => { + const apply = (result: Parameters[3]>[0]) => { const store = useTerminalStore.getState(); const byDirectory = groupTerminalSessionsByDirectory(result.sessions); const directories = new Set([...store.sessions.keys(), ...byDirectory.keys()]); @@ -19,7 +33,15 @@ export const SidebarTerminalActivity = () => { startedActionMutationRevisions: result.startedActionMutationRevisions, }); } - }, - ), [terminal]); + }; + const capture = () => new Map(useTerminalStore.getState().actionMutationRevisions); + if (hasActiveProjectAction) return observeTerminalSessions(terminal, '', capture, apply); + let stop = () => {}; + stop = observeTerminalSessions(terminal, '', capture, (result) => { + apply(result); + stop(); + }); + return () => stop(); + }, [terminal, hasActiveProjectAction]); return null; }; diff --git a/packages/ui/src/components/session/sidebar/sessions/DirectoryActionIndicator.tsx b/packages/ui/src/components/session/sidebar/sessions/DirectoryActionIndicator.tsx index 7018f943..1ec8c84b 100644 --- a/packages/ui/src/components/session/sidebar/sessions/DirectoryActionIndicator.tsx +++ b/packages/ui/src/components/session/sidebar/sessions/DirectoryActionIndicator.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { Icon } from '@/components/icon/Icon'; import { useI18n } from '@/lib/i18n'; import { normalizeTerminalDirectory } from '@/lib/pathNormalization'; -import { ACTIVE_PROJECT_ACTION_LIFECYCLES, useTerminalStore } from '@/stores/useTerminalStore'; +import { isActiveProjectActionTab, useTerminalStore } from '@/stores/useTerminalStore'; import { cn } from '@/lib/utils'; /** A directory-scoped leaf subscription; output chunks do not rerender the indicator. */ @@ -10,8 +10,7 @@ export const DirectoryActionIndicator = ({ directory, className }: { directory: const { t } = useI18n(); const key = normalizeTerminalDirectory(directory); const state = useTerminalStore(React.useCallback(store => store.sessions.get(key), [key])); - const active = state?.tabs.some(tab => tab.purpose.type === 'project-action' - && tab.purpose.executionId !== null && ACTIVE_PROJECT_ACTION_LIFECYCLES.has(tab.lifecycle)); + const active = state?.tabs.some(isActiveProjectActionTab); if (!active) return null; const label = t('sessions.sidebar.projectAction.active'); return diff --git a/packages/ui/src/stores/useTerminalStore.ts b/packages/ui/src/stores/useTerminalStore.ts index 6e5bd305..689ae912 100644 --- a/packages/ui/src/stores/useTerminalStore.ts +++ b/packages/ui/src/stores/useTerminalStore.ts @@ -278,6 +278,12 @@ const nextDefaultTabLabel = (tabs: readonly TerminalTab[]): string => { return highest === 0 ? 'Terminal' : `Terminal ${highest + 1}`; }; +/** A project action the user would expect to see marked as running. */ +export const isActiveProjectActionTab = (tab: TerminalTab): boolean => + tab.purpose.type === 'project-action' + && tab.purpose.executionId !== null + && ACTIVE_PROJECT_ACTION_LIFECYCLES.has(tab.lifecycle); + const isLiveRunningTerminal = (tab: TerminalTab | undefined): boolean => Boolean(tab && tab.terminalSessionId !== null && tab.lifecycle === 'running');