From 4483ee11ac4585c9cf24b91e41b9154a728661d2 Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 29 Oct 2026 17:33:20 +0000 Subject: [PATCH] fix(web): restore sessions multi-selection and retain overview after deletion --- apps/docs/fast-sessions.mdx | 9 + .../(authenticated)/sessions/SessionBoard.tsx | 19 +- .../(authenticated)/sessions/SessionCard.tsx | 78 +++-- .../sessions/page.client.test.tsx | 275 +++++++++++++++- .../src/app/(authenticated)/sessions/page.tsx | 190 +++++------ .../SessionDeleteAction.client.test.tsx | 18 ++ .../[sessionId]/SessionDeleteAction.tsx | 3 +- .../components/sessions/SessionSelection.tsx | 296 ++++++++++++++++++ 8 files changed, 746 insertions(+), 142 deletions(-) create mode 100644 apps/web/src/components/sessions/SessionSelection.tsx diff --git a/apps/docs/fast-sessions.mdx b/apps/docs/fast-sessions.mdx index 247da4a234..0c4d65cbfd 100644 --- a/apps/docs/fast-sessions.mdx +++ b/apps/docs/fast-sessions.mdx @@ -158,6 +158,15 @@ reversible and hides the session and its linked tasks from ordinary lists and search until it is unarchived. Deleting a session uses the existing confirmation and cleanup safeguards. +In the Sessions list or board, choose **Select sessions** to show checkboxes. +Select sessions individually, or choose **All** to select every session you can +manage on the current page. **Delete (n)** confirms and deletes the selection +using the same cleanup safeguards as individual deletion. If some deletions +fail, those sessions stay selected for retry. Exit selection mode to resume +opening sessions normally; changing views, filters, or pages clears selection. +Deleting one or several sessions from either overview keeps you on that view +with its current filters and page. + On desktop, you can keep several task workspaces open beside the session, resize them, and switch between the conversation and task panels without losing your place. On smaller screens, Roomote shows one workspace at a time so the diff --git a/apps/web/src/app/(authenticated)/sessions/SessionBoard.tsx b/apps/web/src/app/(authenticated)/sessions/SessionBoard.tsx index ee42e25c4b..89711bb09a 100644 --- a/apps/web/src/app/(authenticated)/sessions/SessionBoard.tsx +++ b/apps/web/src/app/(authenticated)/sessions/SessionBoard.tsx @@ -41,6 +41,7 @@ import { import { BasicTooltip, Button, Funnel } from '@/components/system'; import { useSessionStatusMutation } from '@/components/sessions/use-session-status-mutation'; +import { useSessionSelectionActive } from '@/components/sessions/SessionSelection'; import { canDropSessionBoardCard, getSessionBoardDropStatus, @@ -454,6 +455,8 @@ export function SessionBoardCard({ }) { const reducedMotion = useReducedMotion() ?? false; const registry = useContext(SessionBoardCardRegistryContext); + const selecting = useSessionSelectionActive(); + const canDrag = canManage && !selecting; const dnd = useContext(SessionBoardDndContext); const [isFlying, setIsFlying] = useState(false); const dragData = useRef({ @@ -470,7 +473,7 @@ export function SessionBoardCard({ const draggable = useDraggable({ id: sessionId, data: dragData.current, - disabled: !canManage || dnd?.statusMutationPending === true, + disabled: !canDrag || dnd?.statusMutationPending === true, }); useLayoutEffect(() => { @@ -529,14 +532,14 @@ export function SessionBoardCard({
{children}
diff --git a/apps/web/src/app/(authenticated)/sessions/SessionCard.tsx b/apps/web/src/app/(authenticated)/sessions/SessionCard.tsx index 86fc568a15..56be723041 100644 --- a/apps/web/src/app/(authenticated)/sessions/SessionCard.tsx +++ b/apps/web/src/app/(authenticated)/sessions/SessionCard.tsx @@ -1,4 +1,3 @@ -import Link from 'next/link'; import { format, formatDistanceToNow, isSameDay, isSameYear } from 'date-fns'; import type { SessionManualStatus } from '@roomote/types'; @@ -21,6 +20,11 @@ import { SessionInferenceCostBreakdown } from '@/components/sessions/SessionInfe import { PrivateSessionIcon } from '@/components/sessions/PrivateSessionIcon'; import { getSessionSurfaceLabel } from '@/components/sessions/session-surfaces'; import { TaskAutomationIcon } from '@/components/tasks/TaskAutomationIcon'; +import { + SessionSelectionControl, + SessionSelectionLink, + SessionSelectionRow, +} from '@/components/sessions/SessionSelection'; import { SessionActions } from '../../(sandbox)/sessions/[sessionId]/SessionDeleteAction'; type SessionCardData = { @@ -108,41 +112,53 @@ export function SessionCard({ const accessibleActivityTimestamp = activityDate.toLocaleString(); return ( -
- + {session.title} - +
- {session.ownerKind === 'automation' ? ( - - ) : ( - - )} - {session.unread && session.ownerUserId === viewerUserId ? ( - - ) : null} + )} + {session.unread && session.ownerUserId === viewerUserId ? ( + + ) : null} +
@@ -230,6 +246,6 @@ export function SessionCard({ />
) : null} -
+ ); } diff --git a/apps/web/src/app/(authenticated)/sessions/page.client.test.tsx b/apps/web/src/app/(authenticated)/sessions/page.client.test.tsx index fde8fdb283..b774137df3 100644 --- a/apps/web/src/app/(authenticated)/sessions/page.client.test.tsx +++ b/apps/web/src/app/(authenticated)/sessions/page.client.test.tsx @@ -1,20 +1,47 @@ -import { render, screen, waitFor, within } from '@testing-library/react'; - -const { getSessionsMock, sessionStatusState } = vi.hoisted(() => ({ - getSessionsMock: vi.fn(), - sessionStatusState: { - current: ['active', 'needs_input', 'blocked', 'ready', 'done'] as Array< - 'active' | 'needs_input' | 'blocked' | 'ready' | 'done' - >, - }, -})); +import { + act, + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; + +const { getSessionsMock, sessionStatusState, remove, refresh, push } = + vi.hoisted(() => ({ + remove: vi.fn(), + refresh: vi.fn(), + push: vi.fn(), + getSessionsMock: vi.fn(), + sessionStatusState: { + current: ['active', 'needs_input', 'blocked', 'ready', 'done'] as Array< + 'active' | 'needs_input' | 'blocked' | 'ready' | 'done' + >, + }, + })); vi.mock('next/navigation', () => ({ usePathname: () => '/sessions', - useRouter: () => ({ replace: vi.fn() }), + useRouter: () => ({ replace: vi.fn(), refresh, push }), useSearchParams: () => new URLSearchParams(), })); +vi.mock('@/trpc/client', () => ({ + useTRPC: () => ({ sessions: { delete: { mutationOptions: () => ({}) } } }), +})); +vi.mock('@tanstack/react-query', () => ({ + useMutation: () => ({ mutateAsync: remove }), +})); +vi.mock( + '../../(sandbox)/sessions/[sessionId]/SessionDeleteAction', + async (importOriginal) => ({ + ...(await importOriginal()), + SessionActions: () => ( + + + + ) : ( + + )} + + {children} + { + if (!pending) setConfirming(open); + }} + > + + + + Delete {selected.size} session{selected.size === 1 ? '' : 's'}? + + + {SESSION_DELETION_DESCRIPTION} + + + + + + + + + + ); +} + +export function SessionSelectionRow({ + sessionId, + className, + children, +}: { + sessionId: string; + className: string; + children: ReactNode; +}) { + const selection = useContext(SessionSelectionContext); + return ( +
+ {children} +
+ ); +} + +export function SessionSelectionLink({ + sessionId, + title, + canManage, + href, + className, + children, +}: { + sessionId: string; + title: string; + canManage: boolean; + href: string; + className: string; + children: ReactNode; +}) { + const selection = useContext(SessionSelectionContext); + if (selection?.active) { + return ( +