@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
@@ -13,7 +13,7 @@
13
13
  * source of truth.
14
14
  */
15
15
 
16
- import { FolderOpen, Pin, PinOff, Trash2 } from 'lucide-preact';
16
+ import { FolderOpen, ListChecks, Pin, PinOff, Trash2 } from 'lucide-preact';
17
17
 
18
18
  interface WorkspaceOptionsMenuProps {
19
19
  variant: 'desktop' | 'mobile';
@@ -21,6 +21,8 @@ interface WorkspaceOptionsMenuProps {
21
21
  /** Reveal the workspace directory in the platform file manager. Omitted for
22
22
  * an unbound folder, which has no directory of its own. */
23
23
  onOpenFolder?: () => void;
24
+ /** Enter the session batch-selection mode scoped to this workspace. */
25
+ onSelectSessions?: () => void;
24
26
  confirmDelete: boolean;
25
27
  onPin: () => void;
26
28
  onDelete: () => void;
@@ -32,6 +34,7 @@ export function WorkspaceOptionsMenu({
32
34
  variant,
33
35
  isPinned = false,
34
36
  onOpenFolder,
37
+ onSelectSessions,
35
38
  confirmDelete,
36
39
  onPin,
37
40
  onDelete,
@@ -62,6 +65,12 @@ export function WorkspaceOptionsMenu({
62
65
  {isPinned ? <PinOff size={13} /> : <Pin size={13} />}
63
66
  <span>{isPinned ? 'Unpin Workspace' : 'Pin Workspace'}</span>
64
67
  </button>
68
+ {onSelectSessions && (
69
+ <button type="button" onClick={onSelectSessions} className="w-full text-left px-3 py-2 hover:bg-ink/5 text-ink/80 flex items-center space-x-2">
70
+ <ListChecks size={13} />
71
+ <span>Select sessions</span>
72
+ </button>
73
+ )}
65
74
  <button type="button" onClick={onRequestDelete} className="w-full text-left px-3 py-2 hover:bg-error/10 text-error flex items-center space-x-2">
66
75
  <Trash2 size={13} />
67
76
  <span>Delete Workspace</span>
@@ -73,27 +82,32 @@ export function WorkspaceOptionsMenu({
73
82
  return confirmDelete ? (
74
83
  <>
75
84
  <div className="px-3 py-1.5 text-xs text-ink/80 font-medium">Delete workspace?</div>
76
- <div className="px-3 py-1.5 hover:bg-ink/5 cursor-pointer flex items-center space-x-2 text-error" onClick={onDelete}>
85
+ <button type="button" onClick={onDelete} className="w-full text-left px-3 py-1.5 hover:bg-ink/5 cursor-pointer flex items-center space-x-2 text-error">
77
86
  <Trash2 size={12} /><span>Yes, delete</span>
78
- </div>
79
- <div className="px-3 py-1.5 hover:bg-ink/5 cursor-pointer flex items-center space-x-2 text-ink/70" onClick={onCancelDelete}>
87
+ </button>
88
+ <button type="button" onClick={onCancelDelete} className="w-full text-left px-3 py-1.5 hover:bg-ink/5 cursor-pointer flex items-center space-x-2 text-ink/70">
80
89
  <span>Cancel</span>
81
- </div>
90
+ </button>
82
91
  </>
83
92
  ) : (
84
93
  <>
85
94
  {onOpenFolder && (
86
- <div className="px-3 py-1.5 hover:bg-ink/5 cursor-pointer flex items-center space-x-2 text-ink/80" onClick={onOpenFolder}>
95
+ <button type="button" onClick={onOpenFolder} className="w-full text-left px-3 py-1.5 hover:bg-ink/5 cursor-pointer flex items-center space-x-2 text-ink/80">
87
96
  <FolderOpen size={12} /><span>Go to Explorer</span>
88
- </div>
97
+ </button>
89
98
  )}
90
- <div className="px-3 py-1.5 hover:bg-ink/5 cursor-pointer flex items-center space-x-2 text-ink/80" onClick={onPin}>
99
+ <button type="button" onClick={onPin} className="w-full text-left px-3 py-1.5 hover:bg-ink/5 cursor-pointer flex items-center space-x-2 text-ink/80">
91
100
  {isPinned ? <PinOff size={12} /> : <Pin size={12} />}
92
101
  <span>{isPinned ? 'Unpin Workspace' : 'Pin Workspace'}</span>
93
- </div>
94
- <div className="px-3 py-1.5 hover:bg-ink/5 cursor-pointer flex items-center space-x-2 text-error" onClick={onRequestDelete}>
102
+ </button>
103
+ {onSelectSessions && (
104
+ <button type="button" onClick={onSelectSessions} className="w-full text-left px-3 py-1.5 hover:bg-ink/5 cursor-pointer flex items-center space-x-2 text-ink/80">
105
+ <ListChecks size={12} /><span>Select sessions</span>
106
+ </button>
107
+ )}
108
+ <button type="button" onClick={onRequestDelete} className="w-full text-left px-3 py-1.5 hover:bg-ink/5 cursor-pointer flex items-center space-x-2 text-error">
95
109
  <Trash2 size={12} /><span>Delete Workspace</span>
96
- </div>
110
+ </button>
97
111
  </>
98
112
  );
99
113
  }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * A workspace's header row in the desktop sidebar: the disclosure mark, the
8
+ * folder's name, its two actions, and — while a batch selection is active — the
9
+ * checkbox that selects it (workspace mode) or all of its rows (session mode).
10
+ *
11
+ * Split out of `CategoryItem` because the row now has two modes' worth of
12
+ * markup and the parent is also the list. The checkbox rules stay in the parent
13
+ * (they are about the selection, not the drawing) and arrive as `checkbox`
14
+ * props; this file only decides how the two states look.
15
+ */
16
+
17
+ import { ChevronDown, ChevronRight, MoreHorizontal, Pin, Plus } from 'lucide-preact';
18
+ import { SelectionCheckbox } from '@/client/components/common/batch-selection/SelectionCheckbox';
19
+ import { WorkspaceOptionsMenu } from '@/client/components/common/workspace-options-menu';
20
+ import { getProjectIcon } from '@/shared/lib/workspace/project-icon';
21
+ import type { WorkspaceFolderData } from '@/shared/types';
22
+
23
+ export interface CategoryHeaderCheckbox {
24
+ checked: boolean;
25
+ indeterminate: boolean;
26
+ label: string;
27
+ onChange: () => void;
28
+ }
29
+
30
+ export interface CategoryHeaderProps {
31
+ folder: WorkspaceFolderData;
32
+ isOpen: boolean;
33
+ onToggleFolder: () => void;
34
+ /** Null when this workspace is not part of the active selection. */
35
+ checkbox: CategoryHeaderCheckbox | null;
36
+ /** True while a batch selection is active — actions are hidden then. */
37
+ batchActive: boolean;
38
+ menuRef: { current: HTMLDivElement | null };
39
+ showMenu: boolean;
40
+ onToggleMenu: () => void;
41
+ onNewSession: () => void;
42
+ onOpenFolder?: () => void;
43
+ onSelectSessions?: () => void;
44
+ confirmDelete: boolean;
45
+ onPin: () => void;
46
+ onDelete: () => void;
47
+ onRequestDelete: () => void;
48
+ onCancelDelete: () => void;
49
+ }
50
+
51
+ export function CategoryHeader({
52
+ folder,
53
+ isOpen,
54
+ onToggleFolder,
55
+ checkbox,
56
+ batchActive,
57
+ menuRef,
58
+ showMenu,
59
+ onToggleMenu,
60
+ onNewSession,
61
+ onOpenFolder,
62
+ onSelectSessions,
63
+ confirmDelete,
64
+ onPin,
65
+ onDelete,
66
+ onRequestDelete,
67
+ onCancelDelete,
68
+ }: CategoryHeaderProps) {
69
+ const ProjectIcon = getProjectIcon(folder.icon);
70
+
71
+ return (
72
+ <div className="group flex items-center justify-between h-7 text-xs font-semibold text-ink px-2 hover:bg-ink/5 rounded-md transition-colors select-none">
73
+ <div
74
+ className="flex-1 h-full flex items-center cursor-pointer min-w-0"
75
+ onClick={batchActive ? undefined : onToggleFolder}
76
+ >
77
+ {checkbox ? (
78
+ <>
79
+ <SelectionCheckbox
80
+ size="md"
81
+ checked={checkbox.checked}
82
+ indeterminate={checkbox.indeterminate}
83
+ label={checkbox.label}
84
+ onChange={checkbox.onChange}
85
+ />
86
+ <span className="w-2 shrink-0" />
87
+ </>
88
+ ) : (
89
+ <>
90
+ {/* One 16px slot owns both glyphs, exactly like a session row's
91
+ status/chevron slot: the folder mark sits there at rest and hands
92
+ the slot to the disclosure chevron on hover, so the icon column
93
+ reads as one line AND the header says what clicking it does. */}
94
+ <span className="relative w-4 h-4 flex items-center justify-center shrink-0">
95
+ <span className="absolute inset-0 flex items-center justify-center transition-opacity group-hover:opacity-0 touch-hidden">
96
+ {folder.customIconUrl ? (
97
+ <img
98
+ src={folder.customIconUrl}
99
+ alt=""
100
+ className="w-[15px] h-[15px] rounded-xs object-contain shrink-0"
101
+ referrerPolicy="no-referrer"
102
+ />
103
+ ) : (
104
+ <ProjectIcon
105
+ size={15}
106
+ className="text-ink/75"
107
+ style={{ color: folder.accentColor || undefined }}
108
+ />
109
+ )}
110
+ </span>
111
+ <span className="absolute inset-0 flex items-center justify-center text-ink/60 opacity-0 transition-opacity group-hover:opacity-100 touch-visible">
112
+ {isOpen ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
113
+ </span>
114
+ </span>
115
+ <span className="w-2 shrink-0" />
116
+ </>
117
+ )}
118
+ {folder.isPinned && <Pin size={11} className="text-ink/60 shrink-0 mr-1.5" />}
119
+ <span className="truncate text-[13px] font-semibold tracking-tight">{folder.name}</span>
120
+ </div>
121
+
122
+ {/* Workspace Actions — always drawn, matching the drawer's folder header.
123
+ They used to appear only on hover, which left the folder's own
124
+ affordances undiscoverable in a sidebar that has no other cue. In
125
+ batch mode they are hidden: the row is a choice, and an action beside
126
+ a checkbox is a second thing the click could mean. */}
127
+ {!batchActive && (
128
+ <div className="flex items-center space-x-0.5 pl-1">
129
+ <button
130
+ type="button"
131
+ title="New Session"
132
+ className="w-5 h-5 flex items-center justify-center text-ink/40 hover:text-ink hover:bg-ink/10 rounded cursor-pointer transition-colors"
133
+ onClick={(e) => { e.stopPropagation(); onNewSession(); }}
134
+ >
135
+ <Plus size={12} />
136
+ </button>
137
+
138
+ <div className="relative flex items-center" ref={menuRef}>
139
+ <button
140
+ type="button"
141
+ title={`Options for ${folder.name}`}
142
+ aria-label={`Options for ${folder.name}`}
143
+ className="w-5 h-5 flex items-center justify-center text-ink/40 hover:text-ink hover:bg-ink/10 rounded cursor-pointer transition-colors"
144
+ onClick={(e) => { e.stopPropagation(); onToggleMenu(); }}
145
+ >
146
+ <MoreHorizontal size={12} />
147
+ </button>
148
+
149
+ {showMenu && (
150
+ <div className="absolute right-0 top-full mt-1 w-44 bg-paper border border-ink/15 rounded-md shadow-lg z-50 py-1 text-xs">
151
+ <WorkspaceOptionsMenu
152
+ variant="desktop"
153
+ isPinned={folder.isPinned}
154
+ onOpenFolder={onOpenFolder}
155
+ onSelectSessions={onSelectSessions}
156
+ confirmDelete={confirmDelete}
157
+ onPin={onPin}
158
+ onDelete={onDelete}
159
+ onRequestDelete={onRequestDelete}
160
+ onCancelDelete={onCancelDelete}
161
+ />
162
+ </div>
163
+ )}
164
+ </div>
165
+ </div>
166
+ )}
167
+ </div>
168
+ );
169
+ }
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useRef, useState } from 'preact/hooks';
2
- import { ChevronDown, ChevronRight, ChevronUp, MoreHorizontal, Pin, Plus } from 'lucide-preact';
2
+ import { ChevronDown, ChevronUp } from 'lucide-preact';
3
3
  import { useSearchParams } from '@/client/lib/router/search-params';
4
4
  import { useFetcher } from '@/client/lib/router/fetcher';
5
5
  import { useOnClickOutside } from '@/client/hooks/ui/on-click-outside';
@@ -8,13 +8,14 @@ import { useWorkspaceFolderActions } from '@/client/hooks/workspace/workspace-fo
8
8
  import { useSessionDelete } from '@/client/hooks/workspace/session-delete';
9
9
  import { SessionDeleteModal } from '@/client/components/common/session-delete-modal';
10
10
  import { SessionItem } from '@/client/components/layout/session-sidebar/SessionItem';
11
+ import { CategoryHeader } from '@/client/components/layout/session-sidebar/CategoryHeader';
11
12
  import { SubagentList } from '@/client/components/common/subagent-list';
12
- import { WorkspaceOptionsMenu } from '@/client/components/common/workspace-options-menu';
13
13
  import { useExpandedSessions } from '@/client/hooks/workspace/expanded-sessions';
14
- import { getProjectIcon } from '@/shared/lib/workspace/project-icon';
15
- import { selectVisibleSessions } from '@/shared/lib/workspace/session-visibility';
16
14
  import { relativeTimeAgo } from '@/shared/lib/workspace/relative-time';
17
15
  import { useSidebarData } from '@/client/hooks/chat/omp/session-list';
16
+ import type { BatchBinding } from '@/client/components/common/batch-selection/types';
17
+ import { folderBatchState } from '@/client/components/common/batch-selection/folder-state';
18
+ import type { WorkspaceFolderData } from '@/shared/types';
18
19
 
19
20
  export { SessionItem };
20
21
 
@@ -27,8 +28,10 @@ export function Category({
27
28
  showArchived = false,
28
29
  sessionStatus = {},
29
30
  onToast,
31
+ batch,
32
+ onStartSessionSelection,
30
33
  }: {
31
- folder: any;
34
+ folder: WorkspaceFolderData;
32
35
  activeSessionId: number | string | null;
33
36
  onSelectSession: (id: number | string) => void;
34
37
  onNewSessionForFolder: (id: number) => void;
@@ -36,6 +39,10 @@ export function Category({
36
39
  showArchived?: boolean;
37
40
  sessionStatus?: Record<string, 'stream' | 'finish' | 'abort'>;
38
41
  onToast?: (message: string, type?: 'success' | 'error') => void;
42
+ /** Present only while a batch selection is active; absent is the normal list. */
43
+ batch?: BatchBinding;
44
+ /** Start a session selection scoped to this workspace, from its `⋯` menu. */
45
+ onStartSessionSelection?: (folderId: number) => void;
39
46
  }) {
40
47
  const [isOpen, setIsOpen] = useState(folder.isExpanded || false);
41
48
  const [showMenu, setShowMenu] = useState(false);
@@ -74,7 +81,6 @@ export function Category({
74
81
  });
75
82
 
76
83
  const allSessions = folder.sessions;
77
- const ProjectIcon = getProjectIcon(folder.icon);
78
84
 
79
85
  const toggleFolder = () => {
80
86
  const nextState = !isOpen;
@@ -113,101 +119,73 @@ export function Category({
113
119
 
114
120
  const isActuallyOpen = forceExpanded || isOpen;
115
121
 
116
- const visibleSessions = (allSessions || []).filter((s: any) =>
122
+ const visibleSessions = (allSessions || []).filter((s) =>
117
123
  showArchived ? s.is_archived === 1 : s.is_archived !== 1
118
124
  );
119
125
 
120
- // The opened session and every live run are exempt from the window, so a
121
- // session being watched is never one of the hidden rows.
122
- const { rendered: renderedSessions, hiddenCount } = selectVisibleSessions(
123
- visibleSessions,
126
+ // Batch rules live in one shared module — the phone's drawer item reads the
127
+ // same function, so the two layouts cannot disagree about which rows a
128
+ // header owns or what its checkbox reports.
129
+ const {
130
+ folderCheckbox,
131
+ sessionCheckboxes,
132
+ folderSummary,
133
+ toggleFolderSelection,
134
+ rendered: renderedSessions,
135
+ hiddenCount,
136
+ } = folderBatchState({
137
+ batch,
138
+ folder,
139
+ sessions: visibleSessions,
140
+ showArchived,
124
141
  visibleCount,
125
- { activeSessionId, sessionStatus },
126
- );
142
+ activeSessionId,
143
+ sessionStatus,
144
+ });
145
+ // A session selection opens every folder: a checkbox on a collapsed workspace
146
+ // would let the user select rows they cannot see or unselect. The WORKSPACE
147
+ // mode leaves the expansion alone — it selects folders, not rows.
148
+ const listOpen = isActuallyOpen || sessionCheckboxes;
127
149
  const hasMore = hiddenCount > 0;
128
150
 
129
151
  return (
130
152
  <div className="space-y-1">
131
- {/* Folder Header */}
132
- <div
133
- className="group flex items-center justify-between h-7 text-xs font-semibold text-ink px-2 hover:bg-ink/5 rounded-md transition-colors select-none"
134
- >
135
- <div className="flex-1 h-full flex items-center cursor-pointer min-w-0" onClick={toggleFolder}>
136
- {/* One 16px slot owns both glyphs, exactly like a session row's
137
- status/chevron slot: the folder mark sits there at rest and hands
138
- the slot to the disclosure chevron on hover, so the icon column
139
- reads as one line AND the header says what clicking it does. */}
140
- <span className="relative w-4 h-4 flex items-center justify-center shrink-0">
141
- <span className="absolute inset-0 flex items-center justify-center transition-opacity group-hover:opacity-0 touch-hidden">
142
- {folder.customIconUrl ? (
143
- <img
144
- src={folder.customIconUrl}
145
- alt=""
146
- className="w-[15px] h-[15px] rounded-xs object-contain shrink-0"
147
- referrerPolicy="no-referrer"
148
- />
149
- ) : (
150
- <ProjectIcon
151
- size={15}
152
- className="text-ink/75"
153
- style={{ color: folder.accentColor || undefined }}
154
- />
155
- )}
156
- </span>
157
- <span className="absolute inset-0 flex items-center justify-center text-ink/60 opacity-0 transition-opacity group-hover:opacity-100 touch-visible">
158
- {isActuallyOpen ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
159
- </span>
160
- </span>
161
- <span className="w-2 shrink-0" />
162
- {folder.isPinned && <Pin size={11} className="text-ink/60 shrink-0 mr-1.5" />}
163
- <span className="truncate text-[13px] font-semibold tracking-tight">{folder.name}</span>
164
- </div>
165
-
166
- {/* Workspace Actions — always drawn, matching the drawer's folder
167
- header. They used to appear only on hover, which left the folder's
168
- own affordances undiscoverable in a sidebar that has no other cue. */}
169
- <div className="flex items-center space-x-0.5 pl-1">
170
- <button
171
- type="button"
172
- title="New Session"
173
- className="w-5 h-5 flex items-center justify-center text-ink/40 hover:text-ink hover:bg-ink/10 rounded cursor-pointer transition-colors"
174
- onClick={(e) => { e.stopPropagation(); onNewSessionForFolder(folder.id); }}
175
- >
176
- <Plus size={12} />
177
- </button>
178
-
179
- <div className="relative flex items-center" ref={menuRef}>
180
- <button
181
- type="button"
182
- title="Workspace Options"
183
- className="w-5 h-5 flex items-center justify-center text-ink/40 hover:text-ink hover:bg-ink/10 rounded cursor-pointer transition-colors"
184
- onClick={(e) => { e.stopPropagation(); setShowMenu(!showMenu); }}
185
- >
186
- <MoreHorizontal size={12} />
187
- </button>
188
-
189
- {showMenu && (
190
- <div className="absolute right-0 top-full mt-1 w-44 bg-paper border border-ink/15 rounded-md shadow-lg z-50 py-1 text-xs">
191
- <WorkspaceOptionsMenu
192
- variant="desktop"
193
- isPinned={folder.isPinned}
194
- onOpenFolder={onOpenFolder}
195
- confirmDelete={confirmDelete}
196
- onPin={onPin}
197
- onDelete={onDelete}
198
- onRequestDelete={requestDelete}
199
- onCancelDelete={cancelDelete}
200
- />
201
- </div>
202
- )}
203
- </div>
204
- </div>
205
- </div>
153
+ <CategoryHeader
154
+ folder={folder}
155
+ isOpen={isActuallyOpen}
156
+ onToggleFolder={toggleFolder}
157
+ checkbox={
158
+ folderCheckbox && folderSummary
159
+ ? {
160
+ checked: folderSummary.all,
161
+ indeterminate: folderSummary.some,
162
+ label: `Select ${folder.name}`,
163
+ onChange: toggleFolderSelection,
164
+ }
165
+ : null
166
+ }
167
+ batchActive={Boolean(batch)}
168
+ menuRef={menuRef}
169
+ showMenu={showMenu}
170
+ onToggleMenu={() => setShowMenu(!showMenu)}
171
+ onNewSession={() => onNewSessionForFolder(folder.id)}
172
+ onOpenFolder={onOpenFolder}
173
+ onSelectSessions={
174
+ onStartSessionSelection
175
+ ? () => { setShowMenu(false); onStartSessionSelection(folder.id); }
176
+ : undefined
177
+ }
178
+ confirmDelete={confirmDelete}
179
+ onPin={onPin}
180
+ onDelete={onDelete}
181
+ onRequestDelete={requestDelete}
182
+ onCancelDelete={cancelDelete}
183
+ />
206
184
 
207
185
  {/* Sessions List */}
208
- {isActuallyOpen && (
186
+ {listOpen && (
209
187
  <div className="space-y-0.5">
210
- {renderedSessions.map((session: any) => {
188
+ {renderedSessions.map((session) => {
211
189
  const sessionKey = String(session.id);
212
190
  const isActive = activeSessionId !== null
213
191
  ? String(activeSessionId) === sessionKey
@@ -248,6 +226,9 @@ export function Category({
248
226
  hasSubagents={hasSubagents}
249
227
  isExpanded={isExpanded}
250
228
  onToggleExpand={() => handleToggleSessionExpand(sessionKey)}
229
+ selectable={sessionCheckboxes}
230
+ selected={Boolean(batch?.selected.includes(sessionKey))}
231
+ onToggleSelect={batch ? () => batch.toggle(sessionKey) : undefined}
251
232
  />
252
233
  {hasSubagents && isExpanded && (
253
234
  <SubagentList sessionId={session.id} isActiveSession={canExpandActive} />
@@ -261,7 +242,7 @@ export function Category({
261
242
  truncated for height, so after a partial reveal the user needs to
262
243
  keep revealing AND to start over, which is what two controls say
263
244
  and one alternating control cannot. */}
264
- {(hasMore || canShowLess) && !forceExpanded && (
245
+ {(hasMore || canShowLess) && !forceExpanded && !sessionCheckboxes && (
265
246
  <div className="flex items-center gap-1">
266
247
  {hasMore && (
267
248
  <button
@@ -0,0 +1,162 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The row's batch-mode switch, on both layouts.
8
+ *
9
+ * Two properties decide whether the feature is usable at all: in select mode a
10
+ * click must TOGGLE the selection rather than open the session (otherwise the
11
+ * checkbox is decorative and every tap navigates away), and the row's own
12
+ * actions must be gone (an action beside a checkbox is a second thing the click
13
+ * could mean). The rows draw these from one prop set, so both layouts are
14
+ * asserted here rather than trusted to match.
15
+ */
16
+
17
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
18
+ import { Window } from 'happy-dom';
19
+ import { h, render } from 'preact';
20
+ import { act } from 'preact/test-utils';
21
+
22
+ import { SessionItem } from '@/client/components/layout/session-sidebar/SessionItem';
23
+ import type { SessionItemProps } from '@/client/components/layout/session-sidebar/SessionItem';
24
+ import { MobileSessionRow } from '@/client/components/mobile/mobile-session-sidebar/SessionRow';
25
+ import type { MobileSessionRowProps } from '@/client/components/mobile/mobile-session-sidebar/SessionRow';
26
+ import type { SessionItemData } from '@/shared/types';
27
+
28
+ const DOM_GLOBALS = ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'HTMLInputElement', 'Event', 'MouseEvent', 'KeyboardEvent'] as const;
29
+ const nativeGlobals: Partial<Record<(typeof DOM_GLOBALS)[number], unknown>> = {};
30
+
31
+ let container: HTMLElement;
32
+
33
+ beforeAll(() => {
34
+ const win = new Window({ url: 'http://localhost' });
35
+ const target = globalThis as unknown as Record<string, unknown>;
36
+ for (const key of DOM_GLOBALS) {
37
+ if (!(key in nativeGlobals)) nativeGlobals[key] = target[key];
38
+ target[key] = (win as unknown as Record<string, unknown>)[key];
39
+ }
40
+ });
41
+
42
+ afterAll(() => {
43
+ const target = globalThis as unknown as Record<string, unknown>;
44
+ for (const key of DOM_GLOBALS) {
45
+ if (nativeGlobals[key] === undefined) delete target[key];
46
+ else target[key] = nativeGlobals[key];
47
+ }
48
+ });
49
+
50
+ afterEach(() => {
51
+ container?.remove();
52
+ });
53
+
54
+ async function mountDesktop(overrides: Partial<SessionItemProps> = {}) {
55
+ const calls = { click: 0, toggle: 0, actions: 0 };
56
+ container = document.createElement('div');
57
+ document.body.appendChild(container);
58
+ await act(async () => {
59
+ render(h(SessionItem, {
60
+ sessionId: 's1',
61
+ title: 'hello world',
62
+ onClick: () => { calls.click += 1; },
63
+ onRename: () => {},
64
+ onDelete: () => {},
65
+ ...overrides,
66
+ ...(overrides.selectable ? { onToggleSelect: () => { calls.toggle += 1; } } : {}),
67
+ }), container);
68
+ });
69
+ return { el: container, calls };
70
+ }
71
+
72
+ const session: SessionItemData = { id: 's1', folder_id: 1, title: 'hello world' };
73
+
74
+ async function mountMobile(overrides: Partial<MobileSessionRowProps> = {}) {
75
+ const calls = { select: 0, toggle: 0 };
76
+ container = document.createElement('div');
77
+ document.body.appendChild(container);
78
+ await act(async () => {
79
+ render(h(MobileSessionRow, {
80
+ session,
81
+ isActive: false,
82
+ timeAgo: null,
83
+ onSelect: () => { calls.select += 1; },
84
+ onArchive: () => {},
85
+ onDelete: () => {},
86
+ onRename: () => {},
87
+ ...overrides,
88
+ ...(overrides.selectable ? { onToggleSelect: () => { calls.toggle += 1; } } : {}),
89
+ }), container);
90
+ });
91
+ return { el: container, calls };
92
+ }
93
+
94
+ function checkbox(el: HTMLElement): HTMLInputElement | null {
95
+ return el.querySelector('input[type="checkbox"]') as HTMLInputElement | null;
96
+ }
97
+
98
+ describe('desktop row batch mode', () => {
99
+ test('no checkbox while the sidebar is not selecting', async () => {
100
+ const { el } = await mountDesktop();
101
+ expect(checkbox(el)).toBeNull();
102
+ expect(el.querySelector('[data-selected]')).toBeNull();
103
+ });
104
+
105
+ test('draws a checkbox named after the row', async () => {
106
+ const { el } = await mountDesktop({ selectable: true });
107
+ expect(checkbox(el)?.getAttribute('aria-label')).toBe('Select hello world');
108
+ });
109
+
110
+ test('a click toggles the selection instead of opening the session', async () => {
111
+ const { el, calls } = await mountDesktop({ selectable: true });
112
+ const row = el.querySelector('[data-testid="session-item"]') as HTMLElement;
113
+ await act(async () => { row.click(); });
114
+ expect(calls.toggle).toBe(1);
115
+ expect(calls.click).toBe(0);
116
+ });
117
+
118
+ test('the checkbox click does not also fire the row', async () => {
119
+ const { el, calls } = await mountDesktop({ selectable: true });
120
+ await act(async () => { checkbox(el)?.click(); });
121
+ expect(calls.toggle).toBe(1);
122
+ expect(calls.click).toBe(0);
123
+ });
124
+
125
+ test('marks the selected row for styling and hides its actions', async () => {
126
+ const { el } = await mountDesktop({ selectable: true, selected: true });
127
+ const row = el.querySelector('[data-testid="session-item"]') as HTMLElement;
128
+ expect(row.getAttribute('data-selected')).toBe('true');
129
+ expect(checkbox(el)?.checked).toBe(true);
130
+ expect(el.querySelector('button[aria-label="Session actions"]')).toBeNull();
131
+ });
132
+
133
+ test('the normal row still opens the session and keeps its actions', async () => {
134
+ const { el, calls } = await mountDesktop();
135
+ const row = el.querySelector('[data-testid="session-item"]') as HTMLElement;
136
+ await act(async () => { row.click(); });
137
+ expect(calls.click).toBe(1);
138
+ expect(el.querySelector('button[aria-label="Session actions"]')).not.toBeNull();
139
+ });
140
+ });
141
+
142
+ describe('mobile row batch mode', () => {
143
+ test('no checkbox while the drawer is not selecting', async () => {
144
+ const { el } = await mountMobile();
145
+ expect(checkbox(el)).toBeNull();
146
+ });
147
+
148
+ test('a tap toggles the selection instead of opening the session', async () => {
149
+ const { el, calls } = await mountMobile({ selectable: true });
150
+ const row = el.querySelector('[data-testid="session-item"]') as HTMLElement;
151
+ await act(async () => { row.querySelector('button')?.click(); });
152
+ expect(calls.toggle).toBe(1);
153
+ expect(calls.select).toBe(0);
154
+ });
155
+
156
+ test('hides the row actions and the roster toggle in select mode', async () => {
157
+ const { el } = await mountMobile({ selectable: true, hasSubagents: true, onToggleExpand: () => {} });
158
+ expect(el.querySelector('button[aria-label="Session actions"]')).toBeNull();
159
+ expect(el.querySelector('button[aria-label*="subagents"]')).toBeNull();
160
+ expect(checkbox(el)?.getAttribute('aria-label')).toBe('Select hello world');
161
+ });
162
+ });