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
201 changes: 201 additions & 0 deletions src/client/components/common/session-actions-menu/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/

/**
* One overflow menu for a session row, shared by the desktop sidebar and the
* mobile one.
*
* The row used to carry its actions as side-by-side hover icons (rename,
* archive, delete). That does not scale: three icons cost roughly a third of
* the title's width in the 268px sidebar, and every action added from here
* takes another slice. One trigger whose contents grow is the shape that
* survives the next action.
*
* Two ways in, one menu out:
* - the `⋯` button, which is the discoverable affordance;
* - a right-click anywhere on the row, which is the accelerator. Both are
* wired by the row component through `useRowMenu`.
*
* The menu positions itself from its OWN measured size. The item list is
* conditional (a pending `new-…` chat has no Rename or Delete), so a constant
* height would put the last row off-screen at the viewport edge — the same
* class of bug the file explorer's context menu works around with hand-tuned
* numbers.
*/

import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
import type { TargetedMouseEvent } from 'preact';
import { createPortal } from 'preact/compat';
import { Archive, ArchiveRestore, Pencil, Trash2 } from 'lucide-preact';

/** Viewport coordinates the menu hangs off, plus which edge it aligns to. */
export interface MenuAnchor {
top: number;
right: number;
bottom: number;
left: number;
/**
* `end` hangs the menu's right edge off the anchor's — what a row's overflow
* button expects, since it sits at the row's end. `start` puts the menu's
* left edge at the anchor's, so a context-menu click opens away from the
* pointer rather than under it.
*/
align: 'start' | 'end';
}

export interface RowMenuHandle {
/** Non-null while the menu is open. */
anchor: MenuAnchor | null;
/** Open below the clicked element (the `⋯` button). */
openBelow: (event: TargetedMouseEvent<HTMLElement>) => void;
/** Open at the pointer (a right-click on the row). */
openAtCursor: (event: TargetedMouseEvent<HTMLElement>) => void;
close: () => void;
}

/** Anchor state for one row's overflow menu. */
export function useRowMenu(): RowMenuHandle {
const [anchor, setAnchor] = useState<MenuAnchor | null>(null);

const openBelow = useCallback((event: TargetedMouseEvent<HTMLElement>) => {
event.preventDefault();
// The row underneath selects the session on click; opening its menu is not
// a selection.
event.stopPropagation();
const rect = event.currentTarget.getBoundingClientRect();
setAnchor({ top: rect.top, right: rect.right, bottom: rect.bottom, left: rect.left, align: 'end' });
}, []);

const openAtCursor = useCallback((event: TargetedMouseEvent<HTMLElement>) => {
event.preventDefault();
event.stopPropagation();
const { clientX, clientY } = event;
setAnchor({ top: clientY, right: clientX, bottom: clientY, left: clientX, align: 'start' });
}, []);

const close = useCallback(() => setAnchor(null), []);

return { anchor, openBelow, openAtCursor, close };
}

export interface SessionActionsMenuProps {
anchor: MenuAnchor;
isArchived?: boolean;
/** Omitted when the row cannot be renamed (a pending `new-…` chat). */
onRename?: () => void;
onArchive?: () => void;
onDelete?: () => void;
onClose: () => void;
}

export function SessionActionsMenu({
anchor,
isArchived = false,
onRename,
onArchive,
onDelete,
onClose,
}: SessionActionsMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);

useLayoutEffect(() => {
const el = menuRef.current;
if (!el) return;
const { width, height } = el.getBoundingClientRect();
const margin = 8;
const below = anchor.bottom + 4;
// Flip above the trigger rather than overflowing the bottom edge.
const top = below + height + margin <= window.innerHeight
? below
: Math.max(margin, anchor.top - height - 4);
const desiredLeft = anchor.align === 'end' ? anchor.right - width : anchor.left;
setPos({
top,
left: Math.max(margin, Math.min(desiredLeft, window.innerWidth - width - margin)),
});
}, [anchor]);

useEffect(() => {
const onKeyDown = (e: globalThis.KeyboardEvent) => {
if (e.key !== 'Escape') return;
e.preventDefault();
onClose();
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [onClose]);

// Every item closes the menu first: Rename opens an inline editor in the row,
// and leaving the menu over it would cover the input.
const run = (action?: () => void) => () => {
onClose();
action?.();
};

return createPortal(
<div
className="fixed inset-0 z-[70]"
onClick={onClose}
onContextMenu={(e) => {
e.preventDefault();
onClose();
}}
>
<div
ref={menuRef}
role="menu"
aria-label="Session actions"
style={{
top: pos?.top ?? anchor.bottom + 4,
left: pos?.left ?? anchor.left,
// Hidden for the single pre-measure render; `useLayoutEffect` settles
// the position before the browser paints, so this never flashes.
visibility: pos ? 'visible' : 'hidden',
}}
className="fixed z-[70] w-44 bg-paper border border-ink/15 rounded-lg shadow-xl py-1 text-xs text-ink font-sans"
onClick={(e) => e.stopPropagation()}
>
{onRename && (
<button
type="button"
role="menuitem"
onClick={run(onRename)}
className="w-full text-left px-3 py-1.5 hover:bg-ink/5 flex items-center gap-2 transition-colors cursor-pointer"
>
<Pencil size={12} className="text-ink/60" />
<span>Rename</span>
</button>
)}
{onArchive && (
<button
type="button"
role="menuitem"
onClick={run(onArchive)}
className="w-full text-left px-3 py-1.5 hover:bg-ink/5 flex items-center gap-2 transition-colors cursor-pointer"
>
{isArchived
? <ArchiveRestore size={12} className="text-ink/60" />
: <Archive size={12} className="text-ink/60" />}
<span>{isArchived ? 'Unarchive' : 'Archive'}</span>
</button>
)}
{(onRename || onArchive) && onDelete && <div className="my-1 border-t border-ink/10" />}
{onDelete && (
<button
type="button"
role="menuitem"
onClick={run(onDelete)}
className="w-full text-left px-3 py-1.5 hover:bg-error/10 text-error flex items-center gap-2 transition-colors cursor-pointer"
>
<Trash2 size={12} className="text-error" />
<span>Delete</span>
</button>
)}
</div>
</div>,
document.body,
);
}
128 changes: 128 additions & 0 deletions src/client/components/common/session-delete-modal/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/

import { useEffect } from 'preact/hooks';
import { AlertTriangle, Loader2, Trash2 } from 'lucide-preact';
import { Modal } from '@/client/components/common/Modal';
import type { SessionItemData } from '@/shared/types';

export interface SessionDeleteModalProps {
/** The session awaiting confirmation, or null when the dialog is closed. */
session: SessionItemData | null;
/** True while the DELETE request is in flight. */
isDeleting: boolean;
/** Why the server refused, shown in place of the warning strip. */
error: string | null;
onClose: () => void;
onConfirm: () => void;
}

/**
* Confirmation for deleting a session.
*
* The dialog names what actually disappears — the transcript on disk, the
* subagent transcripts beside it, the side questions asked in it, and the
* chamber's own rows for it — because none of it is recoverable from the UI.
* That is the whole difference between this button and the archive button next
* to it, and a bare "Delete?" would leave the user guessing which one they
* pressed.
*
* A refusal is rendered INSIDE the dialog rather than as a toast: the reasons
* the server gives (the session is mid-run, or the chat has not been created
* yet) are actionable, and the user is standing in the place where they act on
* them.
*/
export function SessionDeleteModal({
session,
isDeleting,
error,
onClose,
onConfirm,
}: SessionDeleteModalProps) {
useEffect(() => {
// No dismissing mid-delete: the dialog resolves itself on success, and an
// early close would let a second delete be queued against a stale row.
const handler = (e: globalThis.KeyboardEvent) => {
if (e.key !== 'Escape' || isDeleting) return;
e.preventDefault();
onClose();
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [onClose, isDeleting]);

if (!session) return null;

return (
<Modal
onClose={isDeleting ? () => {} : onClose}
zClass="z-[70]"
maxWidthClass="max-w-[460px]"
header={
<div className="flex items-center gap-3 min-w-0">
<Trash2 size={16} className="text-error shrink-0" />
<div className="min-w-0">
<h2 className="text-sm font-semibold text-ink">Delete session?</h2>
<p className="text-[11px] text-ink/50 truncate">{session.title}</p>
</div>
</div>
}
footer={
<div className="flex items-center justify-end gap-2 px-5 py-3.5 border-t border-ink/10">
<button
type="button"
onClick={onClose}
disabled={isDeleting}
className="px-3.5 py-1.5 rounded-md border border-ink/20 text-xs font-medium text-ink hover:bg-ink/5 transition-colors cursor-pointer disabled:opacity-50"
>
Cancel
</button>
<button
type="button"
onClick={onConfirm}
disabled={isDeleting}
className="px-3.5 py-1.5 rounded-md bg-error text-canvas text-xs font-medium hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50 flex items-center gap-1.5"
>
{isDeleting ? (
<>
<Loader2 size={13} className="animate-spin" />
<span>Deleting...</span>
</>
) : (
<>
<Trash2 size={13} />
<span>Delete session</span>
</>
)}
</button>
</div>
}
>
<div className="px-5 py-4 space-y-3">
<ul className="text-[11.5px] leading-relaxed text-ink/70 space-y-1.5">
<li>
Its transcript is removed from{' '}
<span className="font-mono text-ink">~/.omp/agent/sessions</span> — the conversation is
gone from the sidebar and from oh-my-pi.
</li>
<li>The subagent transcripts and any side questions asked in it are removed with it.</li>
<li>Its queued messages, archive state, and saved panel layout go too.</li>
</ul>

{error ? (
<div className="flex items-start gap-2 px-3 py-2 rounded-md border border-error/30 bg-error/5 text-[11px] text-ink/75">
<AlertTriangle size={13} className="mt-0.5 shrink-0 text-error" />
<span>{error}</span>
</div>
) : (
<div className="flex items-start gap-2 px-3 py-2 rounded-md border border-error/30 bg-error/5 text-[11px] text-ink/75">
<AlertTriangle size={13} className="mt-0.5 shrink-0 text-error" />
<span>This cannot be undone from the UI. Archive it instead to keep the transcript.</span>
</div>
)}
</div>
</Modal>
);
}
18 changes: 18 additions & 0 deletions src/client/components/layout/session-sidebar/CategoryItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { useFetcher } from '@/client/lib/router/fetcher';
import { useOnClickOutside } from '@/client/hooks/ui/on-click-outside';
import { useShowMore } from '@/client/hooks/ui/show-more';
import { useWorkspaceFolderActions } from '@/client/hooks/workspace/workspace-folder-actions';
import { useSessionDelete } from '@/client/hooks/workspace/session-delete';
import { SessionDeleteModal } from '@/client/components/common/session-delete-modal';
import { SessionItem } from '@/client/components/layout/session-sidebar/SessionItem';
import { SubagentList } from '@/client/components/common/subagent-list';
import { WorkspaceOptionsMenu } from '@/client/components/common/workspace-options-menu';
Expand Down Expand Up @@ -58,6 +60,7 @@ export function Category({
handleArchive,
handleRename,
} = useWorkspaceFolderActions(folder, refresh);
const sessionDelete = useSessionDelete();

// Desktop's expand toggle keeps its own fetcher: unlike pin/delete it
// dispatches on the *response* (not immediately) and carries no folderId.
Expand Down Expand Up @@ -203,6 +206,13 @@ export function Category({
awaitingInput={Boolean(session.awaitingInput)}
onClick={() => onSelectSession(session.id)}
onArchive={() => handleArchive(session)}
onDelete={
// A pending `new-…` chat has no transcript anywhere yet —
// there is nothing to delete, and the server refuses it.
String(session.id).startsWith('new-')
? undefined
: () => sessionDelete.requestDelete(session)
}
onRename={String(session.id).startsWith('new-') ? undefined : (name) => void handleRename(session, name)}
expandable={hasSubagents}
hasSubagents={hasSubagents}
Expand Down Expand Up @@ -236,6 +246,14 @@ export function Category({
)}
</div>
)}

<SessionDeleteModal
session={sessionDelete.pending}
isDeleting={sessionDelete.isDeleting}
error={sessionDelete.error}
onClose={sessionDelete.cancelDelete}
onConfirm={() => void sessionDelete.confirmDelete()}
/>
</div>
);
}
Loading
Loading