ompchamber 3.4.0 → 3.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +293 -293
- package/dist/client/static/js/index-vx0ernrv.css +1 -0
- package/dist/client/static/js/{index-h05z90mw.js → index-w0cem7n5.js} +601 -601
- package/package.json +1 -1
- package/src/client/components/common/session-actions-menu/index.tsx +201 -0
- package/src/client/components/common/session-delete-modal/index.tsx +128 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +18 -0
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +109 -99
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +18 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +82 -64
- package/src/client/hooks/workspace/session-delete.ts +94 -0
- package/src/server/lib/btw/purge.server.ts +56 -0
- package/src/server/lib/fs/path-segment.test.ts +100 -0
- package/src/server/lib/fs/path-segment.ts +45 -0
- package/src/server/lib/omp/session/delete.server.ts +136 -0
- package/src/server/lib/omp/session/reader.ts +13 -0
- package/src/server/routes/sessions/delete.test.ts +84 -0
- package/src/server/routes/sessions/delete.ts +83 -0
- package/src/server/routes/sessions/index.ts +5 -0
- package/src/shared/lib/workspace/session-state/store.ts +20 -0
- package/dist/client/static/js/index-bsxxt89j.css +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ompchamber",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.5.0",
|
|
4
4
|
"description": "Developer web console and diagnostic chamber for AI Oh-My-Pi — Elysia + Preact on Bun.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"author": "Wijanarko Putra Rajeb <wijanarko.rajeb@gmail.com>",
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* One overflow menu for a session row, shared by the desktop sidebar and the
|
|
8
|
+
* mobile one.
|
|
9
|
+
*
|
|
10
|
+
* The row used to carry its actions as side-by-side hover icons (rename,
|
|
11
|
+
* archive, delete). That does not scale: three icons cost roughly a third of
|
|
12
|
+
* the title's width in the 268px sidebar, and every action added from here
|
|
13
|
+
* takes another slice. One trigger whose contents grow is the shape that
|
|
14
|
+
* survives the next action.
|
|
15
|
+
*
|
|
16
|
+
* Two ways in, one menu out:
|
|
17
|
+
* - the `⋯` button, which is the discoverable affordance;
|
|
18
|
+
* - a right-click anywhere on the row, which is the accelerator. Both are
|
|
19
|
+
* wired by the row component through `useRowMenu`.
|
|
20
|
+
*
|
|
21
|
+
* The menu positions itself from its OWN measured size. The item list is
|
|
22
|
+
* conditional (a pending `new-…` chat has no Rename or Delete), so a constant
|
|
23
|
+
* height would put the last row off-screen at the viewport edge — the same
|
|
24
|
+
* class of bug the file explorer's context menu works around with hand-tuned
|
|
25
|
+
* numbers.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
|
|
29
|
+
import type { TargetedMouseEvent } from 'preact';
|
|
30
|
+
import { createPortal } from 'preact/compat';
|
|
31
|
+
import { Archive, ArchiveRestore, Pencil, Trash2 } from 'lucide-preact';
|
|
32
|
+
|
|
33
|
+
/** Viewport coordinates the menu hangs off, plus which edge it aligns to. */
|
|
34
|
+
export interface MenuAnchor {
|
|
35
|
+
top: number;
|
|
36
|
+
right: number;
|
|
37
|
+
bottom: number;
|
|
38
|
+
left: number;
|
|
39
|
+
/**
|
|
40
|
+
* `end` hangs the menu's right edge off the anchor's — what a row's overflow
|
|
41
|
+
* button expects, since it sits at the row's end. `start` puts the menu's
|
|
42
|
+
* left edge at the anchor's, so a context-menu click opens away from the
|
|
43
|
+
* pointer rather than under it.
|
|
44
|
+
*/
|
|
45
|
+
align: 'start' | 'end';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface RowMenuHandle {
|
|
49
|
+
/** Non-null while the menu is open. */
|
|
50
|
+
anchor: MenuAnchor | null;
|
|
51
|
+
/** Open below the clicked element (the `⋯` button). */
|
|
52
|
+
openBelow: (event: TargetedMouseEvent<HTMLElement>) => void;
|
|
53
|
+
/** Open at the pointer (a right-click on the row). */
|
|
54
|
+
openAtCursor: (event: TargetedMouseEvent<HTMLElement>) => void;
|
|
55
|
+
close: () => void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Anchor state for one row's overflow menu. */
|
|
59
|
+
export function useRowMenu(): RowMenuHandle {
|
|
60
|
+
const [anchor, setAnchor] = useState<MenuAnchor | null>(null);
|
|
61
|
+
|
|
62
|
+
const openBelow = useCallback((event: TargetedMouseEvent<HTMLElement>) => {
|
|
63
|
+
event.preventDefault();
|
|
64
|
+
// The row underneath selects the session on click; opening its menu is not
|
|
65
|
+
// a selection.
|
|
66
|
+
event.stopPropagation();
|
|
67
|
+
const rect = event.currentTarget.getBoundingClientRect();
|
|
68
|
+
setAnchor({ top: rect.top, right: rect.right, bottom: rect.bottom, left: rect.left, align: 'end' });
|
|
69
|
+
}, []);
|
|
70
|
+
|
|
71
|
+
const openAtCursor = useCallback((event: TargetedMouseEvent<HTMLElement>) => {
|
|
72
|
+
event.preventDefault();
|
|
73
|
+
event.stopPropagation();
|
|
74
|
+
const { clientX, clientY } = event;
|
|
75
|
+
setAnchor({ top: clientY, right: clientX, bottom: clientY, left: clientX, align: 'start' });
|
|
76
|
+
}, []);
|
|
77
|
+
|
|
78
|
+
const close = useCallback(() => setAnchor(null), []);
|
|
79
|
+
|
|
80
|
+
return { anchor, openBelow, openAtCursor, close };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface SessionActionsMenuProps {
|
|
84
|
+
anchor: MenuAnchor;
|
|
85
|
+
isArchived?: boolean;
|
|
86
|
+
/** Omitted when the row cannot be renamed (a pending `new-…` chat). */
|
|
87
|
+
onRename?: () => void;
|
|
88
|
+
onArchive?: () => void;
|
|
89
|
+
onDelete?: () => void;
|
|
90
|
+
onClose: () => void;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function SessionActionsMenu({
|
|
94
|
+
anchor,
|
|
95
|
+
isArchived = false,
|
|
96
|
+
onRename,
|
|
97
|
+
onArchive,
|
|
98
|
+
onDelete,
|
|
99
|
+
onClose,
|
|
100
|
+
}: SessionActionsMenuProps) {
|
|
101
|
+
const menuRef = useRef<HTMLDivElement>(null);
|
|
102
|
+
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
|
103
|
+
|
|
104
|
+
useLayoutEffect(() => {
|
|
105
|
+
const el = menuRef.current;
|
|
106
|
+
if (!el) return;
|
|
107
|
+
const { width, height } = el.getBoundingClientRect();
|
|
108
|
+
const margin = 8;
|
|
109
|
+
const below = anchor.bottom + 4;
|
|
110
|
+
// Flip above the trigger rather than overflowing the bottom edge.
|
|
111
|
+
const top = below + height + margin <= window.innerHeight
|
|
112
|
+
? below
|
|
113
|
+
: Math.max(margin, anchor.top - height - 4);
|
|
114
|
+
const desiredLeft = anchor.align === 'end' ? anchor.right - width : anchor.left;
|
|
115
|
+
setPos({
|
|
116
|
+
top,
|
|
117
|
+
left: Math.max(margin, Math.min(desiredLeft, window.innerWidth - width - margin)),
|
|
118
|
+
});
|
|
119
|
+
}, [anchor]);
|
|
120
|
+
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
const onKeyDown = (e: globalThis.KeyboardEvent) => {
|
|
123
|
+
if (e.key !== 'Escape') return;
|
|
124
|
+
e.preventDefault();
|
|
125
|
+
onClose();
|
|
126
|
+
};
|
|
127
|
+
window.addEventListener('keydown', onKeyDown);
|
|
128
|
+
return () => window.removeEventListener('keydown', onKeyDown);
|
|
129
|
+
}, [onClose]);
|
|
130
|
+
|
|
131
|
+
// Every item closes the menu first: Rename opens an inline editor in the row,
|
|
132
|
+
// and leaving the menu over it would cover the input.
|
|
133
|
+
const run = (action?: () => void) => () => {
|
|
134
|
+
onClose();
|
|
135
|
+
action?.();
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
return createPortal(
|
|
139
|
+
<div
|
|
140
|
+
className="fixed inset-0 z-[70]"
|
|
141
|
+
onClick={onClose}
|
|
142
|
+
onContextMenu={(e) => {
|
|
143
|
+
e.preventDefault();
|
|
144
|
+
onClose();
|
|
145
|
+
}}
|
|
146
|
+
>
|
|
147
|
+
<div
|
|
148
|
+
ref={menuRef}
|
|
149
|
+
role="menu"
|
|
150
|
+
aria-label="Session actions"
|
|
151
|
+
style={{
|
|
152
|
+
top: pos?.top ?? anchor.bottom + 4,
|
|
153
|
+
left: pos?.left ?? anchor.left,
|
|
154
|
+
// Hidden for the single pre-measure render; `useLayoutEffect` settles
|
|
155
|
+
// the position before the browser paints, so this never flashes.
|
|
156
|
+
visibility: pos ? 'visible' : 'hidden',
|
|
157
|
+
}}
|
|
158
|
+
className="fixed z-[70] w-44 bg-paper border border-ink/15 rounded-lg shadow-xl py-1 text-xs text-ink font-sans"
|
|
159
|
+
onClick={(e) => e.stopPropagation()}
|
|
160
|
+
>
|
|
161
|
+
{onRename && (
|
|
162
|
+
<button
|
|
163
|
+
type="button"
|
|
164
|
+
role="menuitem"
|
|
165
|
+
onClick={run(onRename)}
|
|
166
|
+
className="w-full text-left px-3 py-1.5 hover:bg-ink/5 flex items-center gap-2 transition-colors cursor-pointer"
|
|
167
|
+
>
|
|
168
|
+
<Pencil size={12} className="text-ink/60" />
|
|
169
|
+
<span>Rename</span>
|
|
170
|
+
</button>
|
|
171
|
+
)}
|
|
172
|
+
{onArchive && (
|
|
173
|
+
<button
|
|
174
|
+
type="button"
|
|
175
|
+
role="menuitem"
|
|
176
|
+
onClick={run(onArchive)}
|
|
177
|
+
className="w-full text-left px-3 py-1.5 hover:bg-ink/5 flex items-center gap-2 transition-colors cursor-pointer"
|
|
178
|
+
>
|
|
179
|
+
{isArchived
|
|
180
|
+
? <ArchiveRestore size={12} className="text-ink/60" />
|
|
181
|
+
: <Archive size={12} className="text-ink/60" />}
|
|
182
|
+
<span>{isArchived ? 'Unarchive' : 'Archive'}</span>
|
|
183
|
+
</button>
|
|
184
|
+
)}
|
|
185
|
+
{(onRename || onArchive) && onDelete && <div className="my-1 border-t border-ink/10" />}
|
|
186
|
+
{onDelete && (
|
|
187
|
+
<button
|
|
188
|
+
type="button"
|
|
189
|
+
role="menuitem"
|
|
190
|
+
onClick={run(onDelete)}
|
|
191
|
+
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"
|
|
192
|
+
>
|
|
193
|
+
<Trash2 size={12} className="text-error" />
|
|
194
|
+
<span>Delete</span>
|
|
195
|
+
</button>
|
|
196
|
+
)}
|
|
197
|
+
</div>
|
|
198
|
+
</div>,
|
|
199
|
+
document.body,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useEffect } from 'preact/hooks';
|
|
7
|
+
import { AlertTriangle, Loader2, Trash2 } from 'lucide-preact';
|
|
8
|
+
import { Modal } from '@/client/components/common/Modal';
|
|
9
|
+
import type { SessionItemData } from '@/shared/types';
|
|
10
|
+
|
|
11
|
+
export interface SessionDeleteModalProps {
|
|
12
|
+
/** The session awaiting confirmation, or null when the dialog is closed. */
|
|
13
|
+
session: SessionItemData | null;
|
|
14
|
+
/** True while the DELETE request is in flight. */
|
|
15
|
+
isDeleting: boolean;
|
|
16
|
+
/** Why the server refused, shown in place of the warning strip. */
|
|
17
|
+
error: string | null;
|
|
18
|
+
onClose: () => void;
|
|
19
|
+
onConfirm: () => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Confirmation for deleting a session.
|
|
24
|
+
*
|
|
25
|
+
* The dialog names what actually disappears — the transcript on disk, the
|
|
26
|
+
* subagent transcripts beside it, the side questions asked in it, and the
|
|
27
|
+
* chamber's own rows for it — because none of it is recoverable from the UI.
|
|
28
|
+
* That is the whole difference between this button and the archive button next
|
|
29
|
+
* to it, and a bare "Delete?" would leave the user guessing which one they
|
|
30
|
+
* pressed.
|
|
31
|
+
*
|
|
32
|
+
* A refusal is rendered INSIDE the dialog rather than as a toast: the reasons
|
|
33
|
+
* the server gives (the session is mid-run, or the chat has not been created
|
|
34
|
+
* yet) are actionable, and the user is standing in the place where they act on
|
|
35
|
+
* them.
|
|
36
|
+
*/
|
|
37
|
+
export function SessionDeleteModal({
|
|
38
|
+
session,
|
|
39
|
+
isDeleting,
|
|
40
|
+
error,
|
|
41
|
+
onClose,
|
|
42
|
+
onConfirm,
|
|
43
|
+
}: SessionDeleteModalProps) {
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
// No dismissing mid-delete: the dialog resolves itself on success, and an
|
|
46
|
+
// early close would let a second delete be queued against a stale row.
|
|
47
|
+
const handler = (e: globalThis.KeyboardEvent) => {
|
|
48
|
+
if (e.key !== 'Escape' || isDeleting) return;
|
|
49
|
+
e.preventDefault();
|
|
50
|
+
onClose();
|
|
51
|
+
};
|
|
52
|
+
window.addEventListener('keydown', handler);
|
|
53
|
+
return () => window.removeEventListener('keydown', handler);
|
|
54
|
+
}, [onClose, isDeleting]);
|
|
55
|
+
|
|
56
|
+
if (!session) return null;
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<Modal
|
|
60
|
+
onClose={isDeleting ? () => {} : onClose}
|
|
61
|
+
zClass="z-[70]"
|
|
62
|
+
maxWidthClass="max-w-[460px]"
|
|
63
|
+
header={
|
|
64
|
+
<div className="flex items-center gap-3 min-w-0">
|
|
65
|
+
<Trash2 size={16} className="text-error shrink-0" />
|
|
66
|
+
<div className="min-w-0">
|
|
67
|
+
<h2 className="text-sm font-semibold text-ink">Delete session?</h2>
|
|
68
|
+
<p className="text-[11px] text-ink/50 truncate">{session.title}</p>
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
}
|
|
72
|
+
footer={
|
|
73
|
+
<div className="flex items-center justify-end gap-2 px-5 py-3.5 border-t border-ink/10">
|
|
74
|
+
<button
|
|
75
|
+
type="button"
|
|
76
|
+
onClick={onClose}
|
|
77
|
+
disabled={isDeleting}
|
|
78
|
+
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"
|
|
79
|
+
>
|
|
80
|
+
Cancel
|
|
81
|
+
</button>
|
|
82
|
+
<button
|
|
83
|
+
type="button"
|
|
84
|
+
onClick={onConfirm}
|
|
85
|
+
disabled={isDeleting}
|
|
86
|
+
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"
|
|
87
|
+
>
|
|
88
|
+
{isDeleting ? (
|
|
89
|
+
<>
|
|
90
|
+
<Loader2 size={13} className="animate-spin" />
|
|
91
|
+
<span>Deleting...</span>
|
|
92
|
+
</>
|
|
93
|
+
) : (
|
|
94
|
+
<>
|
|
95
|
+
<Trash2 size={13} />
|
|
96
|
+
<span>Delete session</span>
|
|
97
|
+
</>
|
|
98
|
+
)}
|
|
99
|
+
</button>
|
|
100
|
+
</div>
|
|
101
|
+
}
|
|
102
|
+
>
|
|
103
|
+
<div className="px-5 py-4 space-y-3">
|
|
104
|
+
<ul className="text-[11.5px] leading-relaxed text-ink/70 space-y-1.5">
|
|
105
|
+
<li>
|
|
106
|
+
Its transcript is removed from{' '}
|
|
107
|
+
<span className="font-mono text-ink">~/.omp/agent/sessions</span> — the conversation is
|
|
108
|
+
gone from the sidebar and from oh-my-pi.
|
|
109
|
+
</li>
|
|
110
|
+
<li>The subagent transcripts and any side questions asked in it are removed with it.</li>
|
|
111
|
+
<li>Its queued messages, archive state, and saved panel layout go too.</li>
|
|
112
|
+
</ul>
|
|
113
|
+
|
|
114
|
+
{error ? (
|
|
115
|
+
<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">
|
|
116
|
+
<AlertTriangle size={13} className="mt-0.5 shrink-0 text-error" />
|
|
117
|
+
<span>{error}</span>
|
|
118
|
+
</div>
|
|
119
|
+
) : (
|
|
120
|
+
<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">
|
|
121
|
+
<AlertTriangle size={13} className="mt-0.5 shrink-0 text-error" />
|
|
122
|
+
<span>This cannot be undone from the UI. Archive it instead to keep the transcript.</span>
|
|
123
|
+
</div>
|
|
124
|
+
)}
|
|
125
|
+
</div>
|
|
126
|
+
</Modal>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
@@ -5,6 +5,8 @@ import { useFetcher } from '@/client/lib/router/fetcher';
|
|
|
5
5
|
import { useOnClickOutside } from '@/client/hooks/ui/on-click-outside';
|
|
6
6
|
import { useShowMore } from '@/client/hooks/ui/show-more';
|
|
7
7
|
import { useWorkspaceFolderActions } from '@/client/hooks/workspace/workspace-folder-actions';
|
|
8
|
+
import { useSessionDelete } from '@/client/hooks/workspace/session-delete';
|
|
9
|
+
import { SessionDeleteModal } from '@/client/components/common/session-delete-modal';
|
|
8
10
|
import { SessionItem } from '@/client/components/layout/session-sidebar/SessionItem';
|
|
9
11
|
import { SubagentList } from '@/client/components/common/subagent-list';
|
|
10
12
|
import { WorkspaceOptionsMenu } from '@/client/components/common/workspace-options-menu';
|
|
@@ -58,6 +60,7 @@ export function Category({
|
|
|
58
60
|
handleArchive,
|
|
59
61
|
handleRename,
|
|
60
62
|
} = useWorkspaceFolderActions(folder, refresh);
|
|
63
|
+
const sessionDelete = useSessionDelete();
|
|
61
64
|
|
|
62
65
|
// Desktop's expand toggle keeps its own fetcher: unlike pin/delete it
|
|
63
66
|
// dispatches on the *response* (not immediately) and carries no folderId.
|
|
@@ -203,6 +206,13 @@ export function Category({
|
|
|
203
206
|
awaitingInput={Boolean(session.awaitingInput)}
|
|
204
207
|
onClick={() => onSelectSession(session.id)}
|
|
205
208
|
onArchive={() => handleArchive(session)}
|
|
209
|
+
onDelete={
|
|
210
|
+
// A pending `new-…` chat has no transcript anywhere yet —
|
|
211
|
+
// there is nothing to delete, and the server refuses it.
|
|
212
|
+
String(session.id).startsWith('new-')
|
|
213
|
+
? undefined
|
|
214
|
+
: () => sessionDelete.requestDelete(session)
|
|
215
|
+
}
|
|
206
216
|
onRename={String(session.id).startsWith('new-') ? undefined : (name) => void handleRename(session, name)}
|
|
207
217
|
expandable={hasSubagents}
|
|
208
218
|
hasSubagents={hasSubagents}
|
|
@@ -236,6 +246,14 @@ export function Category({
|
|
|
236
246
|
)}
|
|
237
247
|
</div>
|
|
238
248
|
)}
|
|
249
|
+
|
|
250
|
+
<SessionDeleteModal
|
|
251
|
+
session={sessionDelete.pending}
|
|
252
|
+
isDeleting={sessionDelete.isDeleting}
|
|
253
|
+
error={sessionDelete.error}
|
|
254
|
+
onClose={sessionDelete.cancelDelete}
|
|
255
|
+
onConfirm={() => void sessionDelete.confirmDelete()}
|
|
256
|
+
/>
|
|
239
257
|
</div>
|
|
240
258
|
);
|
|
241
259
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { TargetedMouseEvent } from 'preact';
|
|
2
|
-
import {
|
|
2
|
+
import { Check, ChevronDown, ChevronRight, CircleQuestionMark, Loader2, MoreHorizontal } from 'lucide-preact';
|
|
3
3
|
import { useInlineRename } from '@/client/hooks/ui/inline-rename';
|
|
4
|
+
import { SessionActionsMenu, useRowMenu } from '@/client/components/common/session-actions-menu';
|
|
4
5
|
|
|
5
6
|
export interface SessionItemProps {
|
|
6
7
|
title: string;
|
|
@@ -11,6 +12,9 @@ export interface SessionItemProps {
|
|
|
11
12
|
awaitingInput?: boolean;
|
|
12
13
|
onClick?: () => void;
|
|
13
14
|
onArchive?: () => void;
|
|
15
|
+
/** Omitted for a session that cannot be deleted yet (a pending `new-…` chat,
|
|
16
|
+
* which has no transcript anywhere). */
|
|
17
|
+
onDelete?: () => void;
|
|
14
18
|
onRename?: (name: string) => void;
|
|
15
19
|
expandable?: boolean;
|
|
16
20
|
isExpanded?: boolean;
|
|
@@ -26,6 +30,7 @@ export function SessionItem({
|
|
|
26
30
|
awaitingInput = false,
|
|
27
31
|
onClick,
|
|
28
32
|
onArchive,
|
|
33
|
+
onDelete,
|
|
29
34
|
onRename,
|
|
30
35
|
expandable = false,
|
|
31
36
|
isExpanded = false,
|
|
@@ -51,116 +56,121 @@ export function SessionItem({
|
|
|
51
56
|
handleKeyDown,
|
|
52
57
|
handleBlur,
|
|
53
58
|
} = useInlineRename(title, onRename);
|
|
59
|
+
// One trigger for every row action; also reachable by right-clicking the row.
|
|
60
|
+
const menu = useRowMenu();
|
|
61
|
+
const hasActions = Boolean(onRename || onArchive || onDelete);
|
|
54
62
|
|
|
55
63
|
return (
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
onClick={(e) => {
|
|
79
|
-
e.stopPropagation();
|
|
80
|
-
onToggleExpand(e);
|
|
81
|
-
}}
|
|
82
|
-
title={isExpanded ? 'Collapse subagents' : 'Expand subagents'}
|
|
83
|
-
aria-expanded={isExpanded}
|
|
84
|
-
className={`peer absolute inset-0 flex items-center justify-center rounded cursor-pointer transition-opacity ${
|
|
85
|
-
chevronPinned
|
|
86
|
-
? 'text-ink/60 hover:text-ink'
|
|
87
|
-
: 'text-ink/40 hover:text-ink pointer-events-none opacity-0 group-hover/item:pointer-events-auto group-hover/item:opacity-100 focus-visible:opacity-100'
|
|
88
|
-
}`}
|
|
89
|
-
>
|
|
90
|
-
{isExpanded ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
|
|
91
|
-
</button>
|
|
92
|
-
)}
|
|
93
|
-
{showStatus && (
|
|
94
|
-
<span
|
|
95
|
-
className={`pointer-events-none absolute inset-0 flex items-center justify-center text-ink/60 transition-opacity ${
|
|
96
|
-
showChevron ? 'group-hover/item:opacity-0 peer-focus-visible:opacity-0' : ''
|
|
97
|
-
}`}
|
|
98
|
-
>
|
|
99
|
-
{/* Waiting on an answer outranks the run spinner: the spinner says
|
|
100
|
-
work is happening, the question mark says it is YOUR turn. */}
|
|
101
|
-
{awaitingInput ? (
|
|
102
|
-
<CircleQuestionMark size={12} className="animate-pulse text-ink/80" />
|
|
103
|
-
) : status === 'stream' ? (
|
|
104
|
-
<Loader2 size={12} className="animate-spin" />
|
|
105
|
-
) : (
|
|
106
|
-
<Check size={12} />
|
|
107
|
-
)}
|
|
108
|
-
</span>
|
|
109
|
-
)}
|
|
110
|
-
</span>
|
|
111
|
-
|
|
112
|
-
{/* Gap between icon and text */}
|
|
113
|
-
<span className="w-2 shrink-0" />
|
|
114
|
-
|
|
115
|
-
{/* Session Title - aligned straight with Folder Name */}
|
|
116
|
-
{isEditing ? (
|
|
117
|
-
<input
|
|
118
|
-
ref={inputRef}
|
|
119
|
-
autoFocus
|
|
120
|
-
value={draft}
|
|
121
|
-
onChange={(e) => setDraft(e.currentTarget.value)}
|
|
122
|
-
onClick={(e) => e.stopPropagation()}
|
|
123
|
-
onKeyDown={handleKeyDown}
|
|
124
|
-
onBlur={handleBlur}
|
|
125
|
-
className="flex-1 min-w-0 bg-paper border border-ink/25 rounded px-1 py-0.5 text-xs text-ink outline-none focus:border-ink/50"
|
|
126
|
-
/>
|
|
127
|
-
) : (
|
|
128
|
-
<span className="flex-1 min-w-0 truncate leading-snug">
|
|
129
|
-
{title.charAt(0).toUpperCase() + title.slice(1)}
|
|
130
|
-
</span>
|
|
131
|
-
)}
|
|
132
|
-
|
|
133
|
-
{/* Quick Actions: Rename / Archive on Hover */}
|
|
134
|
-
{!isEditing && (onRename || onArchive) && (
|
|
135
|
-
<div className="absolute right-1.5 top-1/2 -translate-y-1/2 flex items-center gap-0.5 opacity-0 group-hover/item:opacity-100 transition-opacity">
|
|
136
|
-
{onRename && (
|
|
64
|
+
<>
|
|
65
|
+
<div
|
|
66
|
+
onClick={isEditing ? undefined : onClick}
|
|
67
|
+
onContextMenu={hasActions && !isEditing ? menu.openAtCursor : undefined}
|
|
68
|
+
className={`group/item relative flex items-center w-full rounded-lg px-2 py-1.5 cursor-pointer text-xs transition-colors select-none ${
|
|
69
|
+
isActive
|
|
70
|
+
? 'bg-ink/10 font-medium text-ink'
|
|
71
|
+
: 'text-ink/75 hover:text-ink hover:bg-ink/5'
|
|
72
|
+
}`}
|
|
73
|
+
>
|
|
74
|
+
{/* One 16px slot owns both icons — never two side by side. A live run
|
|
75
|
+
signal (blocked question, run spinner) holds the slot; otherwise the
|
|
76
|
+
chevron is pinned, collapsed or expanded. While the signal holds it,
|
|
77
|
+
hovering hands the slot to the chevron so the roster stays reachable
|
|
78
|
+
mid-run.
|
|
79
|
+
Both layers are pointer-inert for the opposite state: the toggle
|
|
80
|
+
while hidden, the status glyph ALWAYS (it is painted after the
|
|
81
|
+
toggle, so without that it wins the hit test over the revealed
|
|
82
|
+
chevron and every click on a badged row selects the session instead
|
|
83
|
+
of expanding it). */}
|
|
84
|
+
<span className="relative w-4 h-4 shrink-0">
|
|
85
|
+
{showChevron && onToggleExpand && (
|
|
137
86
|
<button
|
|
138
87
|
type="button"
|
|
139
88
|
onClick={(e) => {
|
|
140
89
|
e.stopPropagation();
|
|
141
|
-
|
|
90
|
+
onToggleExpand(e);
|
|
142
91
|
}}
|
|
143
|
-
title=
|
|
144
|
-
|
|
92
|
+
title={isExpanded ? 'Collapse subagents' : 'Expand subagents'}
|
|
93
|
+
aria-expanded={isExpanded}
|
|
94
|
+
className={`peer absolute inset-0 flex items-center justify-center rounded cursor-pointer transition-opacity ${
|
|
95
|
+
chevronPinned
|
|
96
|
+
? 'text-ink/60 hover:text-ink'
|
|
97
|
+
: 'text-ink/40 hover:text-ink pointer-events-none opacity-0 group-hover/item:pointer-events-auto group-hover/item:opacity-100 focus-visible:opacity-100'
|
|
98
|
+
}`}
|
|
145
99
|
>
|
|
146
|
-
<
|
|
100
|
+
{isExpanded ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
|
|
147
101
|
</button>
|
|
148
102
|
)}
|
|
149
|
-
{
|
|
150
|
-
<
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
onArchive();
|
|
155
|
-
}}
|
|
156
|
-
title={isArchived ? 'Unarchive session' : 'Archive session'}
|
|
157
|
-
className="p-1 text-ink/40 hover:text-ink bg-paper/90 hover:bg-ink/10 rounded shadow-xs cursor-pointer"
|
|
103
|
+
{showStatus && (
|
|
104
|
+
<span
|
|
105
|
+
className={`pointer-events-none absolute inset-0 flex items-center justify-center text-ink/60 transition-opacity ${
|
|
106
|
+
showChevron ? 'group-hover/item:opacity-0 peer-focus-visible:opacity-0' : ''
|
|
107
|
+
}`}
|
|
158
108
|
>
|
|
159
|
-
{
|
|
160
|
-
|
|
109
|
+
{/* Waiting on an answer outranks the run spinner: the spinner says
|
|
110
|
+
work is happening, the question mark says it is YOUR turn. */}
|
|
111
|
+
{awaitingInput ? (
|
|
112
|
+
<CircleQuestionMark size={12} className="animate-pulse text-ink/80" />
|
|
113
|
+
) : status === 'stream' ? (
|
|
114
|
+
<Loader2 size={12} className="animate-spin" />
|
|
115
|
+
) : (
|
|
116
|
+
<Check size={12} />
|
|
117
|
+
)}
|
|
118
|
+
</span>
|
|
161
119
|
)}
|
|
162
|
-
</
|
|
120
|
+
</span>
|
|
121
|
+
|
|
122
|
+
{/* Gap between icon and text */}
|
|
123
|
+
<span className="w-2 shrink-0" />
|
|
124
|
+
|
|
125
|
+
{/* Session Title - aligned straight with Folder Name */}
|
|
126
|
+
{isEditing ? (
|
|
127
|
+
<input
|
|
128
|
+
ref={inputRef}
|
|
129
|
+
autoFocus
|
|
130
|
+
value={draft}
|
|
131
|
+
onChange={(e) => setDraft(e.currentTarget.value)}
|
|
132
|
+
onClick={(e) => e.stopPropagation()}
|
|
133
|
+
onKeyDown={handleKeyDown}
|
|
134
|
+
onBlur={handleBlur}
|
|
135
|
+
className="flex-1 min-w-0 bg-paper border border-ink/25 rounded px-1 py-0.5 text-xs text-ink outline-none focus:border-ink/50"
|
|
136
|
+
/>
|
|
137
|
+
) : (
|
|
138
|
+
<span className="flex-1 min-w-0 truncate leading-snug">
|
|
139
|
+
{title.charAt(0).toUpperCase() + title.slice(1)}
|
|
140
|
+
</span>
|
|
141
|
+
)}
|
|
142
|
+
|
|
143
|
+
{/* One trigger, whatever the row can do. The three side-by-side hover
|
|
144
|
+
icons this replaced cost a third of the title's width in a 268px
|
|
145
|
+
sidebar, and each further action took another slice — the menu grows
|
|
146
|
+
instead. Revealed on hover/focus; a right-click anywhere on the row
|
|
147
|
+
opens the same menu. */}
|
|
148
|
+
{!isEditing && hasActions && (
|
|
149
|
+
<button
|
|
150
|
+
type="button"
|
|
151
|
+
onClick={menu.openBelow}
|
|
152
|
+
title="Session actions"
|
|
153
|
+
aria-label="Session actions"
|
|
154
|
+
aria-haspopup="menu"
|
|
155
|
+
aria-expanded={menu.anchor !== null}
|
|
156
|
+
className="shrink-0 p-1 text-ink/40 hover:text-ink hover:bg-ink/10 rounded cursor-pointer transition-opacity opacity-0 group-hover/item:opacity-100 focus-visible:opacity-100"
|
|
157
|
+
>
|
|
158
|
+
<MoreHorizontal size={14} />
|
|
159
|
+
</button>
|
|
160
|
+
)}
|
|
161
|
+
|
|
162
|
+
</div>
|
|
163
|
+
|
|
164
|
+
{menu.anchor && (
|
|
165
|
+
<SessionActionsMenu
|
|
166
|
+
anchor={menu.anchor}
|
|
167
|
+
isArchived={isArchived}
|
|
168
|
+
onRename={onRename ? startRename : undefined}
|
|
169
|
+
onArchive={onArchive}
|
|
170
|
+
onDelete={onDelete}
|
|
171
|
+
onClose={menu.close}
|
|
172
|
+
/>
|
|
163
173
|
)}
|
|
164
|
-
|
|
174
|
+
</>
|
|
165
175
|
);
|
|
166
176
|
}
|
|
@@ -7,6 +7,8 @@ import { WorkspaceOptionsMenu } from '@/client/components/common/workspace-optio
|
|
|
7
7
|
import { useShowMore } from '@/client/hooks/ui/show-more';
|
|
8
8
|
import { useExpandedSessions } from '@/client/hooks/workspace/expanded-sessions';
|
|
9
9
|
import { useWorkspaceFolderActions } from '@/client/hooks/workspace/workspace-folder-actions';
|
|
10
|
+
import { useSessionDelete } from '@/client/hooks/workspace/session-delete';
|
|
11
|
+
import { SessionDeleteModal } from '@/client/components/common/session-delete-modal';
|
|
10
12
|
import { getProjectIcon } from '@/shared/lib/workspace/project-icon';
|
|
11
13
|
import { relativeTimeAgo } from '@/shared/lib/workspace/relative-time';
|
|
12
14
|
import { useOnClickOutside } from '@/client/hooks/ui/on-click-outside';
|
|
@@ -53,6 +55,7 @@ export function MobileSessionCategory({
|
|
|
53
55
|
handleArchive,
|
|
54
56
|
handleRename,
|
|
55
57
|
} = useWorkspaceFolderActions(folder, refresh);
|
|
58
|
+
const sessionDelete = useSessionDelete();
|
|
56
59
|
|
|
57
60
|
useOnClickOutside(menuRef, () => {
|
|
58
61
|
setShowMenu(false);
|
|
@@ -195,6 +198,13 @@ export function MobileSessionCategory({
|
|
|
195
198
|
onToggleExpand={() => toggleSession(sessionKey)}
|
|
196
199
|
onSelect={() => onSelectSession(session.id)}
|
|
197
200
|
onArchive={() => handleArchive(session)}
|
|
201
|
+
onDelete={
|
|
202
|
+
// A pending `new-…` chat has no transcript anywhere yet —
|
|
203
|
+
// there is nothing to delete, and the server refuses it.
|
|
204
|
+
sessionKey.startsWith('new-')
|
|
205
|
+
? undefined
|
|
206
|
+
: () => sessionDelete.requestDelete(session)
|
|
207
|
+
}
|
|
198
208
|
onRename={sessionKey.startsWith('new-') ? undefined : (name) => void handleRename(session, name)}
|
|
199
209
|
/>
|
|
200
210
|
{isRosterOpen && (
|
|
@@ -216,6 +226,14 @@ export function MobileSessionCategory({
|
|
|
216
226
|
)}
|
|
217
227
|
</div>
|
|
218
228
|
)}
|
|
229
|
+
|
|
230
|
+
<SessionDeleteModal
|
|
231
|
+
session={sessionDelete.pending}
|
|
232
|
+
isDeleting={sessionDelete.isDeleting}
|
|
233
|
+
error={sessionDelete.error}
|
|
234
|
+
onClose={sessionDelete.cancelDelete}
|
|
235
|
+
onConfirm={() => void sessionDelete.confirmDelete()}
|
|
236
|
+
/>
|
|
219
237
|
</div>
|
|
220
238
|
);
|
|
221
239
|
}
|