@kabmo-labs/kabmo 0.0.1 → 0.2.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 (127) hide show
  1. package/README.md +16 -11
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/index.js +333 -333
  4. package/dist/client/static/js/index-pdp5dwc8.css +1 -0
  5. package/dist/client/static/js/{index-4vyzz0z4.js → index-zsskckw7.js} +681 -681
  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/bootstrap.d.ts +8 -0
  11. package/src/client/components/auth/login-screen/index.test.tsx +93 -0
  12. package/src/client/components/auth/login-screen/index.tsx +43 -22
  13. package/src/client/components/auth/login-screen/password-toggle.test.tsx +121 -0
  14. package/src/client/components/common/batch-selection/BatchActionBar.test.ts +132 -0
  15. package/src/client/components/common/batch-selection/BatchActionBar.tsx +118 -0
  16. package/src/client/components/common/batch-selection/BatchDeleteModal.tsx +177 -0
  17. package/src/client/components/common/batch-selection/SelectionCheckbox.tsx +58 -0
  18. package/src/client/components/common/batch-selection/folder-state.test.ts +130 -0
  19. package/src/client/components/common/batch-selection/folder-state.ts +101 -0
  20. package/src/client/components/common/batch-selection/types.ts +42 -0
  21. package/src/client/components/common/brand-wordmark/Mark.tsx +62 -0
  22. package/src/client/components/common/brand-wordmark/index.test.tsx +29 -7
  23. package/src/client/components/common/brand-wordmark/index.tsx +33 -11
  24. package/src/client/components/common/brand-wordmark/wordmark-svg.test.ts +88 -0
  25. package/src/client/components/common/header-controls/MoreMenu.tsx +136 -0
  26. package/src/client/components/common/header-controls/index.test.tsx +77 -11
  27. package/src/client/components/common/header-controls/index.tsx +53 -4
  28. package/src/client/components/common/session-state-provider/index.test.ts +22 -1
  29. package/src/client/components/common/workspace-options-menu/index.tsx +25 -11
  30. package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +169 -0
  31. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +72 -91
  32. package/src/client/components/layout/session-sidebar/Footer.tsx +20 -2
  33. package/src/client/components/layout/session-sidebar/OverflowMenu.tsx +23 -5
  34. package/src/client/components/layout/session-sidebar/SessionItem.batch.test.ts +162 -0
  35. package/src/client/components/layout/session-sidebar/SessionItem.tsx +35 -6
  36. package/src/client/components/layout/session-sidebar/SessionList.tsx +10 -1
  37. package/src/client/components/layout/session-sidebar/Toolbar.test.ts +2 -0
  38. package/src/client/components/layout/session-sidebar/Toolbar.tsx +30 -34
  39. package/src/client/components/layout/session-sidebar/index.tsx +92 -0
  40. package/src/client/components/mobile/LayoutWrapper.tsx +32 -1
  41. package/src/client/components/mobile/SessionSidebar.tsx +83 -0
  42. package/src/client/components/mobile/mobile-main-view/Header.tsx +27 -5
  43. package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +21 -1
  44. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +56 -12
  45. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +8 -0
  46. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +27 -6
  47. package/src/client/components/mobile/mobile-session-sidebar/Toolbar.test.ts +106 -0
  48. package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +178 -93
  49. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +9 -0
  50. package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +6 -0
  51. package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +4 -1
  52. package/src/client/components/workspace/chat-timeline/chat-input/DropOverlay.tsx +1 -1
  53. package/src/client/components/workspace/chat-timeline/chat-input/GoalBanner.tsx +6 -1
  54. package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +4 -1
  55. package/src/client/components/workspace/chat-timeline/chat-input/VoiceNotice.tsx +2 -2
  56. package/src/client/components/workspace/chat-timeline/chat-input/expansion.test.ts +72 -1
  57. package/src/client/components/workspace/chat-timeline/chat-input/expansion.ts +28 -4
  58. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +20 -2
  59. package/src/client/components/workspace/chat-timeline/chat-input/props.ts +7 -0
  60. package/src/client/components/workspace/chat-timeline/index.tsx +9 -3
  61. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks.test.tsx +127 -0
  62. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks.tsx +71 -0
  63. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/PluginPendingInteractions.test.ts +1 -0
  64. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -2
  65. package/src/client/components/workspace/git-panel/BranchToolbar.tsx +4 -0
  66. package/src/client/components/workspace/git-panel/index.test.ts +15 -0
  67. package/src/client/components/workspace/search-panel/Body.test.ts +149 -0
  68. package/src/client/components/workspace/search-panel/Body.tsx +53 -9
  69. package/src/client/hooks/chat/timeline/ask-frames-grouped.test.ts +46 -2
  70. package/src/client/hooks/chat/timeline/ask-frames.test.ts +1 -1
  71. package/src/client/hooks/chat/timeline/ask-frames.ts +79 -29
  72. package/src/client/hooks/chat/timeline/index.ts +2 -1
  73. package/src/client/hooks/chat/timeline/scroll-probe.test-util.ts +227 -0
  74. package/src/client/hooks/chat/timeline/scroll-reading-back.test.ts +67 -0
  75. package/src/client/hooks/chat/timeline/scroll.test.ts +84 -226
  76. package/src/client/hooks/chat/timeline/scroll.ts +34 -1
  77. package/src/client/hooks/ui/media-query.ts +41 -0
  78. package/src/client/hooks/ui/updates/android-download.test.ts +73 -0
  79. package/src/client/hooks/ui/updates/android-download.ts +106 -0
  80. package/src/client/hooks/workspace/batch-delete-run.ts +95 -0
  81. package/src/client/hooks/workspace/batch-delete.ts +63 -0
  82. package/src/client/hooks/workspace/batch-selection.ts +98 -0
  83. package/src/client/hooks/workspace/file-tabs.test.ts +165 -0
  84. package/src/client/hooks/workspace/file-tabs.ts +24 -11
  85. package/src/client/hooks/workspace/session-state/index.ts +24 -1
  86. package/src/client/hooks/workspace/toolbar-row-width.ts +43 -0
  87. package/src/client/lib/native/back.test.ts +108 -0
  88. package/src/client/lib/native/back.ts +60 -0
  89. package/src/client/main.tsx +6 -0
  90. package/src/client/tailwind.css +6 -0
  91. package/src/server/lib/fs/git-actions-branch.ts +18 -1
  92. package/src/server/lib/fs/git-actions-sync.test.ts +144 -0
  93. package/src/server/lib/fs/git-publish.test.ts +96 -0
  94. package/src/server/lib/fs/git-publish.ts +35 -3
  95. package/src/server/lib/fs/listing.test.ts +86 -0
  96. package/src/server/lib/fs/listing.ts +20 -6
  97. package/src/server/lib/lifecycle/restart.ts +52 -14
  98. package/src/server/lib/omp/session/delete-flow.server.ts +101 -0
  99. package/src/server/lib/updates/android-release.test.ts +92 -0
  100. package/src/server/lib/updates/android-release.ts +148 -0
  101. package/src/server/lib/updates/apply.ts +8 -0
  102. package/src/server/lib/updates/install-steps.ts +81 -0
  103. package/src/server/lib/updates/install.ts +19 -67
  104. package/src/server/lib/updates/installed-cli.test.ts +222 -0
  105. package/src/server/lib/updates/installed-cli.ts +139 -0
  106. package/src/server/lib/updates/omp.ts +1 -1
  107. package/src/server/plugins/ssr.ts +9 -2
  108. package/src/server/routes/folders/batch-delete.ts +68 -0
  109. package/src/server/routes/folders/index.ts +4 -0
  110. package/src/server/routes/fs/read.ts +24 -9
  111. package/src/server/routes/sessions/batch-delete.test.ts +142 -0
  112. package/src/server/routes/sessions/batch-delete.ts +60 -0
  113. package/src/server/routes/sessions/delete.ts +13 -51
  114. package/src/server/routes/sessions/index.ts +5 -0
  115. package/src/server/routes/updates/android.ts +22 -0
  116. package/src/server/routes/updates/apply.ts +1 -1
  117. package/src/server/routes/updates/index.ts +2 -0
  118. package/src/shared/lib/updates/shell-ua.test.ts +54 -0
  119. package/src/shared/lib/updates/shell-ua.ts +36 -0
  120. package/src/shared/lib/workspace/batch-select.test.ts +92 -0
  121. package/src/shared/lib/workspace/batch-select.ts +86 -0
  122. package/src/shared/lib/workspace/session-state/listeners.ts +38 -0
  123. package/src/shared/lib/workspace/session-state/replacement.test.ts +121 -0
  124. package/src/shared/lib/workspace/session-state/store.ts +13 -2
  125. package/src/shared/lib/workspace/sidebar-toolbar.test.ts +98 -0
  126. package/src/shared/lib/workspace/sidebar-toolbar.ts +84 -0
  127. package/dist/client/static/js/index-x9cehh1a.css +0 -1
@@ -5,6 +5,7 @@ import { MobileSessionList } from '@/client/components/mobile/mobile-session-sid
5
5
  import { MobileSessionFooter } from '@/client/components/mobile/mobile-session-sidebar/Footer';
6
6
  import { ToastStack } from '@/client/components/common/ToastStack';
7
7
  import { AboutModal, NewWorkspaceModal } from '@/client/components/layout/session-sidebar/Modals';
8
+ import { useAndroidDownload } from '@/client/hooks/ui/updates/android-download';
8
9
  import { SchedulerModal } from '@/client/components/layout/session-sidebar/scheduler-modal';
9
10
  import { UpdatePopup } from '@/client/components/layout/update-popup';
10
11
  import { useOnClickOutside } from '@/client/hooks/ui/on-click-outside';
@@ -16,6 +17,12 @@ import { useSessionSidebarController } from '@/client/hooks/chat/omp/session-sid
16
17
  import { useScheduledTaskCount } from '@/client/hooks/workspace/scheduled-tasks';
17
18
  import { cloneWorkspaceFolder, createWorkspaceFolder } from '@/client/hooks/workspace/new-workspace';
18
19
  import { MobileSessionListSkeleton } from '@/client/components/mobile/mobile-session-sidebar/Skeleton';
20
+ import { useBatchSelection } from '@/client/hooks/workspace/batch-selection';
21
+ import { useBatchDeleteRun } from '@/client/hooks/workspace/batch-delete-run';
22
+ import { BatchActionBar } from '@/client/components/common/batch-selection/BatchActionBar';
23
+ import { BatchDeleteModal } from '@/client/components/common/batch-selection/BatchDeleteModal';
24
+ import type { BatchBinding } from '@/client/components/common/batch-selection/types';
25
+ import { liveSelection, selectableSessionIds } from '@/shared/lib/workspace/batch-select';
19
26
 
20
27
  interface MobileSessionSidebarProps {
21
28
  activeSessionId: number | string | null;
@@ -66,6 +73,9 @@ export function MobileSessionSidebar({
66
73
  const scheduledCount = useScheduledTaskCount();
67
74
  const [aboutOpen, setAboutOpen] = useState(false);
68
75
  const updates = useUpdates();
76
+ // Null in a browser, and null when this APK is already current — see
77
+ // `useAndroidDownload`.
78
+ const androidDownload = useAndroidDownload(window.__OMP_BOOTSTRAP__?.shellVersion);
69
79
  const { toasts, pushToast, dismissToast } = useToasts();
70
80
 
71
81
  // The popup's "Update now" hands the run over rather than starting its own —
@@ -139,6 +149,40 @@ export function MobileSessionSidebar({
139
149
  setShowArchived(!showArchived);
140
150
  };
141
151
 
152
+ // Batch selection — same two modes and the same rules as the desktop sidebar
153
+ // (the state machine and the confirmation are shared hooks/components).
154
+ const batch = useBatchSelection();
155
+ const batchDelete = useBatchDeleteRun(refreshNow);
156
+
157
+ const scopeFolder = batch.scopeFolderId === null
158
+ ? null
159
+ : processedFolders.find((folder) => folder.id === batch.scopeFolderId) ?? null;
160
+ const batchAvailable = batch.mode === 'workspace'
161
+ ? processedFolders.map((folder) => String(folder.id))
162
+ : batch.mode === 'session'
163
+ ? selectableSessionIds(scopeFolder?.sessions ?? [], showArchived)
164
+ : [];
165
+ // Same rule as the desktop sidebar: the count and the payload are the LIVE
166
+ // selection, so a row removed elsewhere cannot be counted or sent.
167
+ const batchSelected = liveSelection(batch.selected, batchAvailable);
168
+
169
+ const batchBinding: BatchBinding | undefined = batch.mode
170
+ ? {
171
+ mode: batch.mode,
172
+ scopeFolderId: batch.scopeFolderId,
173
+ selected: batch.selected,
174
+ toggle: batch.toggle,
175
+ selectAllOf: batch.selectAllOf,
176
+ unselectAllOf: batch.unselectAllOf,
177
+ summaryOf: batch.summaryOf,
178
+ }
179
+ : undefined;
180
+
181
+ const startWorkspaceSelection = () => {
182
+ if (batch.mode === 'workspace') batch.end();
183
+ else batch.begin('workspace');
184
+ };
185
+
142
186
  return (
143
187
  <div className="flex flex-col h-full w-full bg-canvas text-ink relative select-none">
144
188
 
@@ -163,6 +207,8 @@ export function MobileSessionSidebar({
163
207
  onHideArchived={() => setShowArchived(false)}
164
208
  onSearchChange={setSearchQuery}
165
209
  onClearSearch={() => setSearchQuery('')}
210
+ onSelectWorkspaces={startWorkspaceSelection}
211
+ selecting={batch.mode !== null}
166
212
  />
167
213
 
168
214
  {initializing ? (
@@ -178,12 +224,29 @@ export function MobileSessionSidebar({
178
224
  onNewSessionForFolder={handleNewSessionForFolderAndClose}
179
225
  onToggleFolder={toggleFolder}
180
226
  onToast={pushToast}
227
+ batch={batchBinding}
228
+ onStartSessionSelection={(folderId) => batch.begin('session', folderId)}
229
+ />
230
+ )}
231
+
232
+ {batch.mode && (
233
+ <BatchActionBar
234
+ mode={batch.mode}
235
+ count={batchSelected.length}
236
+ total={batchAvailable.length}
237
+ busy={batchDelete.isDeleting}
238
+ scopeLabel={scopeFolder?.name}
239
+ onSelectAll={() => batch.selectAllOf(batchAvailable)}
240
+ onUnselectAll={() => batch.unselectAllOf(batchAvailable)}
241
+ onDelete={() => batchDelete.request(batchSelected)}
242
+ onCancel={batch.end}
181
243
  />
182
244
  )}
183
245
 
184
246
  <MobileSessionFooter
185
247
  onSettings={() => window.dispatchEvent(new CustomEvent('omp:open-settings', { detail: {} }))}
186
248
  onAbout={() => setAboutOpen(true)}
249
+ androidDownload={androidDownload}
187
250
  onDesktopToggle={onDesktopToggle}
188
251
  updateAvailable={updates.hasUpdate}
189
252
  onUpdateClick={() => {
@@ -216,6 +279,26 @@ export function MobileSessionSidebar({
216
279
  Its "Update now" hands the run to the About modal below. */}
217
280
  <UpdatePopup updates={updates} />
218
281
 
282
+ {/* Batch delete confirmation — same dialog the desktop draws. */}
283
+ <BatchDeleteModal
284
+ mode={batch.mode ?? 'session'}
285
+ count={batchDelete.pending.length}
286
+ scopeLabel={scopeFolder?.name}
287
+ isDeleting={batchDelete.isDeleting}
288
+ outcome={batchDelete.outcome}
289
+ onClose={() => {
290
+ const refusedCount = batchDelete.outcome?.refused.length ?? 0;
291
+ batchDelete.cancel();
292
+ if (batchDelete.outcome && refusedCount === 0) batch.end();
293
+ }}
294
+ onConfirm={() => {
295
+ const mode = batch.mode ?? 'session';
296
+ void batchDelete.confirm(mode).then((result) => {
297
+ if (result && result.refused.length === 0) batch.end();
298
+ });
299
+ }}
300
+ />
301
+
219
302
  <ToastStack toasts={toasts} onDismiss={dismissToast} />
220
303
 
221
304
  </div>
@@ -88,7 +88,7 @@ export function MobileHeader({
88
88
 
89
89
  return (
90
90
  <header
91
- className="flex-shrink-0 bg-canvas border-b border-ink/10 flex items-center justify-between px-3 z-20 titlebar-drag-region select-none"
91
+ className="flex-shrink-0 bg-canvas border-b border-ink/10 flex items-center justify-between gap-2 px-3 z-20 titlebar-drag-region select-none"
92
92
  style={{
93
93
  height: 'calc(3.5rem + env(safe-area-inset-top, 0px))',
94
94
  paddingLeft: 'max(0.75rem, env(safe-area-inset-left, 0px), env(titlebar-area-x, 0px))',
@@ -96,8 +96,22 @@ export function MobileHeader({
96
96
  paddingTop: 'env(safe-area-inset-top, 0px)',
97
97
  }}
98
98
  >
99
- {/* Left: hamburger, app title & session dropdown */}
100
- <div className="flex items-center space-x-2 min-w-0 titlebar-no-drag">
99
+ {/* Left: hamburger, app title & session dropdown.
100
+ `flex-1 min-w-0` is what makes the title the ONLY part that gives up
101
+ width: the hamburger, wordmark and slash are `flex-shrink-0`, so the
102
+ row shrinks to the control cluster's width and the picker truncates
103
+ inside it. Without `flex-1` the row kept its max-content width and
104
+ the picker's 150px button spilled over the reload control.
105
+
106
+ The wordmark and its slash are DROPPED below 420px. They are fixed
107
+ chrome (53px + 4px + 16px of gaps = 73px, 19% of a 390px header) that
108
+ competes with the one functional thing in the row: the session title.
109
+ With a password set the cluster is 140px wide (reload, context, lock,
110
+ panel), which leaves the title `vw − 285px` — measured 89px at 390,
111
+ 59px at 360 and 19px at 320. The brand is not lost: the drawer header
112
+ draws it, as does the desktop navbar. Same decision, same reason, as
113
+ the desktop navbar's `hidden sm:flex` on its own wordmark. */}
114
+ <div className="flex items-center space-x-2 flex-1 min-w-0 titlebar-no-drag">
101
115
  <button
102
116
  type="button"
103
117
  onClick={onOpenSessionSidebar}
@@ -108,6 +122,10 @@ export function MobileHeader({
108
122
  <Menu size={20} strokeWidth={2} />
109
123
  </button>
110
124
 
125
+ {/* The wordmark is drawn at EVERY width. The width it needs was bought
126
+ back from the control cluster instead: reload and sign out moved
127
+ into the `⋯` menu (`HeaderControls`), which is what pays for the
128
+ wordmark plus the slash beside it. */}
111
129
  <span className="relative font-bold text-sm tracking-tight flex items-center flex-shrink-0">
112
130
  <BrandWordmark />
113
131
  <span className="absolute -top-0.5 -right-1.5 flex">
@@ -117,11 +135,15 @@ export function MobileHeader({
117
135
 
118
136
  <span className="text-ink/25 font-light text-xs flex-shrink-0 select-none">/</span>
119
137
 
120
- <div className="relative min-w-0" ref={sessionPickerRef}>
138
+ <div
139
+ className="relative min-w-0 flex-1 max-w-[150px]"
140
+ ref={sessionPickerRef}
141
+ data-testid="session-picker"
142
+ >
121
143
  <button
122
144
  type="button"
123
145
  onClick={() => setShowSessionPicker(!showSessionPicker)}
124
- className="flex items-center space-x-1 text-xs font-semibold text-ink hover:text-ink/80 transition-colors py-1 max-w-[150px] min-w-0"
146
+ className="flex w-full items-center space-x-1 text-xs font-semibold text-ink hover:text-ink/80 transition-colors py-1 min-w-0"
125
147
  title={displayTitle}
126
148
  >
127
149
  <span className="truncate">{displayTitle}</span>
@@ -1,4 +1,4 @@
1
- import { Info, Monitor, Settings } from 'lucide-preact';
1
+ import { Download, Info, Monitor, Settings } from 'lucide-preact';
2
2
  import packageJson from '@/../package.json';
3
3
 
4
4
  interface MobileSessionFooterProps {
@@ -6,6 +6,8 @@ interface MobileSessionFooterProps {
6
6
  onAbout: () => void;
7
7
  updateAvailable?: boolean;
8
8
  onUpdateClick?: () => void;
9
+ /** The newer APK to offer, or null. Only ever set inside the Android app. */
10
+ androidDownload?: { url: string; latest: string; releaseUrl: string | null } | null;
9
11
  /** Switch the app to the desktop layout for this page session only. The
10
12
  * choice is not persisted, so the next load re-detects the device. */
11
13
  onDesktopToggle?: () => void;
@@ -17,6 +19,7 @@ export function MobileSessionFooter({
17
19
  updateAvailable,
18
20
  onUpdateClick,
19
21
  onDesktopToggle,
22
+ androidDownload,
20
23
  }: MobileSessionFooterProps) {
21
24
  return (
22
25
  <div
@@ -40,6 +43,23 @@ export function MobileSessionFooter({
40
43
  <Info size={14} />
41
44
  <span>About</span>
42
45
  </button>
46
+ {androidDownload && (
47
+ // Directly after About, matching the desktop footer. An `<a>`, not a
48
+ // button: the download is the browser's (or the WebView's) job, and
49
+ // `target="_blank"` hands it to the system browser so the console is
50
+ // not replaced by a file download.
51
+ <a
52
+ href={androidDownload.url}
53
+ target="_blank"
54
+ rel="noopener noreferrer"
55
+ title={`Download Kabmo ${androidDownload.latest} for Android`}
56
+ aria-label={`Download Kabmo ${androidDownload.latest} for Android`}
57
+ className="flex items-center space-x-1 hover:text-ink transition-colors"
58
+ >
59
+ <Download size={14} />
60
+ <span>APK</span>
61
+ </a>
62
+ )}
43
63
  {onDesktopToggle && (
44
64
  <button
45
65
  type="button"
@@ -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
  )}
@@ -3,6 +3,7 @@ import type { SessionItemData } from '@/shared/types';
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 MobileSessionRowProps {
8
9
  session: SessionItemData;
@@ -25,6 +26,11 @@ export interface MobileSessionRowProps {
25
26
  /** Ask omp to name this session from its transcript. Omitted for a pending
26
27
  * `new-…` chat, which has no transcript to derive a title from. */
27
28
  onRenameWithAi?: () => void;
29
+ /** Draw a checkbox and let the row's tap toggle the selection instead of
30
+ * opening the session (batch mode). */
31
+ selectable?: boolean;
32
+ selected?: boolean;
33
+ onToggleSelect?: () => void;
28
34
  }
29
35
 
30
36
  export function MobileSessionRow({
@@ -41,6 +47,9 @@ export function MobileSessionRow({
41
47
  onDelete,
42
48
  onRename,
43
49
  onRenameWithAi,
50
+ selectable = false,
51
+ selected = false,
52
+ onToggleSelect,
44
53
  }: MobileSessionRowProps) {
45
54
  const {
46
55
  isEditing,
@@ -91,14 +100,16 @@ export function MobileSessionRow({
91
100
  <div
92
101
  data-testid="session-item"
93
102
  data-session-id={String(session.id)}
94
- onContextMenu={hasActions ? menu.openAtCursor : undefined}
103
+ onContextMenu={hasActions && !selectable ? menu.openAtCursor : undefined}
95
104
  className={`w-full rounded-lg flex items-center transition-colors ${
96
- isActive ? 'bg-ink/10 font-medium text-ink' : 'hover:bg-ink/5 text-ink/85'
105
+ selectable && selected
106
+ ? 'bg-ink/10 ring-1 ring-inset ring-ink/20 text-ink'
107
+ : isActive ? 'bg-ink/10 font-medium text-ink' : 'hover:bg-ink/5 text-ink/85'
97
108
  }`}
98
109
  >
99
110
  <button
100
111
  type="button"
101
- onClick={onSelect}
112
+ onClick={selectable ? onToggleSelect : onSelect}
102
113
  className="flex-1 text-left pl-2 pr-3 py-2 flex items-center justify-between min-w-0"
103
114
  >
104
115
  <div className="flex items-center min-w-0 pr-2">
@@ -107,7 +118,16 @@ export function MobileSessionRow({
107
118
  outranks the run spinner — the spinner says work is happening,
108
119
  the question mark says it is YOUR turn (desktop parity). An
109
120
  in-flight rename outranks both, for the same reason it does
110
- there: it answers a click that is still pending. */}
121
+ there: it answers a click that is still pending.
122
+ In batch mode the slot carries the checkbox instead: a row that
123
+ is a choice should not also report a run. */}
124
+ {selectable ? (
125
+ <SelectionCheckbox
126
+ checked={selected}
127
+ label={`Select ${session.title}`}
128
+ onChange={() => onToggleSelect?.()}
129
+ />
130
+ ) : (
111
131
  <span className="w-4 flex-shrink-0 flex items-center justify-center">
112
132
  {renaming ? (
113
133
  <span role="status" aria-label="Generating a name…">
@@ -121,6 +141,7 @@ export function MobileSessionRow({
121
141
  <Check size={13} className="text-ink/50" />
122
142
  ) : null}
123
143
  </span>
144
+ )}
124
145
  <span className="text-xs truncate leading-snug ml-2">{title}</span>
125
146
  </div>
126
147
 
@@ -134,7 +155,7 @@ export function MobileSessionRow({
134
155
  column so the trailing glyph's centre stays 19px from the row's right
135
156
  edge whether the last element is this trigger or the chevron. Without
136
157
  it the glyphs step in and out from row to row. */}
137
- {hasActions && (
158
+ {hasActions && !selectable && (
138
159
  <button
139
160
  type="button"
140
161
  onClick={menu.openBelow}
@@ -151,7 +172,7 @@ export function MobileSessionRow({
151
172
  {/* Last element, so its glyph sits in the folder header's chevron column.
152
173
  `p-1.5 mr-1.5` centres a 14px glyph on that column while keeping a
153
174
  touch-sized tap target. */}
154
- {showChevron && (
175
+ {showChevron && !selectable && (
155
176
  <button
156
177
  type="button"
157
178
  onClick={onToggleExpand}
@@ -72,6 +72,8 @@ async function mount(overrides: Partial<MobileSessionToolbarProps> = {}) {
72
72
  onHideArchived: () => { calls.hidden += 1; },
73
73
  onSearchChange: () => {},
74
74
  onClearSearch: () => {},
75
+ onSelectWorkspaces: () => {},
76
+ selecting: false,
75
77
  ...overrides,
76
78
  }), container);
77
79
  });
@@ -131,3 +133,107 @@ describe('mobile toolbar archive button', () => {
131
133
  expect(sortTrigger?.className).not.toContain('bg-ink/10');
132
134
  });
133
135
  });
136
+
137
+ /**
138
+ * The row's overflow rule, which is what the reported missing `+` came down to.
139
+ *
140
+ * happy-dom reports `clientWidth` as 0, so the toolbar renders the unmeasured
141
+ * state — every control on the row — and these cases pin the two halves that
142
+ * are decidable without layout: the primary pill's icon and label are BOTH in
143
+ * the markup at every width, and the `⋯` menu exists as the destination the
144
+ * measured rule moves a control into.
145
+ */
146
+ describe('mobile toolbar overflow', () => {
147
+ test('the primary pill always draws its icon and its label', async () => {
148
+ const { el } = await mount();
149
+ const pill = Array.from(el.querySelectorAll('button')).find((b) => b.getAttribute('aria-label') === 'New Session');
150
+ expect(pill).not.toBeUndefined();
151
+ const icon = pill?.querySelector('svg');
152
+ expect(icon?.getAttribute('class')).toContain('lucide-plus');
153
+ // `shrink-0` is the fix: without it the icon is the flex item that gives up
154
+ // width, and the label's `whitespace-nowrap` min-content refuses to.
155
+ expect(icon?.getAttribute('class')).toContain('shrink-0');
156
+ const label = pill?.querySelector('span');
157
+ expect(label?.textContent).toBe('New Session');
158
+ // Not hidden at any width — the budget drops a CONTROL instead.
159
+ expect(label?.className).not.toContain('hidden');
160
+ });
161
+
162
+ test('every control is on the row until a width says otherwise', async () => {
163
+ // An unmeasured row reports 0, and `mobileToolbarCapacity(0)` keeps the
164
+ // full set: the menu must not appear before anything has been measured.
165
+ const { el } = await mount();
166
+ for (const name of [
167
+ 'New Workspace',
168
+ 'Scheduled tasks',
169
+ 'Show archived sessions',
170
+ 'Select workspaces',
171
+ 'Refresh sessions',
172
+ 'Sort workspaces',
173
+ ]) {
174
+ const button = Array.from(el.querySelectorAll('button')).find((b) => b.getAttribute('aria-label') === name);
175
+ expect(button).not.toBeUndefined();
176
+ }
177
+ expect(el.querySelector('button[aria-label="More sidebar actions"]')).toBeNull();
178
+ });
179
+
180
+ test('the overflow menu opens and carries the controls it took', async () => {
181
+ // The row is stubbed to a phone's 366px (a 390px drawer minus its `p-3`),
182
+ // which is the width the reported bug was seen at. happy-dom computes no
183
+ // layout, so the measurement the hook reads is supplied here — the rule
184
+ // itself is pinned by `sidebar-toolbar.test.ts`.
185
+ const descriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth');
186
+ Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
187
+ configurable: true,
188
+ get() {
189
+ return (this as HTMLElement).className === 'flex items-center space-x-2' ? 366 : 0;
190
+ },
191
+ });
192
+ try {
193
+ const { el } = await mount();
194
+ const onRow = (name: string) =>
195
+ Array.from(el.querySelectorAll('button')).find((b) => b.getAttribute('aria-label') === name);
196
+ // The tail moves out — scheduler and selection — while the head stays,
197
+ // including the two view controls a phone actually uses.
198
+ expect(onRow('Scheduled tasks')).toBeUndefined();
199
+ expect(onRow('Select workspaces')).toBeUndefined();
200
+ expect(onRow('New Workspace')).not.toBeUndefined();
201
+ expect(onRow('Sort workspaces')).not.toBeUndefined();
202
+
203
+ const trigger = el.querySelector('button[aria-label="More sidebar actions"]') as HTMLButtonElement;
204
+ expect(trigger).not.toBeNull();
205
+ await act(async () => { trigger.click(); });
206
+
207
+ // Named in words, not as glyphs — and each one still runs.
208
+ const menuItems = Array.from(el.querySelectorAll('[role="menuitem"]')).map((b) => b.textContent ?? '');
209
+ expect(menuItems).toContain('Scheduled tasks');
210
+ expect(menuItems).toContain('Select workspaces');
211
+ // The sort did NOT overflow here, so its options are not duplicated in
212
+ // the menu — the sort list arrives only when the sort control itself is
213
+ // one of the ones that left the row.
214
+ expect(el.textContent).not.toContain('Sort Workspaces');
215
+ } finally {
216
+ if (descriptor) Object.defineProperty(HTMLElement.prototype, 'clientWidth', descriptor);
217
+ else delete (HTMLElement.prototype as unknown as Record<string, unknown>).clientWidth;
218
+ }
219
+ });
220
+
221
+ test('a wide drawer keeps every control on the row', async () => {
222
+ const descriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth');
223
+ Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
224
+ configurable: true,
225
+ get() {
226
+ return (this as HTMLElement).className === 'flex items-center space-x-2' ? 600 : 0;
227
+ },
228
+ });
229
+ try {
230
+ const { el } = await mount();
231
+ expect(el.querySelector('button[aria-label="More sidebar actions"]')).toBeNull();
232
+ expect(Array.from(el.querySelectorAll('button')).find((b) => b.getAttribute('aria-label') === 'Sort workspaces'))
233
+ .not.toBeUndefined();
234
+ } finally {
235
+ if (descriptor) Object.defineProperty(HTMLElement.prototype, 'clientWidth', descriptor);
236
+ else delete (HTMLElement.prototype as unknown as Record<string, unknown>).clientWidth;
237
+ }
238
+ });
239
+ });