ompchamber 3.4.0 → 3.6.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.
Files changed (87) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +387 -383
  3. package/dist/client/static/js/index-7n838fz5.css +1 -0
  4. package/dist/client/static/js/{index-h05z90mw.js → index-qp2sfmn2.js} +614 -615
  5. package/package.json +1 -1
  6. package/src/cli/lib/commands/status.js +7 -0
  7. package/src/client/components/common/session-actions-menu/index.tsx +201 -0
  8. package/src/client/components/common/session-delete-modal/index.tsx +128 -0
  9. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +18 -0
  10. package/src/client/components/layout/session-sidebar/SessionItem.tsx +109 -99
  11. package/src/client/components/layout/session-sidebar/about-modal/UpdateLog.tsx +50 -0
  12. package/src/client/components/layout/session-sidebar/about-modal/UpdateSection.tsx +12 -17
  13. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +18 -0
  14. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +82 -64
  15. package/src/client/components/settings/ProjectSelectorDropdown.tsx +8 -2
  16. package/src/client/components/settings/SettingsSidebarList.tsx +5 -1
  17. package/src/client/components/settings/categories/CommandSettings.tsx +47 -9
  18. package/src/client/components/settings/categories/SkillCatalogSettings.tsx +23 -43
  19. package/src/client/components/settings/categories/SkillSettings.tsx +51 -7
  20. package/src/client/components/settings/categories/command-settings/DetailPane.tsx +109 -103
  21. package/src/client/components/settings/categories/command-settings/SidebarList.tsx +6 -1
  22. package/src/client/components/settings/categories/skill-settings/DetailPane.tsx +130 -31
  23. package/src/client/components/settings/categories/skill-settings/SidebarList.tsx +9 -3
  24. package/src/client/data/settings/command.ts +0 -40
  25. package/src/client/hooks/settings/workspace-roots.ts +99 -0
  26. package/src/client/hooks/ui/updates/index.ts +178 -0
  27. package/src/client/hooks/workspace/session-delete.ts +94 -0
  28. package/src/server/db.server.ts +41 -11
  29. package/src/server/index.ts +17 -1
  30. package/src/server/lib/btw/purge.server.ts +56 -0
  31. package/src/server/lib/db/client.ts +17 -0
  32. package/src/server/lib/db/compact-chat-overlay.test.ts +121 -0
  33. package/src/server/lib/db/compact-chat-overlay.ts +90 -0
  34. package/src/server/lib/fs/path-segment.test.ts +100 -0
  35. package/src/server/lib/fs/path-segment.ts +45 -0
  36. package/src/server/lib/lifecycle/fd-pressure.test.ts +66 -0
  37. package/src/server/lib/lifecycle/fd-pressure.ts +159 -0
  38. package/src/server/lib/omp/config/commands.test.ts +167 -0
  39. package/src/server/lib/omp/config/commands.ts +195 -0
  40. package/src/server/lib/omp/config/scope.test.ts +49 -0
  41. package/src/server/lib/omp/config/scope.ts +65 -0
  42. package/src/server/lib/omp/config/skills-catalog.ts +35 -3
  43. package/src/server/lib/omp/config/skills.test.ts +105 -0
  44. package/src/server/lib/omp/config/skills.ts +260 -47
  45. package/src/server/lib/omp/rpc/process.ts +22 -14
  46. package/src/server/lib/omp/rpc/session-registry.ts +6 -0
  47. package/src/server/lib/omp/rpc/utility.ts +45 -23
  48. package/src/server/lib/omp/session/delete.server.ts +136 -0
  49. package/src/server/lib/omp/session/merge-stored-anchor.test.ts +85 -0
  50. package/src/server/lib/omp/session/merge-stored-overlay-rows.test.ts +106 -0
  51. package/src/server/lib/omp/session/merge-stored.ts +78 -11
  52. package/src/server/lib/omp/session/reader.ts +13 -0
  53. package/src/server/lib/omp/session/reload.server.test.ts +84 -0
  54. package/src/server/lib/omp/session/reload.server.ts +47 -0
  55. package/src/server/lib/omp/session/subagent-presence.ts +40 -9
  56. package/src/server/lib/terminal/liveness.server.ts +65 -0
  57. package/src/server/lib/terminal/runtime.server.ts +70 -117
  58. package/src/server/lib/terminal/session.server.ts +5 -2
  59. package/src/server/lib/terminal/store.ts +72 -0
  60. package/src/server/lib/updates/apply.ts +62 -33
  61. package/src/server/lib/updates/install.ts +9 -2
  62. package/src/server/lib/updates/omp.ts +37 -9
  63. package/src/server/lib/updates/single-flight.test.ts +61 -0
  64. package/src/server/lib/updates/single-flight.ts +68 -0
  65. package/src/server/routes/chat/session.ts +13 -3
  66. package/src/server/routes/health/probe.ts +8 -0
  67. package/src/server/routes/omp/index.ts +0 -2
  68. package/src/server/routes/sessions/delete.test.ts +84 -0
  69. package/src/server/routes/sessions/delete.ts +83 -0
  70. package/src/server/routes/sessions/index.ts +5 -0
  71. package/src/server/routes/settings/commands.ts +102 -15
  72. package/src/server/routes/settings/skills.ts +165 -113
  73. package/src/server/routes/terminal/sessions.ts +1 -1
  74. package/src/server/routes/updates/apply.ts +101 -14
  75. package/src/shared/lib/chat/composer/client.ts +27 -18
  76. package/src/shared/lib/chat/composer/scope.test.ts +87 -0
  77. package/src/shared/lib/markdown/katex.ts +11 -1
  78. package/src/shared/lib/markdown/marked.ts +68 -13
  79. package/src/shared/lib/markdown/math.test.ts +53 -1
  80. package/src/shared/lib/updates/progress.test.ts +54 -0
  81. package/src/shared/lib/updates/progress.ts +50 -0
  82. package/src/shared/lib/workspace/session-state/store.ts +20 -0
  83. package/src/shared/types/settings/command.ts +15 -2
  84. package/src/shared/types/settings/skill.ts +13 -0
  85. package/dist/client/static/js/index-bsxxt89j.css +0 -1
  86. package/src/client/hooks/ui/updates.ts +0 -108
  87. package/src/server/routes/omp/commands.ts +0 -29
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ompchamber",
3
- "version": "3.4.0",
3
+ "version": "3.6.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>",
@@ -47,6 +47,13 @@ function printInstance(entry) {
47
47
  log(` launch: ${entry.launchMode ?? 'unknown'}${entry.source ? ` (${entry.source})` : ''}`);
48
48
  log(` uptime: ${formatUptime(entry.startedAt)}`);
49
49
  log(` health: ${formatHealth(entry.health)}`);
50
+ // Descriptor pressure, when the server can measure it. A dev server climbs
51
+ // toward 10,240 by holding the client module graph across rebuilds, and past
52
+ // that point every `Bun.spawn` in the process fails at once (the terminal
53
+ // shell, the omp child, git) — this line is the warning that arrives before
54
+ // the breakage instead of after it.
55
+ const fds = entry.health?.fds;
56
+ if (fds) log(` fds: ${fds.open}/${fds.limit}${fds.nearCliff ? ' (near the spawn limit — restart this instance)' : ''}`);
50
57
  log(` log: ${entry.logFile}`);
51
58
  }
52
59
 
@@ -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 { Archive, ArchiveRestore, Check, ChevronDown, ChevronRight, CircleQuestionMark, Loader2, Pencil } from 'lucide-preact';
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
- <div
57
- onClick={isEditing ? undefined : onClick}
58
- className={`group/item relative flex items-center w-full rounded-lg px-2 py-1.5 cursor-pointer text-xs transition-colors select-none ${
59
- isActive
60
- ? 'bg-ink/10 font-medium text-ink'
61
- : 'text-ink/75 hover:text-ink hover:bg-ink/5'
62
- }`}
63
- >
64
- {/* One 16px slot owns both icons — never two side by side. A live run
65
- signal (blocked question, run spinner) holds the slot; otherwise the
66
- chevron is pinned, collapsed or expanded. While the signal holds it,
67
- hovering hands the slot to the chevron so the roster stays reachable
68
- mid-run.
69
- Both layers are pointer-inert for the opposite state: the toggle
70
- while hidden, the status glyph ALWAYS (it is painted after the
71
- toggle, so without that it wins the hit test over the revealed
72
- chevron and every click on a badged row selects the session instead
73
- of expanding it). */}
74
- <span className="relative w-4 h-4 shrink-0">
75
- {showChevron && onToggleExpand && (
76
- <button
77
- type="button"
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
- startRename();
90
+ onToggleExpand(e);
142
91
  }}
143
- title="Rename session"
144
- className="p-1 text-ink/40 hover:text-ink bg-paper/90 hover:bg-ink/10 rounded shadow-xs cursor-pointer"
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
- <Pencil size={12} />
100
+ {isExpanded ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
147
101
  </button>
148
102
  )}
149
- {onArchive && (
150
- <button
151
- type="button"
152
- onClick={(e) => {
153
- e.stopPropagation();
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
- {isArchived ? <ArchiveRestore size={12} /> : <Archive size={12} />}
160
- </button>
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
- </div>
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
- </div>
174
+ </>
165
175
  );
166
176
  }