@kabmo-labs/kabmo 0.0.1 → 0.1.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/README.md +16 -11
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +323 -323
- package/dist/client/static/js/{index-4vyzz0z4.js → index-enkbbffs.js} +643 -643
- package/dist/client/static/js/index-yn17t04b.css +1 -0
- package/package.json +2 -1
- package/public/kabmo-wordmark.svg +31 -0
- package/src/cli/lib/commands/update.js +17 -2
- package/src/cli/lib/commands/update.test.js +36 -0
- package/src/client/components/auth/login-screen/index.test.tsx +93 -0
- package/src/client/components/auth/login-screen/index.tsx +43 -22
- package/src/client/components/auth/login-screen/password-toggle.test.tsx +121 -0
- package/src/client/components/common/batch-selection/BatchActionBar.test.ts +132 -0
- package/src/client/components/common/batch-selection/BatchActionBar.tsx +118 -0
- package/src/client/components/common/batch-selection/BatchDeleteModal.tsx +177 -0
- package/src/client/components/common/batch-selection/SelectionCheckbox.tsx +58 -0
- package/src/client/components/common/batch-selection/folder-state.test.ts +130 -0
- package/src/client/components/common/batch-selection/folder-state.ts +101 -0
- package/src/client/components/common/batch-selection/types.ts +42 -0
- package/src/client/components/common/brand-wordmark/Mark.tsx +62 -0
- package/src/client/components/common/brand-wordmark/index.test.tsx +29 -7
- package/src/client/components/common/brand-wordmark/index.tsx +33 -11
- package/src/client/components/common/brand-wordmark/wordmark-svg.test.ts +88 -0
- package/src/client/components/common/workspace-options-menu/index.tsx +25 -11
- package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +169 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +72 -91
- package/src/client/components/layout/session-sidebar/SessionItem.batch.test.ts +162 -0
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +35 -6
- package/src/client/components/layout/session-sidebar/SessionList.tsx +10 -1
- package/src/client/components/layout/session-sidebar/Toolbar.test.ts +2 -0
- package/src/client/components/layout/session-sidebar/Toolbar.tsx +28 -1
- package/src/client/components/layout/session-sidebar/index.tsx +87 -0
- package/src/client/components/mobile/SessionSidebar.tsx +78 -0
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +56 -12
- package/src/client/components/mobile/mobile-session-sidebar/List.tsx +8 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +27 -6
- package/src/client/components/mobile/mobile-session-sidebar/Toolbar.test.ts +2 -0
- package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +26 -1
- package/src/client/hooks/workspace/batch-delete-run.ts +95 -0
- package/src/client/hooks/workspace/batch-delete.ts +63 -0
- package/src/client/hooks/workspace/batch-selection.ts +98 -0
- package/src/client/tailwind.css +6 -0
- package/src/server/lib/lifecycle/restart.ts +52 -14
- package/src/server/lib/omp/session/delete-flow.server.ts +101 -0
- package/src/server/lib/updates/apply.ts +8 -0
- package/src/server/lib/updates/install-steps.ts +81 -0
- package/src/server/lib/updates/install.ts +19 -67
- package/src/server/lib/updates/installed-cli.test.ts +222 -0
- package/src/server/lib/updates/installed-cli.ts +139 -0
- package/src/server/lib/updates/omp.ts +1 -1
- package/src/server/routes/folders/batch-delete.ts +68 -0
- package/src/server/routes/folders/index.ts +4 -0
- package/src/server/routes/sessions/batch-delete.test.ts +139 -0
- package/src/server/routes/sessions/batch-delete.ts +60 -0
- package/src/server/routes/sessions/delete.ts +13 -51
- package/src/server/routes/sessions/index.ts +5 -0
- package/src/server/routes/updates/apply.ts +1 -1
- package/src/shared/lib/workspace/batch-select.test.ts +92 -0
- package/src/shared/lib/workspace/batch-select.ts +86 -0
- package/src/shared/lib/workspace/sidebar-toolbar.ts +6 -0
- package/dist/client/static/js/index-x9cehh1a.css +0 -1
|
@@ -3,6 +3,7 @@ import { Check, ChevronDown, ChevronRight, CircleQuestionMark, Loader2, MoreHori
|
|
|
3
3
|
import { useInlineRename } from '@/client/hooks/ui/inline-rename';
|
|
4
4
|
import { useSessionRenaming } from '@/client/hooks/workspace/renaming-sessions';
|
|
5
5
|
import { SessionActionsMenu, useRowMenu } from '@/client/components/common/session-actions-menu';
|
|
6
|
+
import { SelectionCheckbox } from '@/client/components/common/batch-selection/SelectionCheckbox';
|
|
6
7
|
|
|
7
8
|
export interface SessionItemProps {
|
|
8
9
|
/** The session this row draws; the key its in-flight rename is tracked under. */
|
|
@@ -29,6 +30,11 @@ export interface SessionItemProps {
|
|
|
29
30
|
isExpanded?: boolean;
|
|
30
31
|
hasSubagents?: boolean;
|
|
31
32
|
onToggleExpand?: (e: TargetedMouseEvent<HTMLElement>) => void;
|
|
33
|
+
/** Draw a checkbox and let the row's click toggle the selection instead of
|
|
34
|
+
* opening the session (batch mode). */
|
|
35
|
+
selectable?: boolean;
|
|
36
|
+
selected?: boolean;
|
|
37
|
+
onToggleSelect?: () => void;
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
export function SessionItem({
|
|
@@ -48,6 +54,9 @@ export function SessionItem({
|
|
|
48
54
|
isExpanded = false,
|
|
49
55
|
hasSubagents = false,
|
|
50
56
|
onToggleExpand,
|
|
57
|
+
selectable = false,
|
|
58
|
+
selected = false,
|
|
59
|
+
onToggleSelect,
|
|
51
60
|
}: SessionItemProps) {
|
|
52
61
|
// A rename in flight is drawn in the same 16px slot the run spinner uses, so
|
|
53
62
|
// a row the user just acted on says so where they are already looking. It
|
|
@@ -82,14 +91,32 @@ export function SessionItem({
|
|
|
82
91
|
<div
|
|
83
92
|
data-testid="session-item"
|
|
84
93
|
data-session-id={String(sessionId)}
|
|
85
|
-
|
|
86
|
-
|
|
94
|
+
data-selected={selectable && selected ? 'true' : undefined}
|
|
95
|
+
onClick={isEditing ? undefined : selectable ? onToggleSelect : onClick}
|
|
96
|
+
onContextMenu={hasActions && !isEditing && !selectable ? menu.openAtCursor : undefined}
|
|
87
97
|
className={`group/item relative flex items-center w-full rounded-lg px-2 py-1.5 cursor-pointer text-xs transition-colors select-none ${
|
|
88
|
-
|
|
89
|
-
? 'bg-ink/10
|
|
90
|
-
:
|
|
98
|
+
selectable && selected
|
|
99
|
+
? 'bg-ink/10 text-ink ring-1 ring-inset ring-ink/20'
|
|
100
|
+
: isActive
|
|
101
|
+
? 'bg-ink/10 font-medium text-ink'
|
|
102
|
+
: 'text-ink/75 hover:text-ink hover:bg-ink/5'
|
|
91
103
|
}`}
|
|
92
104
|
>
|
|
105
|
+
{/* The checkbox takes the row's leading slot in batch mode. It replaces
|
|
106
|
+
the status/chevron column rather than sitting beside it: a row in
|
|
107
|
+
this mode is a choice, and a run spinner next to a checkbox reads as
|
|
108
|
+
a second, unrelated control. */}
|
|
109
|
+
{selectable ? (
|
|
110
|
+
<>
|
|
111
|
+
<SelectionCheckbox
|
|
112
|
+
checked={selected}
|
|
113
|
+
label={`Select ${title}`}
|
|
114
|
+
onChange={() => onToggleSelect?.()}
|
|
115
|
+
/>
|
|
116
|
+
<span className="w-2 shrink-0" />
|
|
117
|
+
</>
|
|
118
|
+
) : (
|
|
119
|
+
<>
|
|
93
120
|
{/* One 16px slot owns both icons — never two side by side. A live run
|
|
94
121
|
signal (blocked question, run spinner) holds the slot; otherwise the
|
|
95
122
|
chevron is pinned, collapsed or expanded. While the signal holds it,
|
|
@@ -147,6 +174,8 @@ export function SessionItem({
|
|
|
147
174
|
|
|
148
175
|
{/* Gap between icon and text */}
|
|
149
176
|
<span className="w-2 shrink-0" />
|
|
177
|
+
</>
|
|
178
|
+
)}
|
|
150
179
|
|
|
151
180
|
{/* Session Title - aligned straight with Folder Name */}
|
|
152
181
|
{isEditing ? (
|
|
@@ -181,7 +210,7 @@ export function SessionItem({
|
|
|
181
210
|
`ml-1` on top of the button's own `p-1` is the same 8px the title
|
|
182
211
|
keeps from the time column, so the row's two gaps read as one
|
|
183
212
|
rhythm instead of the glyph crowding the age. */}
|
|
184
|
-
{!isEditing && hasActions && (
|
|
213
|
+
{!isEditing && hasActions && !selectable && (
|
|
185
214
|
<button
|
|
186
215
|
type="button"
|
|
187
216
|
onClick={menu.openBelow}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { Category } from '@/client/components/layout/session-sidebar/CategoryItem';
|
|
2
|
+
import type { BatchBinding } from '@/client/components/common/batch-selection/types';
|
|
3
|
+
import type { WorkspaceFolderData } from '@/shared/types';
|
|
2
4
|
|
|
3
5
|
interface SessionSidebarSessionListProps {
|
|
4
|
-
folders:
|
|
6
|
+
folders: WorkspaceFolderData[];
|
|
5
7
|
activeSessionId: number | string | null;
|
|
6
8
|
searchQuery: string;
|
|
7
9
|
showArchived: boolean;
|
|
@@ -10,6 +12,9 @@ interface SessionSidebarSessionListProps {
|
|
|
10
12
|
onNewSessionForFolder: (id: number) => void;
|
|
11
13
|
/** Surfaces a refused folder action (the reveal endpoint's own reason). */
|
|
12
14
|
onToast?: (message: string, type?: 'success' | 'error') => void;
|
|
15
|
+
/** Present only while a batch selection is active. */
|
|
16
|
+
batch?: BatchBinding;
|
|
17
|
+
onStartSessionSelection?: (folderId: number) => void;
|
|
13
18
|
}
|
|
14
19
|
|
|
15
20
|
export function SessionSidebarSessionList({
|
|
@@ -21,6 +26,8 @@ export function SessionSidebarSessionList({
|
|
|
21
26
|
onSelectSession,
|
|
22
27
|
onNewSessionForFolder,
|
|
23
28
|
onToast,
|
|
29
|
+
batch,
|
|
30
|
+
onStartSessionSelection,
|
|
24
31
|
}: SessionSidebarSessionListProps) {
|
|
25
32
|
// No fade wiring here: the app-wide scrollbar rule reveals this list's bar
|
|
26
33
|
// from the document's own capture-phase listener (`lib/scrollbar-activity`),
|
|
@@ -43,6 +50,8 @@ export function SessionSidebarSessionList({
|
|
|
43
50
|
showArchived={showArchived}
|
|
44
51
|
sessionStatus={sessionStatus}
|
|
45
52
|
onToast={onToast}
|
|
53
|
+
batch={batch}
|
|
54
|
+
onStartSessionSelection={onStartSessionSelection}
|
|
46
55
|
/>
|
|
47
56
|
))
|
|
48
57
|
)}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Archive, ArrowUpDown, Calendar, FolderPlus, Plus, RefreshCw, Search, X } from 'lucide-preact';
|
|
1
|
+
import { Archive, ArrowUpDown, Calendar, FolderPlus, ListChecks, Plus, RefreshCw, Search, X } from 'lucide-preact';
|
|
2
2
|
import { useLayoutEffect, useRef, useState } from 'preact/hooks';
|
|
3
3
|
import type { SessionSortOption } from '@/shared/types';
|
|
4
4
|
import { SortMenu } from '@/client/components/common/sort-menu';
|
|
@@ -32,6 +32,10 @@ interface SessionSidebarToolbarProps {
|
|
|
32
32
|
onRefresh: () => void;
|
|
33
33
|
/** True while the user-initiated refresh is in flight — spins the icon. */
|
|
34
34
|
refreshing: boolean;
|
|
35
|
+
/** Enter the workspace batch-selection mode (checkbox on each workspace). */
|
|
36
|
+
onSelectWorkspaces: () => void;
|
|
37
|
+
/** True while a batch selection is active — the control reads pressed. */
|
|
38
|
+
selecting: boolean;
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
/**
|
|
@@ -88,6 +92,8 @@ export function SessionSidebarToolbar({
|
|
|
88
92
|
scheduledCount,
|
|
89
93
|
onRefresh,
|
|
90
94
|
refreshing,
|
|
95
|
+
onSelectWorkspaces,
|
|
96
|
+
selecting,
|
|
91
97
|
}: SessionSidebarToolbarProps) {
|
|
92
98
|
const { ref: rowRef, width } = useRowWidth();
|
|
93
99
|
const capacity = sidebarToolbarCapacity(width);
|
|
@@ -111,6 +117,15 @@ export function SessionSidebarToolbar({
|
|
|
111
117
|
if (hidden.includes('archive')) {
|
|
112
118
|
overflowActions.push({ id: 'archive', label: archiveLabel, icon: Archive, onSelect: onToggleArchived, active: showArchived });
|
|
113
119
|
}
|
|
120
|
+
if (hidden.includes('select')) {
|
|
121
|
+
overflowActions.push({
|
|
122
|
+
id: 'select',
|
|
123
|
+
label: selecting ? 'Cancel selection' : 'Select workspaces',
|
|
124
|
+
icon: ListChecks,
|
|
125
|
+
onSelect: onSelectWorkspaces,
|
|
126
|
+
active: selecting,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
114
129
|
if (hidden.includes('scheduler')) {
|
|
115
130
|
overflowActions.push({ id: 'scheduler', label: schedulerLabel, icon: Calendar, onSelect: onScheduler });
|
|
116
131
|
}
|
|
@@ -199,6 +214,18 @@ export function SessionSidebarToolbar({
|
|
|
199
214
|
<Search size={14} />
|
|
200
215
|
</button>
|
|
201
216
|
)}
|
|
217
|
+
{visible.has('select') && (
|
|
218
|
+
<button
|
|
219
|
+
type="button"
|
|
220
|
+
title={selecting ? 'Cancel selection' : 'Select workspaces'}
|
|
221
|
+
aria-label={selecting ? 'Cancel selection' : 'Select workspaces'}
|
|
222
|
+
aria-pressed={selecting}
|
|
223
|
+
onClick={onSelectWorkspaces}
|
|
224
|
+
className={`${ICON_CLASS} ${selecting ? ICON_ACTIVE : ICON_IDLE}`}
|
|
225
|
+
>
|
|
226
|
+
<ListChecks size={14} />
|
|
227
|
+
</button>
|
|
228
|
+
)}
|
|
202
229
|
{visible.has('archive') && (
|
|
203
230
|
<button
|
|
204
231
|
type="button"
|
|
@@ -18,6 +18,12 @@ import { useSessionSidebarController } from '@/client/hooks/chat/omp/session-sid
|
|
|
18
18
|
import { startPendingSession } from '@/client/hooks/chat/omp/pending-sessions';
|
|
19
19
|
import { useScheduledTaskCount } from '@/client/hooks/workspace/scheduled-tasks';
|
|
20
20
|
import { cloneWorkspaceFolder, createWorkspaceFolder } from '@/client/hooks/workspace/new-workspace';
|
|
21
|
+
import { useBatchSelection } from '@/client/hooks/workspace/batch-selection';
|
|
22
|
+
import { useBatchDeleteRun } from '@/client/hooks/workspace/batch-delete-run';
|
|
23
|
+
import { BatchActionBar } from '@/client/components/common/batch-selection/BatchActionBar';
|
|
24
|
+
import { BatchDeleteModal } from '@/client/components/common/batch-selection/BatchDeleteModal';
|
|
25
|
+
import type { BatchBinding } from '@/client/components/common/batch-selection/types';
|
|
26
|
+
import { liveSelection, selectableSessionIds } from '@/shared/lib/workspace/batch-select';
|
|
21
27
|
import type { AgentStreamStatus } from '@/shared/lib/chat/omp/status';
|
|
22
28
|
|
|
23
29
|
export function SessionSidebar({ className = '', onClose, appSettings = {}, streamStatus }: { className?: string, onClose?: () => void, appSettings?: Record<string, any>, streamStatus?: AgentStreamStatus }) {
|
|
@@ -55,6 +61,43 @@ export function SessionSidebar({ className = '', onClose, appSettings = {}, stre
|
|
|
55
61
|
// Sidebar inline states
|
|
56
62
|
const [isSearchVisible, setIsSearchVisible] = useState(false);
|
|
57
63
|
|
|
64
|
+
// Batch selection: two separate modes (sessions from a workspace's `⋯` menu,
|
|
65
|
+
// workspaces from the toolbar). `batchDelete.pending` holds the ids the
|
|
66
|
+
// confirmation dialog is about; the bar's Delete opens it and never deletes
|
|
67
|
+
// on its own.
|
|
68
|
+
const batch = useBatchSelection();
|
|
69
|
+
const batchDelete = useBatchDeleteRun(refreshNow);
|
|
70
|
+
|
|
71
|
+
const scopeFolder = batch.scopeFolderId === null
|
|
72
|
+
? null
|
|
73
|
+
: processedFolders.find((folder) => folder.id === batch.scopeFolderId) ?? null;
|
|
74
|
+
const batchAvailable = batch.mode === 'workspace'
|
|
75
|
+
? processedFolders.map((folder) => String(folder.id))
|
|
76
|
+
: batch.mode === 'session'
|
|
77
|
+
? selectableSessionIds(scopeFolder?.sessions ?? [], showArchived)
|
|
78
|
+
: [];
|
|
79
|
+
// The count and the payload are the LIVE selection: an id that vanished from
|
|
80
|
+
// the list (deleted in another tab, filtered away) is not something this bar
|
|
81
|
+
// may still claim, and not something Delete may still send.
|
|
82
|
+
const batchSelected = liveSelection(batch.selected, batchAvailable);
|
|
83
|
+
|
|
84
|
+
const batchBinding: BatchBinding | undefined = batch.mode
|
|
85
|
+
? {
|
|
86
|
+
mode: batch.mode,
|
|
87
|
+
scopeFolderId: batch.scopeFolderId,
|
|
88
|
+
selected: batch.selected,
|
|
89
|
+
toggle: batch.toggle,
|
|
90
|
+
selectAllOf: batch.selectAllOf,
|
|
91
|
+
unselectAllOf: batch.unselectAllOf,
|
|
92
|
+
summaryOf: batch.summaryOf,
|
|
93
|
+
}
|
|
94
|
+
: undefined;
|
|
95
|
+
|
|
96
|
+
const startWorkspaceSelection = () => {
|
|
97
|
+
if (batch.mode === 'workspace') batch.end();
|
|
98
|
+
else batch.begin('workspace');
|
|
99
|
+
};
|
|
100
|
+
|
|
58
101
|
// The popup's "Update now" hands the run over instead of starting its own: the
|
|
59
102
|
// About modal is where an update has always been driven from and it owns the
|
|
60
103
|
// live output, so this opens it and starts the run it will draw. The popup
|
|
@@ -131,6 +174,8 @@ export function SessionSidebar({ className = '', onClose, appSettings = {}, stre
|
|
|
131
174
|
scheduledCount={scheduledCount}
|
|
132
175
|
onRefresh={refreshNow}
|
|
133
176
|
refreshing={isRefreshing}
|
|
177
|
+
onSelectWorkspaces={startWorkspaceSelection}
|
|
178
|
+
selecting={batch.mode !== null}
|
|
134
179
|
/>
|
|
135
180
|
|
|
136
181
|
{initializing ? (
|
|
@@ -145,6 +190,22 @@ export function SessionSidebar({ className = '', onClose, appSettings = {}, stre
|
|
|
145
190
|
onSelectSession={handleSelectSession}
|
|
146
191
|
onNewSessionForFolder={handleNewSessionForFolder}
|
|
147
192
|
onToast={pushToast}
|
|
193
|
+
batch={batchBinding}
|
|
194
|
+
onStartSessionSelection={(folderId) => batch.begin('session', folderId)}
|
|
195
|
+
/>
|
|
196
|
+
)}
|
|
197
|
+
|
|
198
|
+
{batch.mode && (
|
|
199
|
+
<BatchActionBar
|
|
200
|
+
mode={batch.mode}
|
|
201
|
+
count={batchSelected.length}
|
|
202
|
+
total={batchAvailable.length}
|
|
203
|
+
busy={batchDelete.isDeleting}
|
|
204
|
+
scopeLabel={scopeFolder?.name}
|
|
205
|
+
onSelectAll={() => batch.selectAllOf(batchAvailable)}
|
|
206
|
+
onUnselectAll={() => batch.unselectAllOf(batchAvailable)}
|
|
207
|
+
onDelete={() => batchDelete.request(batchSelected)}
|
|
208
|
+
onCancel={batch.end}
|
|
148
209
|
/>
|
|
149
210
|
)}
|
|
150
211
|
|
|
@@ -174,6 +235,32 @@ export function SessionSidebar({ className = '', onClose, appSettings = {}, stre
|
|
|
174
235
|
Its "Update now" hands the run to the About modal below. */}
|
|
175
236
|
<UpdatePopup updates={updates} />
|
|
176
237
|
|
|
238
|
+
{/* Batch delete confirmation. It stays open on a refusal so the reasons
|
|
239
|
+
can be read; a fully successful run closes itself and leaves the mode. */}
|
|
240
|
+
<BatchDeleteModal
|
|
241
|
+
mode={batch.mode ?? 'session'}
|
|
242
|
+
count={batchDelete.pending.length}
|
|
243
|
+
scopeLabel={scopeFolder?.name}
|
|
244
|
+
isDeleting={batchDelete.isDeleting}
|
|
245
|
+
outcome={batchDelete.outcome}
|
|
246
|
+
onClose={() => {
|
|
247
|
+
// A run that finished cleanly also ends the mode; one that refused
|
|
248
|
+
// ids keeps it, so the rows that survived are still selected for a
|
|
249
|
+
// second attempt.
|
|
250
|
+
const refusedCount = batchDelete.outcome?.refused.length ?? 0;
|
|
251
|
+
batchDelete.cancel();
|
|
252
|
+
if (batchDelete.outcome && refusedCount === 0) batch.end();
|
|
253
|
+
}}
|
|
254
|
+
onConfirm={() => {
|
|
255
|
+
const mode = batch.mode ?? 'session';
|
|
256
|
+
void batchDelete.confirm(mode).then((result) => {
|
|
257
|
+
// A finished run leaves the mode; a refused one keeps it so the
|
|
258
|
+
// remaining rows are still selectable for a second attempt.
|
|
259
|
+
if (result && result.refused.length === 0) batch.end();
|
|
260
|
+
});
|
|
261
|
+
}}
|
|
262
|
+
/>
|
|
263
|
+
|
|
177
264
|
{/* New Workspace Modal */}
|
|
178
265
|
<NewWorkspaceModal
|
|
179
266
|
isOpen={newWorkspaceOpen}
|
|
@@ -16,6 +16,12 @@ import { useSessionSidebarController } from '@/client/hooks/chat/omp/session-sid
|
|
|
16
16
|
import { useScheduledTaskCount } from '@/client/hooks/workspace/scheduled-tasks';
|
|
17
17
|
import { cloneWorkspaceFolder, createWorkspaceFolder } from '@/client/hooks/workspace/new-workspace';
|
|
18
18
|
import { MobileSessionListSkeleton } from '@/client/components/mobile/mobile-session-sidebar/Skeleton';
|
|
19
|
+
import { useBatchSelection } from '@/client/hooks/workspace/batch-selection';
|
|
20
|
+
import { useBatchDeleteRun } from '@/client/hooks/workspace/batch-delete-run';
|
|
21
|
+
import { BatchActionBar } from '@/client/components/common/batch-selection/BatchActionBar';
|
|
22
|
+
import { BatchDeleteModal } from '@/client/components/common/batch-selection/BatchDeleteModal';
|
|
23
|
+
import type { BatchBinding } from '@/client/components/common/batch-selection/types';
|
|
24
|
+
import { liveSelection, selectableSessionIds } from '@/shared/lib/workspace/batch-select';
|
|
19
25
|
|
|
20
26
|
interface MobileSessionSidebarProps {
|
|
21
27
|
activeSessionId: number | string | null;
|
|
@@ -139,6 +145,40 @@ export function MobileSessionSidebar({
|
|
|
139
145
|
setShowArchived(!showArchived);
|
|
140
146
|
};
|
|
141
147
|
|
|
148
|
+
// Batch selection — same two modes and the same rules as the desktop sidebar
|
|
149
|
+
// (the state machine and the confirmation are shared hooks/components).
|
|
150
|
+
const batch = useBatchSelection();
|
|
151
|
+
const batchDelete = useBatchDeleteRun(refreshNow);
|
|
152
|
+
|
|
153
|
+
const scopeFolder = batch.scopeFolderId === null
|
|
154
|
+
? null
|
|
155
|
+
: processedFolders.find((folder) => folder.id === batch.scopeFolderId) ?? null;
|
|
156
|
+
const batchAvailable = batch.mode === 'workspace'
|
|
157
|
+
? processedFolders.map((folder) => String(folder.id))
|
|
158
|
+
: batch.mode === 'session'
|
|
159
|
+
? selectableSessionIds(scopeFolder?.sessions ?? [], showArchived)
|
|
160
|
+
: [];
|
|
161
|
+
// Same rule as the desktop sidebar: the count and the payload are the LIVE
|
|
162
|
+
// selection, so a row removed elsewhere cannot be counted or sent.
|
|
163
|
+
const batchSelected = liveSelection(batch.selected, batchAvailable);
|
|
164
|
+
|
|
165
|
+
const batchBinding: BatchBinding | undefined = batch.mode
|
|
166
|
+
? {
|
|
167
|
+
mode: batch.mode,
|
|
168
|
+
scopeFolderId: batch.scopeFolderId,
|
|
169
|
+
selected: batch.selected,
|
|
170
|
+
toggle: batch.toggle,
|
|
171
|
+
selectAllOf: batch.selectAllOf,
|
|
172
|
+
unselectAllOf: batch.unselectAllOf,
|
|
173
|
+
summaryOf: batch.summaryOf,
|
|
174
|
+
}
|
|
175
|
+
: undefined;
|
|
176
|
+
|
|
177
|
+
const startWorkspaceSelection = () => {
|
|
178
|
+
if (batch.mode === 'workspace') batch.end();
|
|
179
|
+
else batch.begin('workspace');
|
|
180
|
+
};
|
|
181
|
+
|
|
142
182
|
return (
|
|
143
183
|
<div className="flex flex-col h-full w-full bg-canvas text-ink relative select-none">
|
|
144
184
|
|
|
@@ -163,6 +203,8 @@ export function MobileSessionSidebar({
|
|
|
163
203
|
onHideArchived={() => setShowArchived(false)}
|
|
164
204
|
onSearchChange={setSearchQuery}
|
|
165
205
|
onClearSearch={() => setSearchQuery('')}
|
|
206
|
+
onSelectWorkspaces={startWorkspaceSelection}
|
|
207
|
+
selecting={batch.mode !== null}
|
|
166
208
|
/>
|
|
167
209
|
|
|
168
210
|
{initializing ? (
|
|
@@ -178,6 +220,22 @@ export function MobileSessionSidebar({
|
|
|
178
220
|
onNewSessionForFolder={handleNewSessionForFolderAndClose}
|
|
179
221
|
onToggleFolder={toggleFolder}
|
|
180
222
|
onToast={pushToast}
|
|
223
|
+
batch={batchBinding}
|
|
224
|
+
onStartSessionSelection={(folderId) => batch.begin('session', folderId)}
|
|
225
|
+
/>
|
|
226
|
+
)}
|
|
227
|
+
|
|
228
|
+
{batch.mode && (
|
|
229
|
+
<BatchActionBar
|
|
230
|
+
mode={batch.mode}
|
|
231
|
+
count={batchSelected.length}
|
|
232
|
+
total={batchAvailable.length}
|
|
233
|
+
busy={batchDelete.isDeleting}
|
|
234
|
+
scopeLabel={scopeFolder?.name}
|
|
235
|
+
onSelectAll={() => batch.selectAllOf(batchAvailable)}
|
|
236
|
+
onUnselectAll={() => batch.unselectAllOf(batchAvailable)}
|
|
237
|
+
onDelete={() => batchDelete.request(batchSelected)}
|
|
238
|
+
onCancel={batch.end}
|
|
181
239
|
/>
|
|
182
240
|
)}
|
|
183
241
|
|
|
@@ -216,6 +274,26 @@ export function MobileSessionSidebar({
|
|
|
216
274
|
Its "Update now" hands the run to the About modal below. */}
|
|
217
275
|
<UpdatePopup updates={updates} />
|
|
218
276
|
|
|
277
|
+
{/* Batch delete confirmation — same dialog the desktop draws. */}
|
|
278
|
+
<BatchDeleteModal
|
|
279
|
+
mode={batch.mode ?? 'session'}
|
|
280
|
+
count={batchDelete.pending.length}
|
|
281
|
+
scopeLabel={scopeFolder?.name}
|
|
282
|
+
isDeleting={batchDelete.isDeleting}
|
|
283
|
+
outcome={batchDelete.outcome}
|
|
284
|
+
onClose={() => {
|
|
285
|
+
const refusedCount = batchDelete.outcome?.refused.length ?? 0;
|
|
286
|
+
batchDelete.cancel();
|
|
287
|
+
if (batchDelete.outcome && refusedCount === 0) batch.end();
|
|
288
|
+
}}
|
|
289
|
+
onConfirm={() => {
|
|
290
|
+
const mode = batch.mode ?? 'session';
|
|
291
|
+
void batchDelete.confirm(mode).then((result) => {
|
|
292
|
+
if (result && result.refused.length === 0) batch.end();
|
|
293
|
+
});
|
|
294
|
+
}}
|
|
295
|
+
/>
|
|
296
|
+
|
|
219
297
|
<ToastStack toasts={toasts} onDismiss={dismissToast} />
|
|
220
298
|
|
|
221
299
|
</div>
|
|
@@ -10,11 +10,13 @@ import { useWorkspaceFolderActions } from '@/client/hooks/workspace/workspace-fo
|
|
|
10
10
|
import { useSessionDelete } from '@/client/hooks/workspace/session-delete';
|
|
11
11
|
import { SessionDeleteModal } from '@/client/components/common/session-delete-modal';
|
|
12
12
|
import { getProjectIcon } from '@/shared/lib/workspace/project-icon';
|
|
13
|
-
import { selectVisibleSessions } from '@/shared/lib/workspace/session-visibility';
|
|
14
13
|
import { relativeTimeAgo } from '@/shared/lib/workspace/relative-time';
|
|
15
14
|
import { useOnClickOutside } from '@/client/hooks/ui/on-click-outside';
|
|
16
15
|
import { useSidebarData } from '@/client/hooks/chat/omp/session-list';
|
|
17
16
|
import type { WorkspaceFolderData } from '@/shared/types';
|
|
17
|
+
import type { BatchBinding } from '@/client/components/common/batch-selection/types';
|
|
18
|
+
import { folderBatchState } from '@/client/components/common/batch-selection/folder-state';
|
|
19
|
+
import { SelectionCheckbox } from '@/client/components/common/batch-selection/SelectionCheckbox';
|
|
18
20
|
|
|
19
21
|
interface MobileSessionCategoryProps {
|
|
20
22
|
folder: WorkspaceFolderData;
|
|
@@ -26,6 +28,10 @@ interface MobileSessionCategoryProps {
|
|
|
26
28
|
showArchived?: boolean;
|
|
27
29
|
sessionStatus?: Record<string, 'stream' | 'finish' | 'abort'>;
|
|
28
30
|
onToast?: (message: string, type?: 'success' | 'error') => void;
|
|
31
|
+
/** Present only while a batch selection is active; absent is the normal list. */
|
|
32
|
+
batch?: BatchBinding;
|
|
33
|
+
/** Start a session selection scoped to this workspace, from its `⋯` menu. */
|
|
34
|
+
onStartSessionSelection?: (folderId: number) => void;
|
|
29
35
|
}
|
|
30
36
|
|
|
31
37
|
export function MobileSessionCategory({
|
|
@@ -38,6 +44,8 @@ export function MobileSessionCategory({
|
|
|
38
44
|
showArchived = false,
|
|
39
45
|
sessionStatus = {},
|
|
40
46
|
onToast,
|
|
47
|
+
batch,
|
|
48
|
+
onStartSessionSelection,
|
|
41
49
|
}: MobileSessionCategoryProps) {
|
|
42
50
|
const { visibleCount, showMore, showLess, canShowLess } = useShowMore();
|
|
43
51
|
const { refresh } = useSidebarData();
|
|
@@ -91,23 +99,45 @@ export function MobileSessionCategory({
|
|
|
91
99
|
const filteredSessions = (folder.sessions || []).filter((s) =>
|
|
92
100
|
showArchived ? s.is_archived === 1 : s.is_archived !== 1
|
|
93
101
|
);
|
|
94
|
-
// Same window as the desktop sidebar: the opened session and every live run
|
|
95
|
-
// stay on screen past the boundary, so nothing being watched is paginated
|
|
96
|
-
// away on the phone either.
|
|
97
|
-
const { rendered: visibleSessions, hiddenCount } = selectVisibleSessions(
|
|
98
|
-
filteredSessions,
|
|
99
|
-
visibleCount,
|
|
100
|
-
{ activeSessionId, sessionStatus },
|
|
101
|
-
);
|
|
102
102
|
const ProjectIcon = getProjectIcon(folder.icon);
|
|
103
103
|
|
|
104
|
+
// Batch rules are shared with the desktop category (`folderBatchState`), so
|
|
105
|
+
// the phone cannot offer a different scope, summary or pagination rule.
|
|
106
|
+
const {
|
|
107
|
+
folderCheckbox,
|
|
108
|
+
sessionCheckboxes,
|
|
109
|
+
folderSummary,
|
|
110
|
+
toggleFolderSelection,
|
|
111
|
+
rendered: visibleSessions,
|
|
112
|
+
hiddenCount,
|
|
113
|
+
} = folderBatchState({
|
|
114
|
+
batch,
|
|
115
|
+
folder,
|
|
116
|
+
sessions: filteredSessions,
|
|
117
|
+
showArchived,
|
|
118
|
+
visibleCount,
|
|
119
|
+
activeSessionId,
|
|
120
|
+
sessionStatus,
|
|
121
|
+
});
|
|
122
|
+
const listOpen = isExpanded || sessionCheckboxes;
|
|
123
|
+
|
|
104
124
|
return (
|
|
105
125
|
<div className="mb-4">
|
|
106
126
|
<div
|
|
107
|
-
onClick={onToggleExpand}
|
|
127
|
+
onClick={batch ? undefined : onToggleExpand}
|
|
108
128
|
className="flex items-center justify-between pl-4 pr-3 py-2 cursor-pointer hover:bg-ink/5 rounded-lg select-none transition-colors"
|
|
109
129
|
>
|
|
110
130
|
<div className="flex items-center space-x-2 min-w-0">
|
|
131
|
+
{folderCheckbox && folderSummary ? (
|
|
132
|
+
<SelectionCheckbox
|
|
133
|
+
size="md"
|
|
134
|
+
checked={folderSummary.all}
|
|
135
|
+
indeterminate={folderSummary.some}
|
|
136
|
+
label={`Select ${folder.name}`}
|
|
137
|
+
onChange={toggleFolderSelection}
|
|
138
|
+
/>
|
|
139
|
+
) : (
|
|
140
|
+
<>
|
|
111
141
|
{/* The folder glyph occupies the same 16px left slot the session rows
|
|
112
142
|
give their roster toggle, so the two columns line up down the
|
|
113
143
|
drawer — the icon, the chevron and every row's status glyph share
|
|
@@ -131,6 +161,8 @@ export function MobileSessionCategory({
|
|
|
131
161
|
/>
|
|
132
162
|
)}
|
|
133
163
|
</span>
|
|
164
|
+
</>
|
|
165
|
+
)}
|
|
134
166
|
|
|
135
167
|
<span className="text-sm font-semibold text-ink truncate">
|
|
136
168
|
{folder.name}
|
|
@@ -146,6 +178,8 @@ export function MobileSessionCategory({
|
|
|
146
178
|
<GitBranch size={13} className="text-ink/50 ml-1" />
|
|
147
179
|
)}
|
|
148
180
|
|
|
181
|
+
{!batch && (
|
|
182
|
+
<>
|
|
149
183
|
<button
|
|
150
184
|
type="button"
|
|
151
185
|
onClick={(event) => {
|
|
@@ -178,6 +212,11 @@ export function MobileSessionCategory({
|
|
|
178
212
|
variant="mobile"
|
|
179
213
|
isPinned={folder.isPinned}
|
|
180
214
|
onOpenFolder={onOpenFolder}
|
|
215
|
+
onSelectSessions={
|
|
216
|
+
onStartSessionSelection
|
|
217
|
+
? () => { setShowMenu(false); onStartSessionSelection(folder.id); }
|
|
218
|
+
: undefined
|
|
219
|
+
}
|
|
181
220
|
confirmDelete={confirmDelete}
|
|
182
221
|
onPin={onPin}
|
|
183
222
|
onDelete={onDelete}
|
|
@@ -189,10 +228,12 @@ export function MobileSessionCategory({
|
|
|
189
228
|
</div>
|
|
190
229
|
|
|
191
230
|
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
|
231
|
+
</>
|
|
232
|
+
)}
|
|
192
233
|
</div>
|
|
193
234
|
</div>
|
|
194
235
|
|
|
195
|
-
{
|
|
236
|
+
{listOpen && (
|
|
196
237
|
<div className="mt-0.5 space-y-0.5 pl-2">
|
|
197
238
|
{visibleSessions.map((session) => {
|
|
198
239
|
const sessionKey = String(session.id);
|
|
@@ -231,6 +272,9 @@ export function MobileSessionCategory({
|
|
|
231
272
|
onRenameWithAi={
|
|
232
273
|
sessionKey.startsWith('new-') ? undefined : () => void handleRenameWithAi(session)
|
|
233
274
|
}
|
|
275
|
+
selectable={sessionCheckboxes}
|
|
276
|
+
selected={Boolean(batch?.selected.includes(sessionKey))}
|
|
277
|
+
onToggleSelect={batch ? () => batch.toggle(sessionKey) : undefined}
|
|
234
278
|
/>
|
|
235
279
|
{isRosterOpen && (
|
|
236
280
|
<SubagentList sessionId={session.id} isActiveSession={isSessionActive} />
|
|
@@ -242,7 +286,7 @@ export function MobileSessionCategory({
|
|
|
242
286
|
{/* Reveal / collapse controls, one row — parity with the desktop
|
|
243
287
|
category. Same window, so the phone cannot offer a different
|
|
244
288
|
boundary than the sidebar. */}
|
|
245
|
-
{(hiddenCount > 0 || canShowLess) && (
|
|
289
|
+
{(hiddenCount > 0 || canShowLess) && !sessionCheckboxes && (
|
|
246
290
|
<div className="flex items-center gap-1">
|
|
247
291
|
{hiddenCount > 0 && (
|
|
248
292
|
<button
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { MobileSessionCategory } from '@/client/components/mobile/mobile-session-sidebar/Item';
|
|
2
|
+
import type { BatchBinding } from '@/client/components/common/batch-selection/types';
|
|
2
3
|
import type { WorkspaceFolderData } from '@/shared/types';
|
|
3
4
|
|
|
4
5
|
interface MobileSessionListProps {
|
|
@@ -12,6 +13,9 @@ interface MobileSessionListProps {
|
|
|
12
13
|
onToggleFolder: (folderId: number) => void;
|
|
13
14
|
/** Surfaces a refused folder action (the reveal endpoint's own reason). */
|
|
14
15
|
onToast?: (message: string, type?: 'success' | 'error') => void;
|
|
16
|
+
/** Present only while a batch selection is active. */
|
|
17
|
+
batch?: BatchBinding;
|
|
18
|
+
onStartSessionSelection?: (folderId: number) => void;
|
|
15
19
|
}
|
|
16
20
|
|
|
17
21
|
export function MobileSessionList({
|
|
@@ -24,6 +28,8 @@ export function MobileSessionList({
|
|
|
24
28
|
onNewSessionForFolder,
|
|
25
29
|
onToggleFolder,
|
|
26
30
|
onToast,
|
|
31
|
+
batch,
|
|
32
|
+
onStartSessionSelection,
|
|
27
33
|
}: MobileSessionListProps) {
|
|
28
34
|
// No fade wiring here: the app-wide scrollbar rule reveals this list's bar
|
|
29
35
|
// from the document's own capture-phase listener (`lib/scrollbar-activity`),
|
|
@@ -47,6 +53,8 @@ export function MobileSessionList({
|
|
|
47
53
|
showArchived={showArchived}
|
|
48
54
|
sessionStatus={sessionStatus}
|
|
49
55
|
onToast={onToast}
|
|
56
|
+
batch={batch}
|
|
57
|
+
onStartSessionSelection={onStartSessionSelection}
|
|
50
58
|
/>
|
|
51
59
|
))
|
|
52
60
|
)}
|