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
This commit is contained in:
Bohdan Triapitsyn
2026-09-05 15:08:15 +03:00
parent f4eaa70eaa
commit 1e0d7abb6e
4 changed files with 41 additions and 12 deletions
@@ -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
@@ -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<typeof useTerminalStore.getState>): 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<Parameters<typeof observeTerminalSessions>[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;
};
@@ -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 <span className={cn('inline-flex shrink-0 items-center text-status-info', className)} role="img" aria-label={label} title={label} data-action-directory={key}>
@@ -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');