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.
|
- 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}
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user