feat(sidebar): show running project actions by directory

This commit is contained in:
Bohdan Triapitsyn
2026-09-05 14:12:07 +03:00
parent 7a3244bab2
commit 4f1f9e6650
26 changed files with 310 additions and 54 deletions
@@ -75,3 +75,23 @@ make every row observe unrelated streaming updates.
- Directory permission failures remain visible even when stale sessions are retained. Flat groups inspect every represented root/worktree directory; local Desktop may open the native picker for the exact failed directory, while other runtimes keep the ordinary Retry action.
- Pins and folder assignments are not pruned from the first startup snapshot or from optimistic mutations. Confirmed local deletion and routed external deletion clean immediately; a later authoritative omission after an established baseline covers missed external delete events.
- Pending-permission/question row badges fade with the same hover/menu-open rule as the date label, except on non-VS Code always-visible-actions rows, which reserve permanent padding and keep the badges shown. VS Code hover-reveals its actions over the row's right edge even under `alwaysShowActions`, so its badges keep fading (`selectRowBadgeVisibilityClass` in `sessions/sessionNodeItemUtils.ts`).
## Project action indicators
`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
state on failure. Terminal discovery is separate from OpenCode session bootstrap.
`DirectoryActionIndicator` reads only its directory's terminal metadata. Output chunks and
unrelated directories do not rerender it. It displays a static `pulse` icon in `status.info`
for live project actions, including auto-discovered commands. Persisted idle tabs and ordinary
interactive terminals do not indicate activity. This indicates process activity, not server
readiness.
Grouped views show the icon on project-root and worktree headers. Flat project views show
it on the project-root header and on sessions in linked worktrees. Recent shows it on every
session with an active action in its own directory. Archived buckets do not show action
indicators. Indicators stay inside the existing row/header action-padding boundary, so
hover, keyboard focus, and always-visible action buttons move them left without hiding them.
@@ -1,3 +1,4 @@
import { SidebarTerminalActivity } from './SidebarTerminalActivity';
import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2';
import { useSessionUIStore } from '@/sync/session-ui-store';
@@ -618,6 +619,7 @@ const VisibleSessionProjects: React.FC<SessionProjectCollectionProps> = ({ topol
setSingleProjectId,
]);
return <>
<SidebarTerminalActivity />
<ProjectSessionSelectionEffect
projectSections={projectSections}
activeProjectId={view.activeProjectId}
@@ -0,0 +1,25 @@
import React from 'react';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { groupTerminalSessionsByDirectory } from '@/lib/projectActionTerminal';
import { observeTerminalSessions } from '@/lib/terminalSessionObserver';
import { useTerminalStore } from '@/stores/useTerminalStore';
/** Mounted with the visible sidebar, independently of row count and grouping. */
export const SidebarTerminalActivity = () => {
const { terminal } = useRuntimeAPIs();
React.useEffect(() => observeTerminalSessions(
terminal, '',
() => new Map(useTerminalStore.getState().actionMutationRevisions),
result => {
const store = useTerminalStore.getState();
const byDirectory = groupTerminalSessionsByDirectory(result.sessions);
const directories = new Set([...store.sessions.keys(), ...byDirectory.keys()]);
for (const directory of directories) {
store.reconcileServerSessions(directory, byDirectory.get(directory) ?? [], {
startedActionMutationRevisions: result.startedActionMutationRevisions,
});
}
},
), [terminal]);
return null;
};
@@ -1,3 +1,4 @@
import { DirectoryActionIndicator } from '../sessions/DirectoryActionIndicator';
import React from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useShallow } from 'zustand/react/shallow';
@@ -1197,6 +1198,7 @@ function SessionGroupSectionBase(props: SessionGroupSectionProps): React.ReactNo
</span>
) : null}
</div>
{!group.isArchivedBucket && group.directory ? <DirectoryActionIndicator directory={group.directory} className="self-center" /> : null}
</div>
{group.isArchivedBucket && allGroupSessions.length > 0 ? (
<div className={cn('absolute right-0.5 top-1/2 -translate-y-1/2 z-10 transition-opacity', alwaysShowActions ? 'opacity-100' : 'opacity-0 group-hover/gh:opacity-100 group-focus-within/gh:opacity-100')}>
@@ -20,6 +20,7 @@ import { useI18n } from '@/lib/i18n';
import type { ProjectSortOrder } from '@/stores/useSessionDisplayStore';
import { streamPerfCount } from '@/stores/utils/streamDebug';
import { Icon } from '@/components/icon/Icon';
import { DirectoryActionIndicator } from '../sessions/DirectoryActionIndicator';
type SessionProjectScrollerState = Pick<SessionGroupSectionProps,
| 'editingId'
@@ -313,6 +314,7 @@ function SessionProjectScrollerComponent(props: Props): React.ReactNode {
disabled={model.singleProjectMode || view.projectSortOrder !== 'manual'}
projectLabel={projectLabel}
projectDescription={projectDescription}
projectDirectory={project.normalizedPath}
projectIcon={project.icon}
projectColor={project.color}
projectIconImage={project.iconImage}
@@ -409,14 +411,17 @@ function SessionProjectScrollerComponent(props: Props): React.ReactNode {
aria-hidden="true"
>
{leadingProject && leadingProjectLabel ? (
<ProjectHeaderIdentity
id={leadingProject.id}
projectLabel={leadingProjectLabel}
projectIcon={leadingProject.icon}
projectColor={leadingProject.color}
projectIconImage={leadingProject.iconImage}
projectIconBackground={leadingProject.iconBackground}
/>
<>
<ProjectHeaderIdentity
id={leadingProject.id}
projectLabel={leadingProjectLabel}
projectIcon={leadingProject.icon}
projectColor={leadingProject.color}
projectIconImage={leadingProject.iconImage}
projectIconBackground={leadingProject.iconBackground}
/>
<DirectoryActionIndicator directory={leadingProject.normalizedPath} className="ml-auto" />
</>
) : (
<>
<Icon name="history" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/80" />
@@ -1,3 +1,4 @@
import { DirectoryActionIndicator } from '../sessions/DirectoryActionIndicator';
import React from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
@@ -103,6 +104,7 @@ export const ProjectHeaderIdentity: React.FC<ProjectHeaderIdentityProps> = ({
export interface SortableProjectItemProps extends ProjectIdentityProps {
disabled?: boolean;
projectDescription: string;
projectDirectory?: string;
isCollapsed: boolean;
isRepo: boolean;
isDesktopShell: boolean;
@@ -132,6 +134,7 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
disabled = false,
projectLabel,
projectDescription,
projectDirectory,
projectIcon,
projectColor,
projectIconImage,
@@ -290,13 +293,14 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
// Reserve hover space for the absolute action buttons,
// matching the collapse-toggle branch below.
isRepo && !hideDirectoryControls
? (alwaysShowActions ? 'pr-20' : 'pr-7 group-hover/project:pr-20 group-focus-within/project:pr-20')
: (alwaysShowActions ? 'pr-14' : 'pr-7 group-hover/project:pr-14 group-focus-within/project:pr-14'),
? (alwaysShowActions || isMenuOpen ? 'pr-20' : 'pr-0 group-hover/project:pr-20 group-focus-within/project:pr-20')
: (alwaysShowActions || isMenuOpen ? 'pr-14' : 'pr-0 group-hover/project:pr-14 group-focus-within/project:pr-14'),
)}
aria-label={t('sessions.sidebar.project.selectAria', { project: projectLabel })}
>
<ProjectHeaderIdentity id={id} projectLabel={projectLabel} projectIcon={projectIcon} projectColor={projectColor} projectIconImage={projectIconImage} projectIconBackground={projectIconBackground} />
<Icon name="arrow-down-s" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
{projectDirectory ? <DirectoryActionIndicator directory={projectDirectory} className="ml-auto" /> : null}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-h-[70vh] min-w-[220px] overflow-y-auto">
@@ -320,8 +324,8 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
className={cn(
'flex-1 min-w-0 flex items-center gap-1.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 rounded-md cursor-grab active:cursor-grabbing transition-[padding]',
isRepo && !hideDirectoryControls
? (alwaysShowActions ? 'pr-20' : 'pr-7 group-hover/project:pr-20 group-focus-within/project:pr-20')
: (alwaysShowActions ? 'pr-14' : 'pr-7 group-hover/project:pr-14 group-focus-within/project:pr-14'),
? (alwaysShowActions || isMenuOpen ? 'pr-20' : 'pr-0 group-hover/project:pr-20 group-focus-within/project:pr-20')
: (alwaysShowActions || isMenuOpen ? 'pr-14' : 'pr-0 group-hover/project:pr-14 group-focus-within/project:pr-14'),
)}
>
<ProjectHeaderIdentity
@@ -337,6 +341,7 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
{statusIndicator ? (
<span className="ml-1 inline-flex flex-shrink-0 items-center">{statusIndicator}</span>
) : null}
{projectDirectory ? <DirectoryActionIndicator directory={projectDirectory} className="ml-auto" /> : null}
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
@@ -0,0 +1,69 @@
import { afterEach, beforeEach, expect, test } from 'bun:test';
import React, { act, Profiler } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { Window } from 'happy-dom';
import { I18nProvider } from '@/lib/i18n';
import { useTerminalStore } from '@/stores/useTerminalStore';
import { DirectoryActionIndicator } from './DirectoryActionIndicator';
let browser: Window;
let root: Root;
const descriptors = new Map<string, PropertyDescriptor | undefined>();
beforeEach(() => {
browser = new Window({ url: 'http://localhost' });
for (const [key, value] of Object.entries({ window: browser, document: browser.document, navigator: browser.navigator, HTMLElement: browser.HTMLElement, IS_REACT_ACT_ENVIRONMENT: true })) {
descriptors.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { value, configurable: true });
}
const host = document.createElement('div');
document.body.append(host);
root = createRoot(host);
useTerminalStore.getState().clearAll();
});
afterEach(async () => {
await act(async () => root.unmount());
useTerminalStore.getState().clearAll();
await browser.happyDOM.close();
for (const [key, descriptor] of descriptors) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
});
test('shows only live actions in its exact directory and follows start, stop and exit', async () => {
await act(async () => root.render(<I18nProvider><DirectoryActionIndicator directory="/repo/" /><DirectoryActionIndicator directory="/repo/worktree" /></I18nProvider>));
const store = useTerminalStore.getState();
let tab = '';
await act(async () => { tab = store.createTab('/repo'); store.setTabSessionId('/repo', tab, 'interactive'); });
expect(document.querySelectorAll('[data-action-directory]')).toHaveLength(0);
await act(async () => { store.allocateActionExecution('/repo', tab, 'dev'); });
expect(document.querySelectorAll('[data-action-directory]')).toHaveLength(1);
expect(document.querySelector('[data-action-directory]')?.getAttribute('data-action-directory')).toBe('/repo');
expect(document.querySelector('use')?.getAttribute('href')).toBe('#oc-pulse');
await act(async () => store.setTabLifecycle('/repo', tab, 'stopping'));
expect(document.querySelectorAll('[data-action-directory]')).toHaveLength(1);
await act(async () => store.setTabLifecycle('/repo', tab, 'exited'));
expect(document.querySelectorAll('[data-action-directory]')).toHaveLength(0);
});
test('500 indicators do not rerender for output or another directory changing', async () => {
const store = useTerminalStore.getState();
const tab = store.createTab('/repo');
store.allocateActionExecution('/repo', tab, 'dev');
let renders = 0;
await act(async () => root.render(<I18nProvider><Profiler id="indicators" onRender={() => { renders += 1; }}>
{Array.from({ length: 500 }, (_, index) => <DirectoryActionIndicator key={index} directory="/repo" />)}
</Profiler></I18nProvider>));
expect(document.querySelectorAll('[data-action-directory]')).toHaveLength(500);
renders = 0;
await act(async () => {
for (let index = 0; index < 1000; index += 1) store.appendToBuffer('/repo', tab, 'output\n', index);
const other = store.createTab('/unrelated');
store.allocateActionExecution('/unrelated', other, 'dev');
});
expect(store.getBuffer('/repo', tab).lastSequence).toBe(999);
expect(renders).toBe(0);
await act(async () => store.setTabLifecycle('/repo', tab, 'exited'));
expect(renders).toBeGreaterThan(0);
expect(document.querySelectorAll('[data-action-directory]')).toHaveLength(0);
});
@@ -0,0 +1,20 @@
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { useI18n } from '@/lib/i18n';
import { normalizeProjectActionDirectory } from '@/lib/projectActions';
import { ACTIVE_PROJECT_ACTION_LIFECYCLES, useTerminalStore } from '@/stores/useTerminalStore';
import { cn } from '@/lib/utils';
/** A directory-scoped leaf subscription; output chunks do not rerender the indicator. */
export const DirectoryActionIndicator = ({ directory, className }: { directory: string; className?: string }) => {
const { t } = useI18n();
const key = normalizeProjectActionDirectory(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));
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}>
<Icon name="pulse" className="size-3.5" />
</span>;
};
@@ -1,3 +1,4 @@
import { DirectoryActionIndicator } from './DirectoryActionIndicator';
import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2';
import { ContextMenu } from '@base-ui/react/context-menu';
@@ -335,6 +336,9 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
? 'group-hover:pr-7 group-focus-within:pr-7'
: 'group-hover:pr-3 group-focus-within:pr-3');
const alwaysActionPaddingClass = showQuickArchiveAction ? 'pr-13' : 'pr-7';
const menuActionPaddingClass = isVSCode
? (showQuickArchiveAction ? 'pr-18' : 'pr-14')
: (showQuickArchiveAction ? 'pr-7' : 'pr-3');
const suppressNextSelectRef = React.useRef(false);
const [isTouchPressed, setIsTouchPressed] = React.useState(false);
const editingIdRef = React.useRef(editingId);
@@ -1410,7 +1414,7 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
isTouchPressed && 'bg-interactive-hover/70',
alwaysShowActions
? (isVSCode ? revealPaddingClass : alwaysActionPaddingClass)
: revealPaddingClass,
: (isSessionMenuOpen ? menuActionPaddingClass : revealPaddingClass),
)}
>
<div className="flex w-full items-center min-w-0 flex-1 gap-1 overflow-hidden">
@@ -1418,6 +1422,18 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
would reflow the truncated title and cause a micro
horizontal shift when the status flips. */}
<div className={cn('block min-w-0 flex-1 truncate typography-ui-label font-normal', isActive ? 'text-primary' : needsAttention ? 'text-foreground' : 'text-foreground/80')}>{renderHighlightedText(sessionTitle, normalizedSessionSearchQuery)}</div>
{!archivedBucket && sessionDirectory && (renderContext === 'recent'
|| (sessionGroupingMode === 'flat' && node.worktree
&& normalizePath(node.worktree.path) !== normalizePath(node.worktree.projectDirectory))) ? (
<DirectoryActionIndicator
directory={sessionDirectory}
className={alwaysShowActions ? undefined : isSessionMenuOpen
? 'mr-1'
: isVSCode
? 'group-hover:mr-1'
: 'group-hover:mr-1 group-focus-within:mr-1'}
/>
) : null}
{/* While a turn runs (and until its result is read) the
elapsed counter takes over this slot from the usual
goal/branch/date metadata, which stays one hover or
@@ -1443,13 +1459,15 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
)}
</span>
) : (showActivityDuration || sessionGoalGlyph || showInlineBranchMarker || renderContext === 'recent') ? (
<div className="relative ml-1 flex h-4 flex-shrink-0 items-center justify-end">
<span className={cn(
'inline-flex items-center gap-1 whitespace-nowrap text-right transition-opacity duration-150',
<div className={cn(
'relative ml-1 flex h-4 flex-shrink-0 items-center justify-end',
isSessionMenuOpen
? 'opacity-0'
: hideOnHoverClass,
? 'hidden'
: isVSCode
? 'group-hover:hidden'
: 'group-hover:hidden group-focus-within:hidden',
)}>
<span className="inline-flex items-center gap-1 whitespace-nowrap text-right">
{showActivityDuration ? (
<SessionActivityDuration
sessionId={session.id}
+1 -1
View File
@@ -112,7 +112,7 @@ export interface TerminalServerSession {
export interface TerminalAPI {
listShells?(): Promise<TerminalShellOption[]>;
/** Server-side sessions for a working directory; absent on runtimes without a server terminal list. */
/** Server-side sessions for a working directory, or all directories when cwd is empty; absent on runtimes without a server terminal list. */
listSessions?(cwd: string): Promise<TerminalServerSession[]>;
/** Marks the sessions as active so the server's idle sweep does not reap terminals an open client still shows. */
touchSessions?(sessionIds: string[]): Promise<void>;
+1
View File
@@ -3,6 +3,7 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n';
export const dict = {
'sessions.sidebar.projectAction.active': 'Projektaktion aktiv',
...settingsDict,
...linearIssuePickerI18n.de,
...linearPanelI18n.de,
+1
View File
@@ -3,6 +3,7 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n';
export const dict = {
'sessions.sidebar.projectAction.active': 'Project action active',
...settingsDict,
...linearIssuePickerI18n.en,
...linearPanelI18n.en,
+1
View File
@@ -4,6 +4,7 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n';
export const dict: Record<I18nKey, string> = {
'sessions.sidebar.projectAction.active': 'Acción del proyecto en curso',
...settingsDict,
...linearIssuePickerI18n.es,
...linearPanelI18n.es,
+1
View File
@@ -3,6 +3,7 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n';
export const dict = {
'sessions.sidebar.projectAction.active': 'Action du projet en cours',
...settingsDict,
...linearIssuePickerI18n.fr,
...linearPanelI18n.fr,
+1
View File
@@ -4,6 +4,7 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n';
export const dict: Record<I18nKey, string> = {
'sessions.sidebar.projectAction.active': 'プロジェクトアクション実行中',
...settingsDict,
...linearIssuePickerI18n.ja,
...linearPanelI18n.ja,
+1
View File
@@ -4,6 +4,7 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n';
export const dict: Record<I18nKey, string> = {
'sessions.sidebar.projectAction.active': '프로젝트 작업 실행 중',
...settingsDict,
...linearIssuePickerI18n.ko,
...linearPanelI18n.ko,
+1
View File
@@ -4,6 +4,7 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n';
export const dict: Record<I18nKey, string> = {
'sessions.sidebar.projectAction.active': 'Trwa wykonywanie akcji projektu',
...settingsDict,
...linearIssuePickerI18n.pl,
...linearPanelI18n.pl,
@@ -4,6 +4,7 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n';
export const dict: Record<I18nKey, string> = {
'sessions.sidebar.projectAction.active': 'Ação do projeto em execução',
...settingsDict,
...linearIssuePickerI18n['pt-BR'],
...linearPanelI18n['pt-BR'],
+1
View File
@@ -3,6 +3,7 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n';
export const dict = {
'sessions.sidebar.projectAction.active': 'Proje eylemi çalışıyor',
...settingsDict,
...linearIssuePickerI18n.tr,
...linearPanelI18n.tr,
+1
View File
@@ -4,6 +4,7 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n';
export const dict: Record<I18nKey, string> = {
'sessions.sidebar.projectAction.active': 'Виконується дія проєкту',
...settingsDict,
...linearIssuePickerI18n.uk,
...linearPanelI18n.uk,
@@ -4,6 +4,7 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n';
export const dict: Record<I18nKey, string> = {
'sessions.sidebar.projectAction.active': '项目操作正在运行',
...settingsDict,
...linearIssuePickerI18n['zh-CN'],
...linearPanelI18n['zh-CN'],
@@ -4,6 +4,7 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n';
export const dict: Record<I18nKey, string> = {
'sessions.sidebar.projectAction.active': '專案操作正在執行',
...settingsDict,
...linearIssuePickerI18n['zh-TW'],
...linearPanelI18n['zh-TW'],
@@ -1,3 +1,4 @@
import { normalizeProjectActionDirectory } from './projectActions';
import { getRuntimeKey } from './runtime-switch';
import type { CreateTerminalOptions, TerminalAPI, TerminalServerSession, TerminalSession, TerminalSessionPurpose } from './api/types';
@@ -253,3 +254,15 @@ export const reconcileTerminalSessionAuthority = (
terminalFlights.set(flightKey, flight);
return flight;
};
export const groupTerminalSessionsByDirectory = (sessions: TerminalServerSession[]): Map<string, TerminalServerSession[]> => {
const groups = new Map<string, TerminalServerSession[]>();
for (const session of sessions) {
const directory = normalizeProjectActionDirectory(session.cwd);
const group = groups.get(directory);
if (group) group.push(session);
else groups.set(directory, [session]);
}
return groups;
};
@@ -49,10 +49,10 @@ test('visible consumers share one loop and discover a later peer run without int
cleanups.push(observeTerminalSessions(source.terminal, '/repo', () => new Map(), result => first.push(result.sessions)));
cleanups.push(observeTerminalSessions(source.terminal, '/repo', () => new Map(), result => second.push(result.sessions)));
await tick();
expect(source.reads).toEqual(['/repo']);
expect(source.reads).toEqual(['']);
source.setRecords([running]);
await new Promise(resolve => setTimeout(resolve, 5100));
expect(source.reads).toEqual(['/repo', '/repo']);
expect(source.reads).toEqual(['', '']);
expect(first.at(-1)).toEqual([running]);
expect(second.at(-1)).toEqual([running]);
}, 10000);
@@ -91,3 +91,36 @@ test('hidden and offline scopes stop reads, wake on recovery, and preserve state
await tick();
expect(source.reads).toHaveLength(3);
});
test('one global request serves 100 directories and an all-directory consumer', async () => {
const source = createTerminal();
source.setRecords([running]);
const seen = new Map<string, TerminalServerSession[]>();
for (let index = 0; index < 100; index += 1) {
const directory = index === 0 ? '/repo/' : `/project-${index}`;
cleanups.push(observeTerminalSessions(source.terminal, directory, () => new Map(), result => seen.set(directory, result.sessions)));
}
cleanups.push(observeTerminalSessions(source.terminal, '', () => new Map(), result => seen.set('all', result.sessions)));
await tick();
expect(source.reads).toEqual(['']);
expect(seen.get('/repo/')).toEqual([running]);
expect(seen.get('/project-1')).toEqual([]);
expect(seen.get('all')).toEqual([running]);
});
test('rejects a replaced runtime response and refreshes the new runtime', async () => {
const source = createTerminal();
let resolvePending: (sessions: TerminalServerSession[]) => void = () => {};
const pending = new Promise<TerminalServerSession[]>(resolve => { resolvePending = resolve; });
let calls = 0;
source.terminal.listSessions = () => ++calls === 1 ? pending : Promise.resolve([]);
const seen: TerminalServerSession[][] = [];
cleanups.push(observeTerminalSessions(source.terminal, '/repo', () => new Map([['/repo\0build', 3]]), result => seen.push(result.sessions)));
await tick();
browser.dispatchEvent(new browser.CustomEvent('openchamber:runtime-endpoint-changed', { detail: {} }));
resolvePending([running]);
await tick();
expect(seen).toEqual([[]]);
expect(calls).toBe(2);
});
+58 -29
View File
@@ -1,15 +1,17 @@
import type { TerminalAPI } from './api/types';
import { reconcileTerminalSessionAuthority } from './projectActionTerminal';
import { normalizeProjectActionDirectory } from './projectActions';
import { groupTerminalSessionsByDirectory, reconcileTerminalSessionAuthority } from './projectActionTerminal';
import { getRuntimeKey, subscribeRuntimeEndpointChanged } from './runtime-switch';
const REFRESH_INTERVAL_MS = 5_000;
type AuthorityResult = NonNullable<Awaited<ReturnType<typeof reconcileTerminalSessionAuthority>>>;
type RevisionCapture = (directory: string) => ReadonlyMap<string, number>;
type Listener = (result: AuthorityResult) => void;
type Observation = { listeners: Set<Listener>; refresh: () => void; close: () => void };
const observations = new WeakMap<TerminalAPI, Map<string, Observation>>();
type Scope = { listeners: Set<Listener>; capture: RevisionCapture };
type Observation = { scopes: Map<string, Scope>; refresh: () => void; close: () => void };
const observations = new WeakMap<TerminalAPI, Observation>();
/** One visible-demand loop per adapter/directory, shared by the header and panel. */
/** One visible-demand loop per adapter. An empty directory observes all sessions. */
export const observeTerminalSessions = (
terminal: TerminalAPI,
directory: string,
@@ -17,35 +19,56 @@ export const observeTerminalSessions = (
listener: Listener,
): (() => void) => {
if (!terminal.listSessions) return () => {};
let directories = observations.get(terminal);
if (!directories) {
directories = new Map();
observations.set(terminal, directories);
}
let observation = directories.get(directory);
const key = normalizeProjectActionDirectory(directory);
let observation = observations.get(terminal);
if (!observation) {
const listeners = new Set<Listener>();
const scopes = new Map<string, Scope>();
let closed = false;
let inFlight = false;
let refreshAgain = false;
let queued = false;
let timer: ReturnType<typeof setTimeout> | null = null;
let generation = 0;
const active = () => document.visibilityState !== 'hidden' && navigator.onLine !== false;
const clearTimer = () => { if (timer !== null) clearTimeout(timer); timer = null; };
const refresh = () => {
clearTimer();
if (closed || inFlight || !active()) return;
inFlight = true;
const startedGeneration = generation;
const runtimeKey = getRuntimeKey();
void reconcileTerminalSessionAuthority(terminal, directory, { captureStartedActionMutationRevisions })
.then(result => {
if (closed || !active()) return;
if (inFlight) { refreshAgain = true; return; }
// Mounting many directory consumers still starts one request.
if (queued) return;
queued = true;
queueMicrotask(() => {
queued = false;
if (closed || !active()) return;
inFlight = true;
refreshAgain = false;
const startedGeneration = generation;
const runtimeKey = getRuntimeKey();
const startedScopes = new Map(scopes);
void reconcileTerminalSessionAuthority(terminal, '', {
captureStartedActionMutationRevisions: () => {
const revisions = new Map<string, number>();
for (const [directory, scope] of startedScopes) {
for (const [action, revision] of scope.capture(directory)) revisions.set(action, revision);
}
return revisions;
},
}).then(result => {
if (closed || generation !== startedGeneration || runtimeKey !== getRuntimeKey() || !result) return;
for (const notify of listeners) notify(result);
})
.finally(() => {
const byDirectory = groupTerminalSessionsByDirectory(result.sessions);
for (const [directory, scope] of startedScopes) {
if (scopes.get(directory) !== scope) continue;
const sessions = directory ? byDirectory.get(directory) ?? [] : result.sessions;
for (const notify of scope.listeners) notify({ ...result, sessions });
}
}).finally(() => {
inFlight = false;
if (!closed && active()) timer = setTimeout(refresh, REFRESH_INTERVAL_MS);
if (closed || !active()) return;
if (refreshAgain) refresh();
else timer = setTimeout(refresh, REFRESH_INTERVAL_MS);
});
});
};
const runtimeChanged = () => { generation += 1; refresh(); };
window.addEventListener('focus', refresh);
@@ -54,7 +77,7 @@ export const observeTerminalSessions = (
document.addEventListener('visibilitychange', refresh);
const stopRuntimeListener = subscribeRuntimeEndpointChanged(runtimeChanged);
observation = {
listeners,
scopes,
refresh,
close: () => {
closed = true;
@@ -66,15 +89,21 @@ export const observeTerminalSessions = (
stopRuntimeListener();
},
};
directories.set(directory, observation);
observations.set(terminal, observation);
}
observation.listeners.add(listener);
observation.refresh();
let scope = observation.scopes.get(key);
if (!scope) {
scope = { listeners: new Set(), capture: captureStartedActionMutationRevisions };
observation.scopes.set(key, scope);
observation.refresh();
}
scope.listeners.add(listener);
return () => {
observation.listeners.delete(listener);
if (observation.listeners.size > 0) return;
scope.listeners.delete(listener);
if (scope.listeners.size > 0) return;
observation.scopes.delete(key);
if (observation.scopes.size > 0) return;
observation.close();
directories.delete(directory);
if (directories.size === 0) observations.delete(terminal);
observations.delete(terminal);
};
};
+7 -4
View File
@@ -130,10 +130,13 @@ Invariants to preserve when editing:
- Reconciliation selects one record per action before updating tabs. A running execution wins
over retained exited records independently of listing order. An in-progress stop remains
stopping until the same execution exits or explicit termination failure restores running.
- `terminalSessionObserver` shares one five-second refresh loop per terminal adapter and
demanded directory. Only visible, online headers/panels demand refreshes; focus and online
recovery refresh immediately. Failed reads preserve state, the last consumer stops the loop,
and responses from a replaced runtime cannot publish into the new runtime.
- `terminalSessionObserver` shares one five-second refresh loop per terminal adapter across
the visible sidebar, headers and panels. The existing empty-cwd listing returns all server
sessions in one request; directory subscribers receive only their own records. A sidebar
subscriber reconciles the complete list, including omitted known action directories.
Focus and online recovery refresh immediately. Hidden/offline clients pause, failed reads
preserve state, and the last consumer stops the loop. Replaced runtimes cannot publish old
responses. Mutation revisions are captured for every subscribed scope before the request.
- Passive action adoption may restore output but has no launch-time authority to open browser
tabs. Preview navigation belongs to the initiating host directory even for a parent action.
- Server session listings capture the directory's per-action mutation revisions when the