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 = ({ + + 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" > - = ({ {isCancelling ? t('flowChatHeader.agentTreeCancelling') : t('flowChatHeader.agentTreeCancel')} - + : null} + {canDelete ? void handleDelete(node)} + disabled={deletingSessionId !== null} + leading={} + > + {t('flowChatHeader.agentTreeDelete')} + : 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 會話樹。",