@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.
Files changed (61) hide show
  1. package/README.md +16 -11
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/index.js +323 -323
  4. package/dist/client/static/js/{index-4vyzz0z4.js → index-enkbbffs.js} +643 -643
  5. package/dist/client/static/js/index-yn17t04b.css +1 -0
  6. package/package.json +2 -1
  7. package/public/kabmo-wordmark.svg +31 -0
  8. package/src/cli/lib/commands/update.js +17 -2
  9. package/src/cli/lib/commands/update.test.js +36 -0
  10. package/src/client/components/auth/login-screen/index.test.tsx +93 -0
  11. package/src/client/components/auth/login-screen/index.tsx +43 -22
  12. package/src/client/components/auth/login-screen/password-toggle.test.tsx +121 -0
  13. package/src/client/components/common/batch-selection/BatchActionBar.test.ts +132 -0
  14. package/src/client/components/common/batch-selection/BatchActionBar.tsx +118 -0
  15. package/src/client/components/common/batch-selection/BatchDeleteModal.tsx +177 -0
  16. package/src/client/components/common/batch-selection/SelectionCheckbox.tsx +58 -0
  17. package/src/client/components/common/batch-selection/folder-state.test.ts +130 -0
  18. package/src/client/components/common/batch-selection/folder-state.ts +101 -0
  19. package/src/client/components/common/batch-selection/types.ts +42 -0
  20. package/src/client/components/common/brand-wordmark/Mark.tsx +62 -0
  21. package/src/client/components/common/brand-wordmark/index.test.tsx +29 -7
  22. package/src/client/components/common/brand-wordmark/index.tsx +33 -11
  23. package/src/client/components/common/brand-wordmark/wordmark-svg.test.ts +88 -0
  24. package/src/client/components/common/workspace-options-menu/index.tsx +25 -11
  25. package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +169 -0
  26. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +72 -91
  27. package/src/client/components/layout/session-sidebar/SessionItem.batch.test.ts +162 -0
  28. package/src/client/components/layout/session-sidebar/SessionItem.tsx +35 -6
  29. package/src/client/components/layout/session-sidebar/SessionList.tsx +10 -1
  30. package/src/client/components/layout/session-sidebar/Toolbar.test.ts +2 -0
  31. package/src/client/components/layout/session-sidebar/Toolbar.tsx +28 -1
  32. package/src/client/components/layout/session-sidebar/index.tsx +87 -0
  33. package/src/client/components/mobile/SessionSidebar.tsx +78 -0
  34. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +56 -12
  35. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +8 -0
  36. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +27 -6
  37. package/src/client/components/mobile/mobile-session-sidebar/Toolbar.test.ts +2 -0
  38. package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +26 -1
  39. package/src/client/hooks/workspace/batch-delete-run.ts +95 -0
  40. package/src/client/hooks/workspace/batch-delete.ts +63 -0
  41. package/src/client/hooks/workspace/batch-selection.ts +98 -0
  42. package/src/client/tailwind.css +6 -0
  43. package/src/server/lib/lifecycle/restart.ts +52 -14
  44. package/src/server/lib/omp/session/delete-flow.server.ts +101 -0
  45. package/src/server/lib/updates/apply.ts +8 -0
  46. package/src/server/lib/updates/install-steps.ts +81 -0
  47. package/src/server/lib/updates/install.ts +19 -67
  48. package/src/server/lib/updates/installed-cli.test.ts +222 -0
  49. package/src/server/lib/updates/installed-cli.ts +139 -0
  50. package/src/server/lib/updates/omp.ts +1 -1
  51. package/src/server/routes/folders/batch-delete.ts +68 -0
  52. package/src/server/routes/folders/index.ts +4 -0
  53. package/src/server/routes/sessions/batch-delete.test.ts +139 -0
  54. package/src/server/routes/sessions/batch-delete.ts +60 -0
  55. package/src/server/routes/sessions/delete.ts +13 -51
  56. package/src/server/routes/sessions/index.ts +5 -0
  57. package/src/server/routes/updates/apply.ts +1 -1
  58. package/src/shared/lib/workspace/batch-select.test.ts +92 -0
  59. package/src/shared/lib/workspace/batch-select.ts +86 -0
  60. package/src/shared/lib/workspace/sidebar-toolbar.ts +6 -0
  61. 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
- onClick={isEditing ? undefined : onClick}
86
- onContextMenu={hasActions && !isEditing ? menu.openAtCursor : undefined}
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
- isActive
89
- ? 'bg-ink/10 font-medium text-ink'
90
- : 'text-ink/75 hover:text-ink hover:bg-ink/5'
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: any[];
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
  )}
@@ -75,6 +75,8 @@ async function mount() {
75
75
  scheduledCount: 0,
76
76
  onRefresh: () => {},
77
77
  refreshing: false,
78
+ onSelectWorkspaces: () => {},
79
+ selecting: false,
78
80
  }), container);
79
81
  });
80
82
  return { el: container, calls };
@@ -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
- {isExpanded && (
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
  )}