diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md
index 0becd46432..8159e456ec 100644
--- a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md
+++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md
@@ -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
diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.test.tsx b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.test.tsx
index 9a4eb8e262..c2dc39710d 100644
--- a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.test.tsx
+++ b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.test.tsx
@@ -97,8 +97,8 @@ vi.mock('./SessionFilesBadge', () => ({
}));
vi.mock('./SessionTreePopover', () => ({
- SessionTreePopover: ({ embedded }: { embedded?: boolean }) => embedded
- ?
+ SessionTreePopover: ({ embedded, activeOnly }: { embedded?: boolean; activeOnly?: boolean }) => embedded
+ ?
: null,
}));
@@ -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('[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 () => {
diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx
index 2ba074311b..d64528bc31 100644
--- a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx
+++ b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx
@@ -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';
@@ -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;
+ onDeleteSessionTreeSession?: (selection: SessionTreeSelection) => Promise;
/** Long-running background commands launched by the active parent session. */
backgroundCommands?: FlowChatHeaderCommandSummary[];
/** Open a read-only output panel for a background command. */
@@ -101,6 +102,7 @@ export const FlowChatHeader: React.FC = ({
onOpenSessionTreeSession,
hasActiveSessionTreeDescendants = false,
onCancelSessionTreeSession,
+ onDeleteSessionTreeSession,
backgroundCommands = [],
onOpenBackgroundCommandOutput,
onRequestBackgroundCommandInput,
@@ -114,6 +116,7 @@ export const FlowChatHeader: React.FC = ({
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(null);
const [isSearchOpen, setIsSearchOpen] = useState(false);
@@ -759,6 +762,13 @@ export const FlowChatHeader: React.FC = ({
) : null}
+
+ setActiveAgentsOnly(event.currentTarget.checked)}
+ />
+
{sessionId ? (
= ({
onSelectSession={onOpenSessionTreeSession}
hasActiveDescendants={hasActiveSessionTreeDescendants}
onCancelSession={onCancelSessionTreeSession}
+ onDeleteSession={onDeleteSessionTreeSession}
embedded
+ activeOnly={activeAgentsOnly}
open={isSessionOverviewOpen}
onRequestClose={() => closeSessionOverview(false)}
t={t}
diff --git a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx
index a3a431e8be..54272b60aa 100644
--- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx
+++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx
@@ -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,
@@ -2378,6 +2382,34 @@ export const ModernFlowChatContainer: React.FC = (
}
}, [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,
@@ -2572,6 +2604,7 @@ export const ModernFlowChatContainer: React.FC = (
onOpenSessionTreeSession={handleOpenSessionTreeSession}
hasActiveSessionTreeDescendants={hasActiveSessionTreeDescendants}
onCancelSessionTreeSession={handleCancelSessionTreeSession}
+ onDeleteSessionTreeSession={handleDeleteSessionTreeSession}
onOpenBackgroundCommandOutput={handleOpenBackgroundCommandOutput}
onRequestBackgroundCommandInput={handleRequestBackgroundCommandInput}
onStopBackgroundCommand={handleStopBackgroundCommand}
diff --git a/src/web-ui/src/flow_chat/components/modern/SessionTreePopover.scss b/src/web-ui/src/flow_chat/components/modern/SessionTreePopover.scss
index 3c2902576e..46ec42c28c 100644
--- a/src/web-ui/src/flow_chat/components/modern/SessionTreePopover.scss
+++ b/src/web-ui/src/flow_chat/components/modern/SessionTreePopover.scss
@@ -27,6 +27,12 @@
min-height: 30px;
}
+ &--embedded &__expand,
+ &--embedded &__expand-spacer {
+ width: 12px;
+ flex-basis: 12px;
+ }
+
&--embedded &__node--subagent {
min-height: 30px;
}
@@ -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 {
diff --git a/src/web-ui/src/flow_chat/components/modern/SessionTreePopover.test.tsx b/src/web-ui/src/flow_chat/components/modern/SessionTreePopover.test.tsx
index 93008d8850..5afc5fc914 100644
--- a/src/web-ui/src/flow_chat/components/modern/SessionTreePopover.test.tsx
+++ b/src/web-ui/src/flow_chat/components/modern/SessionTreePopover.test.tsx
@@ -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( key} />);
+ });
+ };
+ const nodeIds = () => Array.from(container.querySelectorAll('[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( 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( key} />);
+ });
+ await act(async () => {
+ container.querySelector('[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('[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;
@@ -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 () => {
@@ -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('[role="treeitem"]'))
- .find(node => node.textContent?.includes('Running child'));
+ const childNode = container.querySelector('[role="treeitem"][data-session-id="child"]');
await act(async () => {
childNode?.querySelector('.session-tree-popover__node-main')?.click();
});
diff --git a/src/web-ui/src/flow_chat/components/modern/SessionTreePopover.tsx b/src/web-ui/src/flow_chat/components/modern/SessionTreePopover.tsx
index 496dccf496..c86ef240ef 100644
--- a/src/web-ui/src/flow_chat/components/modern/SessionTreePopover.tsx
+++ b/src/web-ui/src/flow_chat/components/modern/SessionTreePopover.tsx
@@ -5,6 +5,7 @@ import { OverflowText, Spinner, Tooltip } from '@openbitfun/ui';
import { RetainedMountBoundary } from '@/shared/presence';
import { sessionAPI, type SessionLineageSnapshot } from '@/infrastructure/api/service-api/SessionAPI';
import { getAppearanceOverlayHost } from '@/infrastructure/appearance';
+import { getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurface';
import { computeFixedPopoverPosition } from '@/shared/utils/fixedPopoverViewport';
import { useAnchoredPopoverPosition } from '@/shared/utils/useAnchoredPopoverPosition';
import { flowChatStore } from '../../store/FlowChatStore';
@@ -12,6 +13,7 @@ import {
buildSessionLineageTree,
collectExpandedRunningBranches,
countSessionLineageDescendants,
+ filterActiveSessionLineageTree,
type SessionLineageLifecycle,
type SessionLineageNode,
} from '../../utils/sessionLineage';
@@ -43,8 +45,10 @@ interface SessionTreePopoverProps {
hasActiveDescendants?: boolean;
onSelectSession?: (selection: SessionTreeSelection) => void;
onCancelSession?: (selection: SessionTreeSelection) => Promise;
+ onDeleteSession?: (selection: SessionTreeSelection) => Promise;
/** Render the tree inside a parent-owned popover instead of creating another trigger and surface. */
embedded?: boolean;
+ activeOnly?: boolean;
/** Whether an embedded tree is active and should load live data. */
open?: boolean;
/** Ask the parent-owned popover to close after a selection or keyboard dismissal. */
@@ -75,7 +79,9 @@ export const SessionTreePopover: React.FC = ({
hasActiveDescendants = false,
onSelectSession,
onCancelSession,
+ onDeleteSession,
embedded = false,
+ activeOnly = false,
open = false,
onRequestClose,
t,
@@ -90,6 +96,7 @@ export const SessionTreePopover: React.FC = ({
const [collapsedSessionIds, setCollapsedSessionIds] = useState>(new Set());
const [openActionSessionId, setOpenActionSessionId] = useState(null);
const [cancellingSessionIds, setCancellingSessionIds] = useState>(new Set());
+ const [deletingSessionId, setDeletingSessionId] = useState(null);
const containerRef = useRef(null);
const triggerRef = useRef(null);
const panelRef = useRef(null);
@@ -270,7 +277,8 @@ export const SessionTreePopover: React.FC = ({
);
}, [liveRevision, sessionId, snapshot]);
- const descendantCount = countSessionLineageDescendants(tree);
+ const visibleTree = useMemo(() => activeOnly ? filterActiveSessionLineageTree(tree) : tree, [activeOnly, tree]);
+ const descendantCount = countSessionLineageDescendants(visibleTree);
const panelLayout = useAnchoredPopoverPosition({
open: isOpen && !embedded,
anchorRef: triggerRef,
@@ -341,7 +349,7 @@ export const SessionTreePopover: React.FC = ({
) => {
event.preventDefault();
event.stopPropagation();
- if (!onCancelSession || node.isRoot || !nodeHasActiveWork(node)) return;
+ if (node.isRoot || (!onDeleteSession && (!onCancelSession || !nodeHasActiveWork(node)))) return;
if (openActionSessionId === node.sessionId) {
setOpenActionSessionId(null);
@@ -352,7 +360,21 @@ export const SessionTreePopover: React.FC = ({
actionMenuAnchorRef.current = event.currentTarget;
setOpenActionSessionId(node.sessionId);
updateActionMenuPosition();
- }, [onCancelSession, openActionSessionId, updateActionMenuPosition]);
+ }, [onCancelSession, onDeleteSession, openActionSessionId, updateActionMenuPosition]);
+
+ const handleDelete = useCallback(async (node: SessionLineageNode) => {
+ if (!onDeleteSession || node.isRoot || deletingSessionId) return;
+ const scope = getActiveSurfaceScope();
+ setOpenActionSessionId(null);
+ setActionMenuPosition(null);
+ setDeletingSessionId(node.sessionId);
+ try {
+ await onDeleteSession({ ...node, displayTitle: nodeDisplayTitle(node) });
+ } finally {
+ setDeletingSessionId(null);
+ if (scope.isCurrent()) await refreshSnapshot();
+ }
+ }, [deletingSessionId, onDeleteSession, refreshSnapshot]);
const handleCancel = useCallback(async (node: SessionLineageNode) => {
if (!onCancelSession || node.isRoot || !nodeHasActiveWork(node)) return;
@@ -395,19 +417,10 @@ export const SessionTreePopover: React.FC = ({
? t('flowChatHeader.agentTreeCancelling')
: lifecycleLabel(node.lifecycle, t);
const secondaryLabel = node.subagentType || node.agentType;
- const rawPrimaryLabel = node.isRoot ? node.title : node.agentId || node.title;
const primaryLabel = nodeDisplayTitle(node);
- const descriptiveTitle = node.title !== primaryLabel && node.title !== rawPrimaryLabel
- ? node.title
- : undefined;
- const nodeMeta = node.isRoot
- ? secondaryLabel
- : [secondaryLabel, descriptiveTitle]
- .filter((value, index, values): value is string =>
- Boolean(value) && values.indexOf(value) === index
- )
- .join(' · ');
+ const nodeMeta = secondaryLabel;
const canCancel = !!onCancelSession && !node.isRoot && nodeHasActiveWork(node);
+ const canDelete = !!onDeleteSession && !node.isRoot;
return (
@@ -427,7 +440,7 @@ export const SessionTreePopover: React.FC = ({
role="treeitem"
aria-level={depth + 1}
aria-expanded={hasChildren ? isExpanded : undefined}
- style={{ paddingLeft: `${6 + Math.min(depth, 10) * 14}px` }}
+ style={{ paddingLeft: `${(embedded ? 0 : 6) + Math.min(depth, 10) * 14}px` }}
>
{hasChildren ? (
- {canCancel ? (
+ {canCancel || canDelete ? (
= ({
aria-label={t('flowChatHeader.agentTreeActions')}
aria-haspopup="menu"
aria-expanded={openActionSessionId === node.sessionId}
- disabled={isCancelling}
+ disabled={isCancelling || deletingSessionId !== null}
icon={}
/>
@@ -500,7 +513,7 @@ export const SessionTreePopover: React.FC
= ({
style={actionMenuPosition}
data-testid="flowchat-header-session-tree-menu"
>
-
+ : null}
+ {canDelete ? : null}
,
getAppearanceOverlayHost(),
) : null}
@@ -532,10 +554,10 @@ export const SessionTreePopover: React.FC = ({
};
const panelLabel = t('flowChatHeader.agentTree');
- const renderedTreeNodes = tree
+ const renderedTreeNodes = visibleTree
? embedded
- ? tree.children.map(child => renderNode(child, 0))
- : renderNode(tree, 0)
+ ? visibleTree.children.map(child => renderNode(child, 0))
+ : renderNode(visibleTree, 0)
: null;
const treeBody = (
@@ -544,7 +566,7 @@ export const SessionTreePopover: React.FC = ({
data-openbitfun-component="flow-chat-header"
data-openbitfun-part="sessionTreeBody"
>
- {tree ? {renderedTreeNodes}
: null}
+ {visibleTree ? {renderedTreeNodes}
: null}
{isLoading && !tree ? (
= ({
data-openbitfun-part="sessionTreeState"
data-openbitfun-state="empty"
>
- {t('flowChatHeader.agentTreeEmpty')}
+ {t(activeOnly ? 'flowChatHeader.agentTreeNoActive' : 'flowChatHeader.agentTreeEmpty')}
) : null}
{loadFailed ? (
diff --git a/src/web-ui/src/flow_chat/services/deleteSessionTreeBranch.test.ts b/src/web-ui/src/flow_chat/services/deleteSessionTreeBranch.test.ts
new file mode 100644
index 0000000000..f59a544116
--- /dev/null
+++ b/src/web-ui/src/flow_chat/services/deleteSessionTreeBranch.test.ts
@@ -0,0 +1,64 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { deleteSessionTreeBranch } from './deleteSessionTreeBranch';
+
+const mocks = vi.hoisted(() => ({
+ lineage: vi.fn(), deleteSession: vi.fn(), discard: vi.fn(), assertCurrent: vi.fn(),
+ clearIntent: vi.fn(), clearTransition: vi.fn(),
+}));
+vi.mock('@/infrastructure/api/service-api/SessionAPI', () => ({ sessionAPI: { getSessionLineage: mocks.lineage } }));
+vi.mock('@/infrastructure/api/service-api/AgentAPI', () => ({ agentAPI: { deleteSession: mocks.deleteSession } }));
+vi.mock('@/infrastructure/peer-device/deviceSurface', () => ({ getActiveSurfaceScope: () => ({ assertCurrent: mocks.assertCurrent }) }));
+vi.mock('../store/FlowChatStore', () => ({ flowChatStore: { getState: () => ({ sessions: new Map() }) } }));
+vi.mock('./FlowChatManager', () => ({ FlowChatManager: { getInstance: () => ({ discardLocalSession: mocks.discard }) } }));
+vi.mock('./sessionOpenIntent', () => ({ clearRecentHistorySessionOpenIntent: mocks.clearIntent, clearHistorySessionOpenTransition: mocks.clearTransition }));
+
+const location = { sessionId: 'child', workspacePath: '/remote/project', remoteConnectionId: 'ssh-1', remoteSshHost: 'host' };
+describe('deleteSessionTreeBranch', () => {
+ beforeEach(() => {
+ vi.resetAllMocks();
+ mocks.lineage.mockResolvedValue({ rootSessionId: 'root', sessions: [
+ { sessionId: 'root', sessionName: 'Root', createdAtMs: 1 },
+ { sessionId: 'child', parentSessionId: 'root', sessionName: 'Child', createdAtMs: 2 },
+ { sessionId: 'grandchild', parentSessionId: 'child', sessionName: 'Done', status: 'completed', createdAtMs: 3 },
+ { sessionId: 'sibling', parentSessionId: 'root', sessionName: 'Sibling', createdAtMs: 4 },
+ ] });
+ });
+
+ it('deletes unloaded, inactive descendants before their parent and preserves the remote location', async () => {
+ await deleteSessionTreeBranch(location);
+ expect(mocks.lineage).toHaveBeenCalledWith(location);
+ expect(mocks.deleteSession.mock.calls).toEqual([
+ ['grandchild', '/remote/project', 'ssh-1', 'host'],
+ ['child', '/remote/project', 'ssh-1', 'host'],
+ ]);
+ expect(mocks.discard.mock.calls).toEqual([['grandchild'], ['child']]);
+ expect(mocks.clearIntent.mock.calls).toEqual([['grandchild'], ['child']]);
+ });
+
+ it('keeps failed descendants and their parent available for retry', async () => {
+ mocks.deleteSession.mockRejectedValue(new Error('offline'));
+ await expect(deleteSessionTreeBranch(location)).rejects.toThrow('offline');
+ expect(mocks.deleteSession).toHaveBeenCalledTimes(1);
+ expect(mocks.discard).not.toHaveBeenCalled();
+ });
+
+ it('does not delete against another peer after the lineage request', async () => {
+ mocks.assertCurrent.mockImplementationOnce(() => {}).mockImplementationOnce(() => { throw new Error('surface changed'); });
+ await expect(deleteSessionTreeBranch(location)).rejects.toThrow('surface changed');
+ expect(mocks.deleteSession).not.toHaveBeenCalled();
+ });
+
+ it('does not discard another peer projection after a successful backend deletion', async () => {
+ mocks.deleteSession.mockImplementationOnce(async () => {
+ mocks.assertCurrent.mockImplementation(() => { throw new Error('surface changed'); });
+ });
+ await expect(deleteSessionTreeBranch(location)).rejects.toThrow('surface changed');
+ expect(mocks.discard).not.toHaveBeenCalled();
+ });
+
+ it('refuses deletion without a complete lineage response', async () => {
+ mocks.lineage.mockResolvedValue(null);
+ await expect(deleteSessionTreeBranch(location)).rejects.toThrow('lineage is unavailable');
+ expect(mocks.deleteSession).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/web-ui/src/flow_chat/services/deleteSessionTreeBranch.ts b/src/web-ui/src/flow_chat/services/deleteSessionTreeBranch.ts
new file mode 100644
index 0000000000..f7e9bbdf84
--- /dev/null
+++ b/src/web-ui/src/flow_chat/services/deleteSessionTreeBranch.ts
@@ -0,0 +1,56 @@
+import { agentAPI } from '@/infrastructure/api/service-api/AgentAPI';
+import { sessionAPI } from '@/infrastructure/api/service-api/SessionAPI';
+import { getActiveSurfaceScope, type SurfaceScope } from '@/infrastructure/peer-device/deviceSurface';
+import { flowChatStore } from '../store/FlowChatStore';
+import { buildSessionLineageTree, type SessionLineageNode } from '../utils/sessionLineage';
+import { FlowChatManager } from './FlowChatManager';
+import { clearHistorySessionOpenTransition, clearRecentHistorySessionOpenIntent } from './sessionOpenIntent';
+
+export interface SessionTreeBranchLocation {
+ sessionId: string;
+ workspacePath: string;
+ remoteConnectionId?: string;
+ remoteSshHost?: string;
+}
+
+/** Read the complete lineage: the visible tree may hide inactive or unloaded descendants. */
+export async function deleteSessionTreeBranch(
+ location: SessionTreeBranchLocation,
+ scope: SurfaceScope = getActiveSurfaceScope(),
+): Promise {
+ scope.assertCurrent('read session branch before deletion');
+ const snapshot = await sessionAPI.getSessionLineage(location);
+ scope.assertCurrent('resolve session branch deletion');
+ if (!snapshot) throw new Error('Agent session lineage is unavailable');
+ const tree = buildSessionLineageTree(location.sessionId, snapshot, flowChatStore.getState().sessions);
+ const findBranch = (node: SessionLineageNode | null): SessionLineageNode | undefined => {
+ if (!node) return undefined;
+ if (node.sessionId === location.sessionId) return node;
+ for (const child of node.children) {
+ const found = findBranch(child);
+ if (found) return found;
+ }
+ return undefined;
+ };
+ const branch = findBranch(tree);
+ if (!branch || !branch.parentSessionId) throw new Error('Agent session branch not found');
+ const nodes: SessionLineageNode[] = [];
+ const collect = (node: SessionLineageNode) => {
+ node.children.forEach(collect);
+ nodes.push(node);
+ };
+ collect(branch);
+ for (const node of nodes) {
+ scope.assertCurrent('delete session branch node');
+ clearRecentHistorySessionOpenIntent(node.sessionId);
+ clearHistorySessionOpenTransition(node.sessionId);
+ await agentAPI.deleteSession(
+ node.sessionId,
+ location.workspacePath,
+ location.remoteConnectionId,
+ location.remoteSshHost,
+ );
+ scope.assertCurrent('apply session branch deletion');
+ FlowChatManager.getInstance().discardLocalSession(node.sessionId);
+ }
+}
diff --git a/src/web-ui/src/flow_chat/utils/sessionLineage.ts b/src/web-ui/src/flow_chat/utils/sessionLineage.ts
index cebb58e9ce..42c0519d19 100644
--- a/src/web-ui/src/flow_chat/utils/sessionLineage.ts
+++ b/src/web-ui/src/flow_chat/utils/sessionLineage.ts
@@ -69,6 +69,17 @@ function isActiveSessionLineageLifecycle(lifecycle: SessionLineageLifecycle): bo
return lifecycle === 'running' || lifecycle === 'finishing';
}
+/** Keep active nodes and the ancestor paths needed to reach them. */
+export function filterActiveSessionLineageTree(node: SessionLineageNode | null): SessionLineageNode | null {
+ if (!node) return null;
+ const children = node.children
+ .map(filterActiveSessionLineageTree)
+ .filter((child): child is SessionLineageNode => child !== null);
+ return isActiveSessionLineageLifecycle(node.lifecycle) || children.length > 0
+ ? { ...node, children }
+ : null;
+}
+
function nodeFromMetadata(metadata: SessionLineageEntry): FlatSessionLineageNode {
return {
sessionId: metadata.sessionId,
diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json
index 92cb0b8fc5..08f34d3846 100644
--- a/src/web-ui/src/locales/en-US/flow-chat.json
+++ b/src/web-ui/src/locales/en-US/flow-chat.json
@@ -1379,6 +1379,12 @@
"sessionOverviewBackgroundFinished": "{{count}} recently finished",
"sessionOverviewPullRequests": "Open workspace pull requests",
"agentTree": "Agents",
+ "agentTreeDelete": "Delete session",
+ "agentTreeDeleteConfirm": "Delete \"{{name}}\" and all its descendant sessions? This cannot be undone.",
+ "agentTreeDeleteFailed": "Failed to delete the Agent session branch.",
+ "agentTreeDeleteUnsupported": "Deleting individual agents in a detached task is not supported.",
+ "agentTreeActiveOnly": "Active agents only",
+ "agentTreeNoActive": "No active agents",
"agentTreeLoading": "Loading Agent tree...",
"agentTreeEmpty": "No subagents in this session",
"agentTreeLoadFailed": "Unable to load the Agent tree.",
diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json
index d71c6eddb6..cb2fd5f672 100644
--- a/src/web-ui/src/locales/zh-CN/flow-chat.json
+++ b/src/web-ui/src/locales/zh-CN/flow-chat.json
@@ -1379,6 +1379,12 @@
"sessionOverviewBackgroundFinished": "{{count}} 个最近已结束",
"sessionOverviewPullRequests": "打开工作区拉取请求",
"agentTree": "Agents",
+ "agentTreeDelete": "删除会话",
+ "agentTreeDeleteConfirm": "确定删除“{{name}}”及其所有后代会话吗?此操作无法撤销。",
+ "agentTreeDeleteFailed": "删除 Agent 会话分支失败。",
+ "agentTreeDeleteUnsupported": "暂不支持单独删除离线派发任务中的 Agent。",
+ "agentTreeActiveOnly": "仅显示活跃 Agent",
+ "agentTreeNoActive": "当前没有活跃 Agent",
"agentTreeLoading": "正在加载 Agent 会话树...",
"agentTreeEmpty": "当前会话没有子 Agent",
"agentTreeLoadFailed": "无法加载 Agent 会话树。",
diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json
index 1ca70b0b81..32863ce52f 100644
--- a/src/web-ui/src/locales/zh-TW/flow-chat.json
+++ b/src/web-ui/src/locales/zh-TW/flow-chat.json
@@ -1379,6 +1379,12 @@
"sessionOverviewBackgroundFinished": "{{count}} 個最近已結束",
"sessionOverviewPullRequests": "開啟工作區拉取請求",
"agentTree": "Agents",
+ "agentTreeDelete": "刪除會話",
+ "agentTreeDeleteConfirm": "確定刪除「{{name}}」及其所有後代會話嗎?此操作無法復原。",
+ "agentTreeDeleteFailed": "刪除 Agent 會話分支失敗。",
+ "agentTreeDeleteUnsupported": "暫不支援單獨刪除離線派發任務中的 Agent。",
+ "agentTreeActiveOnly": "僅顯示活躍 Agent",
+ "agentTreeNoActive": "目前沒有活躍 Agent",
"agentTreeLoading": "正在載入 Agent 會話樹...",
"agentTreeEmpty": "目前會話沒有子 Agent",
"agentTreeLoadFailed": "無法載入 Agent 會話樹。",