feat(sidebar): show running project actions by directory
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user