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}