@kabmo-labs/kabmo 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -11
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +323 -323
- package/dist/client/static/js/{index-4vyzz0z4.js → index-enkbbffs.js} +643 -643
- package/dist/client/static/js/index-yn17t04b.css +1 -0
- package/package.json +2 -1
- package/public/kabmo-wordmark.svg +31 -0
- package/src/cli/lib/commands/update.js +17 -2
- package/src/cli/lib/commands/update.test.js +36 -0
- package/src/client/components/auth/login-screen/index.test.tsx +93 -0
- package/src/client/components/auth/login-screen/index.tsx +43 -22
- package/src/client/components/auth/login-screen/password-toggle.test.tsx +121 -0
- package/src/client/components/common/batch-selection/BatchActionBar.test.ts +132 -0
- package/src/client/components/common/batch-selection/BatchActionBar.tsx +118 -0
- package/src/client/components/common/batch-selection/BatchDeleteModal.tsx +177 -0
- package/src/client/components/common/batch-selection/SelectionCheckbox.tsx +58 -0
- package/src/client/components/common/batch-selection/folder-state.test.ts +130 -0
- package/src/client/components/common/batch-selection/folder-state.ts +101 -0
- package/src/client/components/common/batch-selection/types.ts +42 -0
- package/src/client/components/common/brand-wordmark/Mark.tsx +62 -0
- package/src/client/components/common/brand-wordmark/index.test.tsx +29 -7
- package/src/client/components/common/brand-wordmark/index.tsx +33 -11
- package/src/client/components/common/brand-wordmark/wordmark-svg.test.ts +88 -0
- package/src/client/components/common/workspace-options-menu/index.tsx +25 -11
- package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +169 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +72 -91
- package/src/client/components/layout/session-sidebar/SessionItem.batch.test.ts +162 -0
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +35 -6
- package/src/client/components/layout/session-sidebar/SessionList.tsx +10 -1
- package/src/client/components/layout/session-sidebar/Toolbar.test.ts +2 -0
- package/src/client/components/layout/session-sidebar/Toolbar.tsx +28 -1
- package/src/client/components/layout/session-sidebar/index.tsx +87 -0
- package/src/client/components/mobile/SessionSidebar.tsx +78 -0
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +56 -12
- package/src/client/components/mobile/mobile-session-sidebar/List.tsx +8 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +27 -6
- package/src/client/components/mobile/mobile-session-sidebar/Toolbar.test.ts +2 -0
- package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +26 -1
- package/src/client/hooks/workspace/batch-delete-run.ts +95 -0
- package/src/client/hooks/workspace/batch-delete.ts +63 -0
- package/src/client/hooks/workspace/batch-selection.ts +98 -0
- package/src/client/tailwind.css +6 -0
- package/src/server/lib/lifecycle/restart.ts +52 -14
- package/src/server/lib/omp/session/delete-flow.server.ts +101 -0
- package/src/server/lib/updates/apply.ts +8 -0
- package/src/server/lib/updates/install-steps.ts +81 -0
- package/src/server/lib/updates/install.ts +19 -67
- package/src/server/lib/updates/installed-cli.test.ts +222 -0
- package/src/server/lib/updates/installed-cli.ts +139 -0
- package/src/server/lib/updates/omp.ts +1 -1
- package/src/server/routes/folders/batch-delete.ts +68 -0
- package/src/server/routes/folders/index.ts +4 -0
- package/src/server/routes/sessions/batch-delete.test.ts +139 -0
- package/src/server/routes/sessions/batch-delete.ts +60 -0
- package/src/server/routes/sessions/delete.ts +13 -51
- package/src/server/routes/sessions/index.ts +5 -0
- package/src/server/routes/updates/apply.ts +1 -1
- package/src/shared/lib/workspace/batch-select.test.ts +92 -0
- package/src/shared/lib/workspace/batch-select.ts +86 -0
- package/src/shared/lib/workspace/sidebar-toolbar.ts +6 -0
- package/dist/client/static/js/index-x9cehh1a.css +0 -1
|
@@ -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
|
-
<
|
|
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
|
-
</
|
|
79
|
-
<
|
|
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
|
-
</
|
|
90
|
+
</button>
|
|
82
91
|
</>
|
|
83
92
|
) : (
|
|
84
93
|
<>
|
|
85
94
|
{onOpenFolder && (
|
|
86
|
-
<
|
|
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
|
-
</
|
|
97
|
+
</button>
|
|
89
98
|
)}
|
|
90
|
-
<
|
|
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
|
-
</
|
|
94
|
-
|
|
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
|
-
</
|
|
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,
|
|
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:
|
|
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
|
|
122
|
+
const visibleSessions = (allSessions || []).filter((s) =>
|
|
117
123
|
showArchived ? s.is_archived === 1 : s.is_archived !== 1
|
|
118
124
|
);
|
|
119
125
|
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
{
|
|
186
|
+
{listOpen && (
|
|
209
187
|
<div className="space-y-0.5">
|
|
210
|
-
{renderedSessions.map((session
|
|
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
|
+
});
|