Skip to content
Open
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
47 changes: 46 additions & 1 deletion src/features/sessions/SessionList.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { fireEvent, render, screen } from '@testing-library/react';
import type { Session } from '@/types';
import { SessionList } from './SessionList';

Expand Down Expand Up @@ -45,3 +45,48 @@ describe('SessionList empty state', () => {
expect(screen.queryByText('No active sessions')).not.toBeInTheDocument();
});
});

describe('SessionList actions', () => {
it('allows renaming and deleting direct child sessions', () => {
const sessions: Session[] = [
{ sessionKey: 'agent:reviewer:main', label: 'Reviewer' },
{ sessionKey: 'agent:reviewer:telegram:direct:123', displayName: 'Telegram DM' },
];

renderSessionList({ sessions, compact: true, onRename: vi.fn(), onDelete: vi.fn() });

fireEvent.click(screen.getAllByLabelText('Session actions')[1]!);

expect(screen.getByTitle('Rename session')).toBeInTheDocument();
expect(screen.getByTitle('Delete session')).toBeInTheDocument();
});

it('allows renaming and deleting ACP child sessions', () => {
const sessions: Session[] = [
{ sessionKey: 'agent:codex:main', label: 'Codex' },
{ sessionKey: 'agent:codex:acp:123', displayName: 'ACP Session', parentId: 'agent:codex:main' },
];

renderSessionList({ sessions, compact: true, onRename: vi.fn(), onDelete: vi.fn() });

fireEvent.click(screen.getAllByLabelText('Session actions')[1]!);

expect(screen.getByTitle('Rename session')).toBeInTheDocument();
expect(screen.getByTitle('Delete session')).toBeInTheDocument();
});

it('shows an informational dialog when deleting a main agent session', () => {
const sessions: Session[] = [
{ sessionKey: 'agent:reviewer:main', label: 'Reviewer' },
];

renderSessionList({ sessions, compact: true, onRename: vi.fn(), onDelete: vi.fn() });

fireEvent.click(screen.getByLabelText('Session actions'));
fireEvent.click(screen.getByTitle('Delete session'));

expect(screen.getByText('Cannot Delete Main Agent Session')).toBeInTheDocument();
expect(screen.getByText(/Main agent sessions are not deletable/i)).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'docs.openclaw.ai/cli/agents' })).toHaveAttribute('href', 'https://docs.openclaw.ai/cli/agents');
});
});
45 changes: 41 additions & 4 deletions src/features/sessions/SessionList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ function findNodeByKey(nodes: ReturnType<typeof buildSessionTree>, key: string):
/** Sidebar list of agent sessions with tree structure and context menus. */
export function SessionList({ sessions, currentSession, busyState, agentStatus, unreadSessions, onSelect, onRefresh, onDelete, onSpawn, onRename, onAbort, isLoading, agentName = 'Agent', compact = false }: SessionListProps) {
const [deleteTarget, setDeleteTarget] = useState<{ key: string; label: string; descendantCount: number; isRootAgent: boolean } | null>(null);
const [deleteBlockedTarget, setDeleteBlockedTarget] = useState<{ key: string; label: string } | null>(null);
const [deleting, setDeleting] = useState(false);
const [spawnOpen, setSpawnOpen] = useState(false);
const [renamingKey, setRenamingKey] = useState<string | null>(null);
Expand Down Expand Up @@ -137,12 +138,17 @@ export function SessionList({ sessions, currentSession, busyState, agentStatus,
const flatNodes = useMemo(() => flattenTree(tree, expandedState), [tree, expandedState]);

const handleSetDeleteTarget = useCallback((key: string, label: string) => {
if (isTopLevelAgentSessionKey(key)) {
setDeleteBlockedTarget({ key, label });
return;
}

const targetNode = findNodeByKey(tree, key);
setDeleteTarget({
key,
label,
descendantCount: targetNode ? countDescendants(targetNode) : 0,
isRootAgent: isTopLevelAgentSessionKey(key),
isRootAgent: false,
});
}, [tree]);

Expand Down Expand Up @@ -231,6 +237,39 @@ export function SessionList({ sessions, currentSession, busyState, agentStatus,
})}
</div>

{/* Main agent delete blocked dialog */}
<Dialog open={!!deleteBlockedTarget} onOpenChange={(open) => !open && setDeleteBlockedTarget(null)}>
<DialogContent className="bg-card border-border max-w-md">
<DialogHeader>
<DialogTitle className="text-red font-mono text-sm tracking-wider uppercase flex items-center gap-2">
<AlertTriangle size={16} />
Cannot Delete Main Agent Session
</DialogTitle>
<DialogDescription className="text-muted-foreground text-xs leading-6">
Main agent sessions are not deletable. Use the <span className="text-foreground font-semibold">Reset</span> button to clear context.
<br />
To delete an agent, see the docs: <a className="text-info underline underline-offset-2" href="https://docs.openclaw.ai/cli/agents" target="_blank" rel="noreferrer">docs.openclaw.ai/cli/agents</a>
</DialogDescription>
</DialogHeader>
<div className="py-4">
<div className="bg-background border border-border/60 px-3 py-2">
<p className="text-[0.733rem] text-muted-foreground uppercase tracking-wider mb-1">Session:</p>
<p className="text-[0.8rem] text-foreground font-mono">{deleteBlockedTarget?.label}</p>
<p className="text-[0.667rem] text-muted-foreground font-mono mt-1 break-all">{deleteBlockedTarget?.key}</p>
</div>
</div>
<DialogFooter>
<Button
type="button"
onClick={() => setDeleteBlockedTarget(null)}
className="font-mono text-xs"
>
OK
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

{/* Delete confirmation dialog */}
<Dialog open={!!deleteTarget} onOpenChange={(open) => !open && !deleting && setDeleteTarget(null)}>
<DialogContent className="bg-card border-border max-w-md">
Expand All @@ -240,9 +279,7 @@ export function SessionList({ sessions, currentSession, busyState, agentStatus,
{deleteTarget?.descendantCount ? 'Delete Session Tree' : 'Delete Session'}
</DialogTitle>
<DialogDescription className="text-muted-foreground text-xs">
{deleteTarget?.isRootAgent
? 'This will permanently delete this root session and any nested child sessions attached to it.'
: deleteTarget?.descendantCount
{deleteTarget?.descendantCount
? `This will permanently delete this session and ${deleteTarget.descendantCount} nested child session${deleteTarget.descendantCount === 1 ? '' : 's'}.`
: 'This will permanently delete the session and archive its transcript.'}
</DialogDescription>
Expand Down
44 changes: 20 additions & 24 deletions src/features/sessions/SessionNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ export const SessionNode = memo(function SessionNode({
const [actionsOpen, setActionsOpen] = useState(false);
const actionsRef = useRef<HTMLDivElement>(null);

const canRenameDelete = isRootAgent || isSubagent || isCron || isCronRun;
const canRenameDelete = isRootAgent || isSubagent || isCron || isCronRun || Boolean(onStartRename) || Boolean(onDelete);
const hasAbortAction = Boolean(onAbort && running);
const hasRenameAction = Boolean(canRenameDelete && onStartRename && !isRenaming);
const hasDeleteAction = Boolean(canRenameDelete && onDelete);
Expand Down Expand Up @@ -359,29 +359,25 @@ export const SessionNode = memo(function SessionNode({
</button>
)}
{canRenameDelete && (
<>
{hasRenameAction && (
<button
type="button"
onClick={handleRenameClick}
title="Rename session"
className="bg-card/90 border border-border/60 text-muted-foreground hover:text-foreground hover:border-muted-foreground cursor-pointer text-[0.667rem] w-5 h-5 flex items-center justify-center"
>
<PenLine size={10} />
</button>
)}
{hasDeleteAction && (
<button
type="button"
onClick={handleDeleteClick}
title="Delete session"
className="bg-card/90 border border-border/60 text-muted-foreground hover:text-red hover:border-red/40 cursor-pointer text-[0.667rem] w-5 h-5 flex items-center justify-center"
>
</button>
)}
</>
{hasRenameAction && (
<button
type="button"
onClick={handleRenameClick}
title="Rename session"
className="bg-card/90 border border-border/60 text-muted-foreground hover:text-foreground hover:border-muted-foreground cursor-pointer text-[0.667rem] w-5 h-5 flex items-center justify-center"
>
<PenLine size={10} />
</button>
)}
{hasDeleteAction && (
<button
type="button"
onClick={handleDeleteClick}
title="Delete session"
className="bg-card/90 border border-border/60 text-muted-foreground hover:text-red hover:border-red/40 cursor-pointer text-[0.667rem] w-5 h-5 flex items-center justify-center"
>
</button>
)}
</div>
)}
Expand Down
Loading