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. - 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. - 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`). - 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 React from 'react';
import type { Session } from '@opencode-ai/sdk/v2'; import type { Session } from '@opencode-ai/sdk/v2';
import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionUIStore } from '@/sync/session-ui-store';
@@ -618,6 +619,7 @@ const VisibleSessionProjects: React.FC<SessionProjectCollectionProps> = ({ topol
setSingleProjectId, setSingleProjectId,
]); ]);
return <> return <>
<SidebarTerminalActivity />
<ProjectSessionSelectionEffect <ProjectSessionSelectionEffect
projectSections={projectSections} projectSections={projectSections}
activeProjectId={view.activeProjectId} 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 React from 'react';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
@@ -1197,6 +1198,7 @@ function SessionGroupSectionBase(props: SessionGroupSectionProps): React.ReactNo
</span> </span>
) : null} ) : null}
</div> </div>
{!group.isArchivedBucket && group.directory ? <DirectoryActionIndicator directory={group.directory} className="self-center" /> : null}
</div> </div>
{group.isArchivedBucket && allGroupSessions.length > 0 ? ( {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')}> <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 type { ProjectSortOrder } from '@/stores/useSessionDisplayStore';
import { streamPerfCount } from '@/stores/utils/streamDebug'; import { streamPerfCount } from '@/stores/utils/streamDebug';
import { Icon } from '@/components/icon/Icon'; import { Icon } from '@/components/icon/Icon';
import { DirectoryActionIndicator } from '../sessions/DirectoryActionIndicator';
type SessionProjectScrollerState = Pick<SessionGroupSectionProps, type SessionProjectScrollerState = Pick<SessionGroupSectionProps,
| 'editingId' | 'editingId'
@@ -313,6 +314,7 @@ function SessionProjectScrollerComponent(props: Props): React.ReactNode {
disabled={model.singleProjectMode || view.projectSortOrder !== 'manual'} disabled={model.singleProjectMode || view.projectSortOrder !== 'manual'}
projectLabel={projectLabel} projectLabel={projectLabel}
projectDescription={projectDescription} projectDescription={projectDescription}
projectDirectory={project.normalizedPath}
projectIcon={project.icon} projectIcon={project.icon}
projectColor={project.color} projectColor={project.color}
projectIconImage={project.iconImage} projectIconImage={project.iconImage}
@@ -409,14 +411,17 @@ function SessionProjectScrollerComponent(props: Props): React.ReactNode {
aria-hidden="true" aria-hidden="true"
> >
{leadingProject && leadingProjectLabel ? ( {leadingProject && leadingProjectLabel ? (
<ProjectHeaderIdentity <>
id={leadingProject.id} <ProjectHeaderIdentity
projectLabel={leadingProjectLabel} id={leadingProject.id}
projectIcon={leadingProject.icon} projectLabel={leadingProjectLabel}
projectColor={leadingProject.color} projectIcon={leadingProject.icon}
projectIconImage={leadingProject.iconImage} projectColor={leadingProject.color}
projectIconBackground={leadingProject.iconBackground} 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" /> <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 React from 'react';
import { useSortable } from '@dnd-kit/sortable'; import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
@@ -103,6 +104,7 @@ export const ProjectHeaderIdentity: React.FC<ProjectHeaderIdentityProps> = ({
export interface SortableProjectItemProps extends ProjectIdentityProps { export interface SortableProjectItemProps extends ProjectIdentityProps {
disabled?: boolean; disabled?: boolean;
projectDescription: string; projectDescription: string;
projectDirectory?: string;
isCollapsed: boolean; isCollapsed: boolean;
isRepo: boolean; isRepo: boolean;
isDesktopShell: boolean; isDesktopShell: boolean;
@@ -132,6 +134,7 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
disabled = false, disabled = false,
projectLabel, projectLabel,
projectDescription, projectDescription,
projectDirectory,
projectIcon, projectIcon,
projectColor, projectColor,
projectIconImage, projectIconImage,
@@ -290,13 +293,14 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
// Reserve hover space for the absolute action buttons, // Reserve hover space for the absolute action buttons,
// matching the collapse-toggle branch below. // matching the collapse-toggle branch below.
isRepo && !hideDirectoryControls isRepo && !hideDirectoryControls
? (alwaysShowActions ? 'pr-20' : 'pr-7 group-hover/project:pr-20 group-focus-within/project:pr-20') ? (alwaysShowActions || isMenuOpen ? 'pr-20' : 'pr-0 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-14' : 'pr-0 group-hover/project:pr-14 group-focus-within/project:pr-14'),
)} )}
aria-label={t('sessions.sidebar.project.selectAria', { project: projectLabel })} aria-label={t('sessions.sidebar.project.selectAria', { project: projectLabel })}
> >
<ProjectHeaderIdentity id={id} projectLabel={projectLabel} projectIcon={projectIcon} projectColor={projectColor} projectIconImage={projectIconImage} projectIconBackground={projectIconBackground} /> <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" /> <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> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-h-[70vh] min-w-[220px] overflow-y-auto"> <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( 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]', '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 isRepo && !hideDirectoryControls
? (alwaysShowActions ? 'pr-20' : 'pr-7 group-hover/project:pr-20 group-focus-within/project:pr-20') ? (alwaysShowActions || isMenuOpen ? 'pr-20' : 'pr-0 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-14' : 'pr-0 group-hover/project:pr-14 group-focus-within/project:pr-14'),
)} )}
> >
<ProjectHeaderIdentity <ProjectHeaderIdentity
@@ -337,6 +341,7 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
{statusIndicator ? ( {statusIndicator ? (
<span className="ml-1 inline-flex flex-shrink-0 items-center">{statusIndicator}</span> <span className="ml-1 inline-flex flex-shrink-0 items-center">{statusIndicator}</span>
) : null} ) : null}
{projectDirectory ? <DirectoryActionIndicator directory={projectDirectory} className="ml-auto" /> : null}
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right" sideOffset={8}> <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 React from 'react';
import type { Session } from '@opencode-ai/sdk/v2'; import type { Session } from '@opencode-ai/sdk/v2';
import { ContextMenu } from '@base-ui/react/context-menu'; 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-7 group-focus-within:pr-7'
: 'group-hover:pr-3 group-focus-within:pr-3'); : 'group-hover:pr-3 group-focus-within:pr-3');
const alwaysActionPaddingClass = showQuickArchiveAction ? 'pr-13' : 'pr-7'; 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 suppressNextSelectRef = React.useRef(false);
const [isTouchPressed, setIsTouchPressed] = React.useState(false); const [isTouchPressed, setIsTouchPressed] = React.useState(false);
const editingIdRef = React.useRef(editingId); const editingIdRef = React.useRef(editingId);
@@ -1410,7 +1414,7 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
isTouchPressed && 'bg-interactive-hover/70', isTouchPressed && 'bg-interactive-hover/70',
alwaysShowActions alwaysShowActions
? (isVSCode ? revealPaddingClass : alwaysActionPaddingClass) ? (isVSCode ? revealPaddingClass : alwaysActionPaddingClass)
: revealPaddingClass, : (isSessionMenuOpen ? menuActionPaddingClass : revealPaddingClass),
)} )}
> >
<div className="flex w-full items-center min-w-0 flex-1 gap-1 overflow-hidden"> <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 would reflow the truncated title and cause a micro
horizontal shift when the status flips. */} 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> <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 {/* While a turn runs (and until its result is read) the
elapsed counter takes over this slot from the usual elapsed counter takes over this slot from the usual
goal/branch/date metadata, which stays one hover or goal/branch/date metadata, which stays one hover or
@@ -1443,13 +1459,15 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
)} )}
</span> </span>
) : (showActivityDuration || sessionGoalGlyph || showInlineBranchMarker || renderContext === 'recent') ? ( ) : (showActivityDuration || sessionGoalGlyph || showInlineBranchMarker || renderContext === 'recent') ? (
<div className="relative ml-1 flex h-4 flex-shrink-0 items-center justify-end"> <div className={cn(
<span className={cn( 'relative ml-1 flex h-4 flex-shrink-0 items-center justify-end',
'inline-flex items-center gap-1 whitespace-nowrap text-right transition-opacity duration-150',
isSessionMenuOpen isSessionMenuOpen
? 'opacity-0' ? 'hidden'
: hideOnHoverClass, : isVSCode
? 'group-hover:hidden'
: 'group-hover:hidden group-focus-within:hidden',
)}> )}>
<span className="inline-flex items-center gap-1 whitespace-nowrap text-right">
{showActivityDuration ? ( {showActivityDuration ? (
<SessionActivityDuration <SessionActivityDuration
sessionId={session.id} sessionId={session.id}
+1 -1
View File
@@ -112,7 +112,7 @@ export interface TerminalServerSession {
export interface TerminalAPI { export interface TerminalAPI {
listShells?(): Promise<TerminalShellOption[]>; 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[]>; 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. */ /** 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>; 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'; import { linearPanelI18n } from './linear-panel.i18n';
export const dict = { export const dict = {
'sessions.sidebar.projectAction.active': 'Projektaktion aktiv',
...settingsDict, ...settingsDict,
...linearIssuePickerI18n.de, ...linearIssuePickerI18n.de,
...linearPanelI18n.de, ...linearPanelI18n.de,
+1
View File
@@ -3,6 +3,7 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n'; import { linearPanelI18n } from './linear-panel.i18n';
export const dict = { export const dict = {
'sessions.sidebar.projectAction.active': 'Project action active',
...settingsDict, ...settingsDict,
...linearIssuePickerI18n.en, ...linearIssuePickerI18n.en,
...linearPanelI18n.en, ...linearPanelI18n.en,
+1
View File
@@ -4,6 +4,7 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n'; import { linearPanelI18n } from './linear-panel.i18n';
export const dict: Record<I18nKey, string> = { export const dict: Record<I18nKey, string> = {
'sessions.sidebar.projectAction.active': 'Acción del proyecto en curso',
...settingsDict, ...settingsDict,
...linearIssuePickerI18n.es, ...linearIssuePickerI18n.es,
...linearPanelI18n.es, ...linearPanelI18n.es,
+1
View File
@@ -3,6 +3,7 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n'; import { linearPanelI18n } from './linear-panel.i18n';
export const dict = { export const dict = {
'sessions.sidebar.projectAction.active': 'Action du projet en cours',
...settingsDict, ...settingsDict,
...linearIssuePickerI18n.fr, ...linearIssuePickerI18n.fr,
...linearPanelI18n.fr, ...linearPanelI18n.fr,
+1
View File
@@ -4,6 +4,7 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n'; import { linearPanelI18n } from './linear-panel.i18n';
export const dict: Record<I18nKey, string> = { export const dict: Record<I18nKey, string> = {
'sessions.sidebar.projectAction.active': 'プロジェクトアクション実行中',
...settingsDict, ...settingsDict,
...linearIssuePickerI18n.ja, ...linearIssuePickerI18n.ja,
...linearPanelI18n.ja, ...linearPanelI18n.ja,
+1
View File
@@ -4,6 +4,7 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n'; import { linearPanelI18n } from './linear-panel.i18n';
export const dict: Record<I18nKey, string> = { export const dict: Record<I18nKey, string> = {
'sessions.sidebar.projectAction.active': '프로젝트 작업 실행 중',
...settingsDict, ...settingsDict,
...linearIssuePickerI18n.ko, ...linearIssuePickerI18n.ko,
...linearPanelI18n.ko, ...linearPanelI18n.ko,
+1
View File
@@ -4,6 +4,7 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n'; import { linearPanelI18n } from './linear-panel.i18n';
export const dict: Record<I18nKey, string> = { export const dict: Record<I18nKey, string> = {
'sessions.sidebar.projectAction.active': 'Trwa wykonywanie akcji projektu',
...settingsDict, ...settingsDict,
...linearIssuePickerI18n.pl, ...linearIssuePickerI18n.pl,
...linearPanelI18n.pl, ...linearPanelI18n.pl,
@@ -4,6 +4,7 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n'; import { linearPanelI18n } from './linear-panel.i18n';
export const dict: Record<I18nKey, string> = { export const dict: Record<I18nKey, string> = {
'sessions.sidebar.projectAction.active': 'Ação do projeto em execução',
...settingsDict, ...settingsDict,
...linearIssuePickerI18n['pt-BR'], ...linearIssuePickerI18n['pt-BR'],
...linearPanelI18n['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'; import { linearPanelI18n } from './linear-panel.i18n';
export const dict = { export const dict = {
'sessions.sidebar.projectAction.active': 'Proje eylemi çalışıyor',
...settingsDict, ...settingsDict,
...linearIssuePickerI18n.tr, ...linearIssuePickerI18n.tr,
...linearPanelI18n.tr, ...linearPanelI18n.tr,
+1
View File
@@ -4,6 +4,7 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n'; import { linearPanelI18n } from './linear-panel.i18n';
export const dict: Record<I18nKey, string> = { export const dict: Record<I18nKey, string> = {
'sessions.sidebar.projectAction.active': 'Виконується дія проєкту',
...settingsDict, ...settingsDict,
...linearIssuePickerI18n.uk, ...linearIssuePickerI18n.uk,
...linearPanelI18n.uk, ...linearPanelI18n.uk,
@@ -4,6 +4,7 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n'; import { linearPanelI18n } from './linear-panel.i18n';
export const dict: Record<I18nKey, string> = { export const dict: Record<I18nKey, string> = {
'sessions.sidebar.projectAction.active': '项目操作正在运行',
...settingsDict, ...settingsDict,
...linearIssuePickerI18n['zh-CN'], ...linearIssuePickerI18n['zh-CN'],
...linearPanelI18n['zh-CN'], ...linearPanelI18n['zh-CN'],
@@ -4,6 +4,7 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n'; import { linearPanelI18n } from './linear-panel.i18n';
export const dict: Record<I18nKey, string> = { export const dict: Record<I18nKey, string> = {
'sessions.sidebar.projectAction.active': '專案操作正在執行',
...settingsDict, ...settingsDict,
...linearIssuePickerI18n['zh-TW'], ...linearIssuePickerI18n['zh-TW'],
...linearPanelI18n['zh-TW'], ...linearPanelI18n['zh-TW'],
@@ -1,3 +1,4 @@
import { normalizeProjectActionDirectory } from './projectActions';
import { getRuntimeKey } from './runtime-switch'; import { getRuntimeKey } from './runtime-switch';
import type { CreateTerminalOptions, TerminalAPI, TerminalServerSession, TerminalSession, TerminalSessionPurpose } from './api/types'; import type { CreateTerminalOptions, TerminalAPI, TerminalServerSession, TerminalSession, TerminalSessionPurpose } from './api/types';
@@ -253,3 +254,15 @@ export const reconcileTerminalSessionAuthority = (
terminalFlights.set(flightKey, flight); terminalFlights.set(flightKey, flight);
return 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 => first.push(result.sessions)));
cleanups.push(observeTerminalSessions(source.terminal, '/repo', () => new Map(), result => second.push(result.sessions))); cleanups.push(observeTerminalSessions(source.terminal, '/repo', () => new Map(), result => second.push(result.sessions)));
await tick(); await tick();
expect(source.reads).toEqual(['/repo']); expect(source.reads).toEqual(['']);
source.setRecords([running]); source.setRecords([running]);
await new Promise(resolve => setTimeout(resolve, 5100)); await new Promise(resolve => setTimeout(resolve, 5100));
expect(source.reads).toEqual(['/repo', '/repo']); expect(source.reads).toEqual(['', '']);
expect(first.at(-1)).toEqual([running]); expect(first.at(-1)).toEqual([running]);
expect(second.at(-1)).toEqual([running]); expect(second.at(-1)).toEqual([running]);
}, 10000); }, 10000);
@@ -91,3 +91,36 @@ test('hidden and offline scopes stop reads, wake on recovery, and preserve state
await tick(); await tick();
expect(source.reads).toHaveLength(3); 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 type { TerminalAPI } from './api/types';
import { reconcileTerminalSessionAuthority } from './projectActionTerminal'; import { normalizeProjectActionDirectory } from './projectActions';
import { groupTerminalSessionsByDirectory, reconcileTerminalSessionAuthority } from './projectActionTerminal';
import { getRuntimeKey, subscribeRuntimeEndpointChanged } from './runtime-switch'; import { getRuntimeKey, subscribeRuntimeEndpointChanged } from './runtime-switch';
const REFRESH_INTERVAL_MS = 5_000; const REFRESH_INTERVAL_MS = 5_000;
type AuthorityResult = NonNullable<Awaited<ReturnType<typeof reconcileTerminalSessionAuthority>>>; type AuthorityResult = NonNullable<Awaited<ReturnType<typeof reconcileTerminalSessionAuthority>>>;
type RevisionCapture = (directory: string) => ReadonlyMap<string, number>; type RevisionCapture = (directory: string) => ReadonlyMap<string, number>;
type Listener = (result: AuthorityResult) => void; type Listener = (result: AuthorityResult) => void;
type Observation = { listeners: Set<Listener>; refresh: () => void; close: () => void }; type Scope = { listeners: Set<Listener>; capture: RevisionCapture };
const observations = new WeakMap<TerminalAPI, Map<string, Observation>>(); 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 = ( export const observeTerminalSessions = (
terminal: TerminalAPI, terminal: TerminalAPI,
directory: string, directory: string,
@@ -17,35 +19,56 @@ export const observeTerminalSessions = (
listener: Listener, listener: Listener,
): (() => void) => { ): (() => void) => {
if (!terminal.listSessions) return () => {}; if (!terminal.listSessions) return () => {};
let directories = observations.get(terminal); const key = normalizeProjectActionDirectory(directory);
if (!directories) { let observation = observations.get(terminal);
directories = new Map();
observations.set(terminal, directories);
}
let observation = directories.get(directory);
if (!observation) { if (!observation) {
const listeners = new Set<Listener>(); const scopes = new Map<string, Scope>();
let closed = false; let closed = false;
let inFlight = false; let inFlight = false;
let refreshAgain = false;
let queued = false;
let timer: ReturnType<typeof setTimeout> | null = null; let timer: ReturnType<typeof setTimeout> | null = null;
let generation = 0; let generation = 0;
const active = () => document.visibilityState !== 'hidden' && navigator.onLine !== false; const active = () => document.visibilityState !== 'hidden' && navigator.onLine !== false;
const clearTimer = () => { if (timer !== null) clearTimeout(timer); timer = null; }; const clearTimer = () => { if (timer !== null) clearTimeout(timer); timer = null; };
const refresh = () => { const refresh = () => {
clearTimer(); clearTimer();
if (closed || inFlight || !active()) return; if (closed || !active()) return;
inFlight = true; if (inFlight) { refreshAgain = true; return; }
const startedGeneration = generation; // Mounting many directory consumers still starts one request.
const runtimeKey = getRuntimeKey(); if (queued) return;
void reconcileTerminalSessionAuthority(terminal, directory, { captureStartedActionMutationRevisions }) queued = true;
.then(result => { 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; if (closed || generation !== startedGeneration || runtimeKey !== getRuntimeKey() || !result) return;
for (const notify of listeners) notify(result); const byDirectory = groupTerminalSessionsByDirectory(result.sessions);
}) for (const [directory, scope] of startedScopes) {
.finally(() => { 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; 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(); }; const runtimeChanged = () => { generation += 1; refresh(); };
window.addEventListener('focus', refresh); window.addEventListener('focus', refresh);
@@ -54,7 +77,7 @@ export const observeTerminalSessions = (
document.addEventListener('visibilitychange', refresh); document.addEventListener('visibilitychange', refresh);
const stopRuntimeListener = subscribeRuntimeEndpointChanged(runtimeChanged); const stopRuntimeListener = subscribeRuntimeEndpointChanged(runtimeChanged);
observation = { observation = {
listeners, scopes,
refresh, refresh,
close: () => { close: () => {
closed = true; closed = true;
@@ -66,15 +89,21 @@ export const observeTerminalSessions = (
stopRuntimeListener(); stopRuntimeListener();
}, },
}; };
directories.set(directory, observation); observations.set(terminal, observation);
} }
observation.listeners.add(listener); let scope = observation.scopes.get(key);
observation.refresh(); if (!scope) {
scope = { listeners: new Set(), capture: captureStartedActionMutationRevisions };
observation.scopes.set(key, scope);
observation.refresh();
}
scope.listeners.add(listener);
return () => { return () => {
observation.listeners.delete(listener); scope.listeners.delete(listener);
if (observation.listeners.size > 0) return; if (scope.listeners.size > 0) return;
observation.scopes.delete(key);
if (observation.scopes.size > 0) return;
observation.close(); observation.close();
directories.delete(directory); observations.delete(terminal);
if (directories.size === 0) 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 - 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 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. stopping until the same execution exits or explicit termination failure restores running.
- `terminalSessionObserver` shares one five-second refresh loop per terminal adapter and - `terminalSessionObserver` shares one five-second refresh loop per terminal adapter across
demanded directory. Only visible, online headers/panels demand refreshes; focus and online the visible sidebar, headers and panels. The existing empty-cwd listing returns all server
recovery refresh immediately. Failed reads preserve state, the last consumer stops the loop, sessions in one request; directory subscribers receive only their own records. A sidebar
and responses from a replaced runtime cannot publish into the new runtime. 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 - 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. 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 - Server session listings capture the directory's per-action mutation revisions when the