@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.
- package/README.md +16 -11
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +333 -333
- package/dist/client/static/js/index-pdp5dwc8.css +1 -0
- package/dist/client/static/js/{index-4vyzz0z4.js → index-zsskckw7.js} +681 -681
- 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/bootstrap.d.ts +8 -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/header-controls/MoreMenu.tsx +136 -0
- package/src/client/components/common/header-controls/index.test.tsx +77 -11
- package/src/client/components/common/header-controls/index.tsx +53 -4
- package/src/client/components/common/session-state-provider/index.test.ts +22 -1
- 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/Footer.tsx +20 -2
- package/src/client/components/layout/session-sidebar/OverflowMenu.tsx +23 -5
- 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 +30 -34
- package/src/client/components/layout/session-sidebar/index.tsx +92 -0
- package/src/client/components/mobile/LayoutWrapper.tsx +32 -1
- package/src/client/components/mobile/SessionSidebar.tsx +83 -0
- package/src/client/components/mobile/mobile-main-view/Header.tsx +27 -5
- package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +21 -1
- 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 +106 -0
- package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +178 -93
- package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +9 -0
- package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +6 -0
- package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +4 -1
- package/src/client/components/workspace/chat-timeline/chat-input/DropOverlay.tsx +1 -1
- package/src/client/components/workspace/chat-timeline/chat-input/GoalBanner.tsx +6 -1
- package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +4 -1
- package/src/client/components/workspace/chat-timeline/chat-input/VoiceNotice.tsx +2 -2
- package/src/client/components/workspace/chat-timeline/chat-input/expansion.test.ts +72 -1
- package/src/client/components/workspace/chat-timeline/chat-input/expansion.ts +28 -4
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +20 -2
- package/src/client/components/workspace/chat-timeline/chat-input/props.ts +7 -0
- package/src/client/components/workspace/chat-timeline/index.tsx +9 -3
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks.test.tsx +127 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks.tsx +71 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/PluginPendingInteractions.test.ts +1 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -2
- package/src/client/components/workspace/git-panel/BranchToolbar.tsx +4 -0
- package/src/client/components/workspace/git-panel/index.test.ts +15 -0
- package/src/client/components/workspace/search-panel/Body.test.ts +149 -0
- package/src/client/components/workspace/search-panel/Body.tsx +53 -9
- package/src/client/hooks/chat/timeline/ask-frames-grouped.test.ts +46 -2
- package/src/client/hooks/chat/timeline/ask-frames.test.ts +1 -1
- package/src/client/hooks/chat/timeline/ask-frames.ts +79 -29
- package/src/client/hooks/chat/timeline/index.ts +2 -1
- package/src/client/hooks/chat/timeline/scroll-probe.test-util.ts +227 -0
- package/src/client/hooks/chat/timeline/scroll-reading-back.test.ts +67 -0
- package/src/client/hooks/chat/timeline/scroll.test.ts +84 -226
- package/src/client/hooks/chat/timeline/scroll.ts +34 -1
- package/src/client/hooks/ui/media-query.ts +41 -0
- package/src/client/hooks/ui/updates/android-download.test.ts +73 -0
- package/src/client/hooks/ui/updates/android-download.ts +106 -0
- 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/hooks/workspace/file-tabs.test.ts +165 -0
- package/src/client/hooks/workspace/file-tabs.ts +24 -11
- package/src/client/hooks/workspace/session-state/index.ts +24 -1
- package/src/client/hooks/workspace/toolbar-row-width.ts +43 -0
- package/src/client/lib/native/back.test.ts +108 -0
- package/src/client/lib/native/back.ts +60 -0
- package/src/client/main.tsx +6 -0
- package/src/client/tailwind.css +6 -0
- package/src/server/lib/fs/git-actions-branch.ts +18 -1
- package/src/server/lib/fs/git-actions-sync.test.ts +144 -0
- package/src/server/lib/fs/git-publish.test.ts +96 -0
- package/src/server/lib/fs/git-publish.ts +35 -3
- package/src/server/lib/fs/listing.test.ts +86 -0
- package/src/server/lib/fs/listing.ts +20 -6
- 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/android-release.test.ts +92 -0
- package/src/server/lib/updates/android-release.ts +148 -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/plugins/ssr.ts +9 -2
- package/src/server/routes/folders/batch-delete.ts +68 -0
- package/src/server/routes/folders/index.ts +4 -0
- package/src/server/routes/fs/read.ts +24 -9
- package/src/server/routes/sessions/batch-delete.test.ts +142 -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/android.ts +22 -0
- package/src/server/routes/updates/apply.ts +1 -1
- package/src/server/routes/updates/index.ts +2 -0
- package/src/shared/lib/updates/shell-ua.test.ts +54 -0
- package/src/shared/lib/updates/shell-ua.ts +36 -0
- 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/session-state/listeners.ts +38 -0
- package/src/shared/lib/workspace/session-state/replacement.test.ts +121 -0
- package/src/shared/lib/workspace/session-state/store.ts +13 -2
- package/src/shared/lib/workspace/sidebar-toolbar.test.ts +98 -0
- package/src/shared/lib/workspace/sidebar-toolbar.ts +84 -0
- 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
|
-
|
|
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
|
|
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
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
+
});
|