Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ each missing what the other had.
| `useFlowChatSearch.test.ts` | exact matching-block decoration, occurrence counting, and search navigation state |
| `flowChatSearchDom.test.ts` | concrete text ranges and independent highlight ownership across rows and panes |
| `flowChatSearchPresentation.test.ts` | visible source highlighting and single-line marker geometry, wrapping, scrolling, and clipping |
| `FlowChatHeader.test.tsx` | shared SearchField composition, result controls, and input identity while expanding |
| `FlowChatHeader.test.tsx` | shared SearchField composition, result controls, input identity while expanding, and the default active-only Agent tree toggle |
| `SessionTreePopover.test.tsx` | Agent selection/cancellation/deletion menus, type-only metadata, active branch filtering with ancestor retention, restoring all agents, and the active empty state |
| `../../services/deleteSessionTreeBranch.test.ts` | Unloaded descendant deletion in child-first order, remote location forwarding, failure retention, and device surface guards |

## Manual

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,8 @@ vi.mock('./SessionFilesBadge', () => ({
}));

vi.mock('./SessionTreePopover', () => ({
SessionTreePopover: ({ embedded }: { embedded?: boolean }) => embedded
? <div data-testid="flowchat-header-session-tree-content" />
SessionTreePopover: ({ embedded, activeOnly }: { embedded?: boolean; activeOnly?: boolean }) => embedded
? <div data-testid="flowchat-header-session-tree-content" data-active-only={activeOnly} />
: null,
}));

Expand Down Expand Up @@ -443,6 +443,12 @@ describe('FlowChatHeader', () => {
expect(panel?.querySelector('[data-testid="flowchat-header-pull-requests-empty"]')?.textContent)
.toBe('flowChatHeader.pullRequestEmpty');
expect(panel?.querySelector('[data-testid="flowchat-header-session-overview-back"]')).toBeNull();
const activeSwitch = panel?.querySelector<HTMLInputElement>('[aria-label="flowChatHeader.agentTreeActiveOnly"]');
expect(activeSwitch?.checked).toBe(true);
expect(panel?.querySelector('[data-testid="flowchat-header-session-tree-content"]')?.getAttribute('data-active-only')).toBe('true');
await act(async () => activeSwitch?.click());
expect(activeSwitch?.checked).toBe(false);
expect(panel?.querySelector('[data-testid="flowchat-header-session-tree-content"]')?.getAttribute('data-active-only')).toBe('false');
});

it('shows the empty background terminal state without navigating', async () => {
Expand Down
14 changes: 13 additions & 1 deletion src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { Keyboard, Square } from 'lucide-react';
import { OverflowText, Icon, IconButton, Menu, MenuItem, SearchField, Tooltip } from '@openbitfun/ui';
import { OverflowText, Icon, IconButton, Menu, MenuItem, SearchField, Switch, Tooltip } from '@openbitfun/ui';
import { SceneChromeContribution } from '@/app/components/SceneTopBar/SceneChrome';
import { useSceneChromeContext } from '@/app/components/SceneTopBar/sceneChromeContext';
import { useTranslation } from 'react-i18next';
Expand Down Expand Up @@ -72,6 +72,7 @@ export interface FlowChatHeaderProps {
hasActiveSessionTreeDescendants?: boolean;
/** Cancel one running session from the active Agent tree without cancelling descendants. */
onCancelSessionTreeSession?: (selection: SessionTreeSelection) => Promise<boolean>;
onDeleteSessionTreeSession?: (selection: SessionTreeSelection) => Promise<boolean>;
/** Long-running background commands launched by the active parent session. */
backgroundCommands?: FlowChatHeaderCommandSummary[];
/** Open a read-only output panel for a background command. */
Expand Down Expand Up @@ -101,6 +102,7 @@ export const FlowChatHeader: React.FC<FlowChatHeaderProps> = ({
onOpenSessionTreeSession,
hasActiveSessionTreeDescendants = false,
onCancelSessionTreeSession,
onDeleteSessionTreeSession,
backgroundCommands = [],
onOpenBackgroundCommandOutput,
onRequestBackgroundCommandInput,
Expand All @@ -114,6 +116,7 @@ export const FlowChatHeader: React.FC<FlowChatHeaderProps> = ({
const sceneChrome = useSceneChromeContext();
const isSceneChromeActive = sceneChrome?.activeSceneId === 'session';
const [isSessionOverviewOpen, setIsSessionOverviewOpen] = useState(false);
const [activeAgentsOnly, setActiveAgentsOnly] = useState(true);
const [isBackgroundCommandSectionMenuOpen, setIsBackgroundCommandSectionMenuOpen] = useState(false);
const [openBackgroundCommandMenuId, setOpenBackgroundCommandMenuId] = useState<string | null>(null);
const [isSearchOpen, setIsSearchOpen] = useState(false);
Expand Down Expand Up @@ -759,6 +762,13 @@ export const FlowChatHeader: React.FC<FlowChatHeaderProps> = ({
<span className="flowchat-header__session-overview-section-status" aria-hidden="true" />
) : null}
</span>
<Tooltip content={t('flowChatHeader.agentTreeActiveOnly')}>
<Switch
checked={activeAgentsOnly}
aria-label={t('flowChatHeader.agentTreeActiveOnly')}
onChange={(event) => setActiveAgentsOnly(event.currentTarget.checked)}
/>
</Tooltip>
</div>
{sessionId ? (
<SessionTreePopover
Expand All @@ -767,7 +777,9 @@ export const FlowChatHeader: React.FC<FlowChatHeaderProps> = ({
onSelectSession={onOpenSessionTreeSession}
hasActiveDescendants={hasActiveSessionTreeDescendants}
onCancelSession={onCancelSessionTreeSession}
onDeleteSession={onDeleteSessionTreeSession}
embedded
activeOnly={activeAgentsOnly}
open={isSessionOverviewOpen}
onRequestClose={() => closeSessionOverview(false)}
t={t}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ import {
import { scheduleAfterStartupPaint } from '@/shared/utils/startupTaskScheduling';
import { agentAPI } from '@/infrastructure/api/service-api/AgentAPI';
import { notificationService } from '@/shared/notification-system';
import { confirmDanger } from '@/infrastructure/confirm-dialog';
import { getActiveSurfaceScope, isSurfaceChangedError } from '@/infrastructure/peer-device/deviceSurface';
import { deleteSessionTreeBranch } from '../../services/deleteSessionTreeBranch';
import { resolveSessionDriverId } from '../../session-drivers/resolve';
import {
clearHistorySessionOpenTransition,
getHistorySessionOpenTransitionSnapshot,
Expand Down Expand Up @@ -2378,6 +2382,34 @@ export const ModernFlowChatContainer: React.FC<ModernFlowChatContainerProps> = (
}
}, [t]);

const handleDeleteSessionTreeSession = useCallback(async (selection: SessionTreeSelection) => {
if (selection.isRoot) return false;
if (activeSession && resolveSessionDriverId(activeSession.sessionId, activeSession) === 'dispatch') {
notificationService.error(t('flowChatHeader.agentTreeDeleteUnsupported'), { duration: 5000 });
return false;
}
const scope = getActiveSurfaceScope();
const workspacePath = selection.workspacePath || activeSession?.workspacePath;
const remoteConnectionId = selection.remoteConnectionId || activeSession?.remoteConnectionId;
const remoteSshHost = selection.remoteSshHost || activeSession?.remoteSshHost;
try {
const confirmed = await confirmDanger(
t('flowChatHeader.agentTreeDelete'),
t('flowChatHeader.agentTreeDeleteConfirm', { name: selection.displayTitle }),
{ confirmText: t('flowChatHeader.agentTreeDelete') },
);
if (!confirmed) return false;
if (!workspacePath) throw new Error('Agent session workspace path is missing');
await deleteSessionTreeBranch({ sessionId: selection.sessionId, workspacePath, remoteConnectionId, remoteSshHost }, scope);
return true;
} catch (error) {
if (!isSurfaceChangedError(error)) {
notificationService.error(t('flowChatHeader.agentTreeDeleteFailed'), { duration: 5000 });
}
return false;
}
}, [activeSession, t]);

const handleOpenBackgroundCommandOutput = useCallback((command: FlowChatHeaderCommandSummary) => {
createBackgroundCommandOutputTab({
execSessionKey: command.execSessionKey,
Expand Down Expand Up @@ -2572,6 +2604,7 @@ export const ModernFlowChatContainer: React.FC<ModernFlowChatContainerProps> = (
onOpenSessionTreeSession={handleOpenSessionTreeSession}
hasActiveSessionTreeDescendants={hasActiveSessionTreeDescendants}
onCancelSessionTreeSession={handleCancelSessionTreeSession}
onDeleteSessionTreeSession={handleDeleteSessionTreeSession}
onOpenBackgroundCommandOutput={handleOpenBackgroundCommandOutput}
onRequestBackgroundCommandInput={handleRequestBackgroundCommandInput}
onStopBackgroundCommand={handleStopBackgroundCommand}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@
min-height: 30px;
}

&--embedded &__expand,
&--embedded &__expand-spacer {
width: 12px;
flex-basis: 12px;
}

&--embedded &__node--subagent {
min-height: 30px;
}
Expand All @@ -45,13 +51,13 @@
}

&--embedded &__node-title {
max-width: 40%;
flex: 0 0 auto;
flex: 1 1 auto;
}

&--embedded &__node-meta {
min-width: 0;
flex: 1 1 auto;
max-width: 45%;
flex: 0 1 auto;
}

&__trigger {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,56 @@ describe('SessionTreePopover', () => {
vi.restoreAllMocks();
});

it('filters inactive branches, retains ancestors, and restores all agents when disabled', async () => {
mocks.sessions.set('child', { ...createSession('child', 'subagent', 'root'), dialogTurns: [] });
mocks.sessions.set('grandchild', createSession('grandchild', 'subagent', 'child'));
mocks.sessions.set('idle-sibling', { ...createSession('idle-sibling', 'subagent', 'root'), dialogTurns: [] });
const render = async (activeOnly: boolean) => {
await act(async () => {
root.render(<SessionTreePopover sessionId="root" embedded open activeOnly={activeOnly} t={key => key} />);
});
};
const nodeIds = () => Array.from(container.querySelectorAll<HTMLElement>('[role="treeitem"]'))
.map(node => node.dataset.sessionId);

await render(true);
expect(nodeIds()).toEqual(['child', 'grandchild']);
await render(false);
expect(nodeIds()).toEqual(['child', 'grandchild', 'idle-sibling']);
await render(true);
expect(nodeIds()).toEqual(['child', 'grandchild']);
});

it('shows the active empty state when all agents are inactive', async () => {
mocks.sessions.set('child', { ...createSession('child', 'subagent', 'root'), dialogTurns: [] });
await act(async () => {
root.render(<SessionTreePopover sessionId="root" embedded open activeOnly t={key => key} />);
});
expect(container.querySelector('[role="treeitem"]')).toBeNull();
expect(container.textContent).toContain('flowChatHeader.agentTreeNoActive');
});

it('offers deletion for inactive agents without exposing cancellation or opening the session', async () => {
mocks.sessions.set('child', { ...createSession('child', 'subagent', 'root'), dialogTurns: [] });
const onDeleteSession = vi.fn().mockResolvedValue(true);
const onSelectSession = vi.fn();
await act(async () => {
root.render(<SessionTreePopover sessionId="root" embedded open onDeleteSession={onDeleteSession} onSelectSession={onSelectSession} t={key => key} />);
});
await act(async () => {
container.querySelector<HTMLButtonElement>('[aria-label="flowChatHeader.agentTreeActions"]')?.click();
});
const menu = document.querySelector('[data-testid="flowchat-header-session-tree-menu"]');
expect(menu?.textContent).toContain('flowChatHeader.agentTreeDelete');
expect(menu?.textContent).not.toContain('flowChatHeader.agentTreeCancel');
await act(async () => {
menu?.querySelector<HTMLButtonElement>('[role="menuitem"]')?.click();
});
expect(onDeleteSession).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 'child' }));
expect(onSelectSession).not.toHaveBeenCalled();
expect(mocks.getSessionLineage).toHaveBeenCalledTimes(2);
});

it('offers non-cascading cancellation for running child sessions', async () => {
const onCancelSession = vi.fn().mockResolvedValue(true);
const t = (key: string) => key;
Expand Down Expand Up @@ -188,6 +238,8 @@ describe('SessionTreePopover', () => {
expect(subagentNodes).toHaveLength(3);
expect(subagentNodes.map(node => node.querySelector('.session-tree-popover__node-title')?.textContent))
.toEqual(['Parser review', 'Test runner', 'Docs audit']);
expect(subagentNodes.map(node => node.querySelector('.session-tree-popover__node-meta')?.textContent))
.toEqual(['worker', 'worker', 'worker']);
});

it('closes a sibling action-menu portal and restores focus with the parent', async () => {
Expand Down Expand Up @@ -265,8 +317,7 @@ describe('SessionTreePopover', () => {
expect(container.querySelector('[data-testid="flowchat-header-session-tree-content"]')).not.toBeNull();
expect(document.querySelector('.session-tree-popover__panel')).toBeNull();

const childNode = Array.from(container.querySelectorAll<HTMLElement>('[role="treeitem"]'))
.find(node => node.textContent?.includes('Running child'));
const childNode = container.querySelector<HTMLElement>('[role="treeitem"][data-session-id="child"]');
await act(async () => {
childNode?.querySelector<HTMLButtonElement>('.session-tree-popover__node-main')?.click();
});
Expand Down
Loading
Loading