@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
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The bar a batch selection raises: what is selected, select-all / unselect-all,
|
|
8
|
+
* Delete and Cancel.
|
|
9
|
+
*
|
|
10
|
+
* It sits at the bottom of the sidebar (desktop) or above the list (phone) and
|
|
11
|
+
* is the ONLY place a delete can be started — the checkboxes themselves never
|
|
12
|
+
* act, so a mis-tap on a row cannot delete anything. Select-all and unselect-all
|
|
13
|
+
* are two buttons rather than one toggle: after a partial selection the user
|
|
14
|
+
* needs both directions, and one alternating control cannot say which it means.
|
|
15
|
+
*
|
|
16
|
+
* The scope noun is the mode's, not the bar's: "Select all" on the session mode
|
|
17
|
+
* means every session of the workspace whose menu started it, and on the
|
|
18
|
+
* workspace mode every workspace on screen.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { CheckCheck, Loader2, Trash2, X } from 'lucide-preact';
|
|
22
|
+
import type { BatchMode } from '@/shared/lib/workspace/batch-select';
|
|
23
|
+
|
|
24
|
+
export interface BatchActionBarProps {
|
|
25
|
+
mode: BatchMode;
|
|
26
|
+
/** How many rows are selected. */
|
|
27
|
+
count: number;
|
|
28
|
+
/** How many the scope can offer — the denominator of "3 of 12". */
|
|
29
|
+
total: number;
|
|
30
|
+
/** True while a batch delete is in flight. */
|
|
31
|
+
busy: boolean;
|
|
32
|
+
/** The scope's name, for the sentence ("in kabmo"). Omitted in workspace mode. */
|
|
33
|
+
scopeLabel?: string;
|
|
34
|
+
onSelectAll: () => void;
|
|
35
|
+
onUnselectAll: () => void;
|
|
36
|
+
onDelete: () => void;
|
|
37
|
+
onCancel: () => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function BatchActionBar({
|
|
41
|
+
mode,
|
|
42
|
+
count,
|
|
43
|
+
total,
|
|
44
|
+
busy,
|
|
45
|
+
scopeLabel,
|
|
46
|
+
onSelectAll,
|
|
47
|
+
onUnselectAll,
|
|
48
|
+
onDelete,
|
|
49
|
+
onCancel,
|
|
50
|
+
}: BatchActionBarProps) {
|
|
51
|
+
const noun = mode === 'workspace' ? 'workspace' : 'session';
|
|
52
|
+
const plural = count === 1 ? noun : `${noun}s`;
|
|
53
|
+
const where = mode === 'session' && scopeLabel ? ` in ${scopeLabel}` : '';
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div
|
|
57
|
+
data-testid="batch-action-bar"
|
|
58
|
+
className="border-t border-ink/15 bg-paper px-2.5 py-2 flex flex-col gap-1.5 flex-shrink-0"
|
|
59
|
+
>
|
|
60
|
+
<div className="flex items-center justify-between gap-2 text-[11px]">
|
|
61
|
+
<span className="min-w-0 truncate text-ink/70">
|
|
62
|
+
<span className="font-semibold text-ink">{count}</span>
|
|
63
|
+
{' '}of {total} {total === 1 ? noun : `${noun}s`} selected{where}
|
|
64
|
+
</span>
|
|
65
|
+
<button
|
|
66
|
+
type="button"
|
|
67
|
+
onClick={onCancel}
|
|
68
|
+
disabled={busy}
|
|
69
|
+
title="Cancel selection"
|
|
70
|
+
aria-label="Cancel selection"
|
|
71
|
+
className="shrink-0 w-5 h-5 flex items-center justify-center rounded text-ink/50 hover:text-ink hover:bg-ink/10 transition-colors cursor-pointer disabled:opacity-50"
|
|
72
|
+
>
|
|
73
|
+
<X size={13} />
|
|
74
|
+
</button>
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
<div className="flex items-center gap-1.5">
|
|
78
|
+
<button
|
|
79
|
+
type="button"
|
|
80
|
+
onClick={onSelectAll}
|
|
81
|
+
disabled={busy || total === 0 || count === total}
|
|
82
|
+
className="flex-1 min-w-0 h-7 flex items-center justify-center gap-1 px-2 rounded border border-ink/20 text-[11px] font-medium text-ink hover:bg-ink/5 transition-colors cursor-pointer disabled:opacity-40"
|
|
83
|
+
>
|
|
84
|
+
<CheckCheck size={12} className="shrink-0" />
|
|
85
|
+
<span className="truncate">Select all</span>
|
|
86
|
+
</button>
|
|
87
|
+
<button
|
|
88
|
+
type="button"
|
|
89
|
+
onClick={onUnselectAll}
|
|
90
|
+
disabled={busy || count === 0}
|
|
91
|
+
className="flex-1 min-w-0 h-7 flex items-center justify-center gap-1 px-2 rounded border border-ink/20 text-[11px] font-medium text-ink hover:bg-ink/5 transition-colors cursor-pointer disabled:opacity-40"
|
|
92
|
+
>
|
|
93
|
+
<span className="truncate">Unselect all</span>
|
|
94
|
+
</button>
|
|
95
|
+
<button
|
|
96
|
+
type="button"
|
|
97
|
+
onClick={onDelete}
|
|
98
|
+
disabled={busy || count === 0}
|
|
99
|
+
className="flex-1 min-w-0 h-7 flex items-center justify-center gap-1 px-2 rounded bg-error text-canvas text-[11px] font-semibold hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-40"
|
|
100
|
+
>
|
|
101
|
+
{busy ? (
|
|
102
|
+
<>
|
|
103
|
+
<Loader2 size={12} className="animate-spin shrink-0" />
|
|
104
|
+
<span className="truncate">Deleting…</span>
|
|
105
|
+
</>
|
|
106
|
+
) : (
|
|
107
|
+
<>
|
|
108
|
+
<Trash2 size={12} className="shrink-0" />
|
|
109
|
+
<span className="truncate">
|
|
110
|
+
Delete {count > 0 ? `${count} ${plural}` : plural}
|
|
111
|
+
</span>
|
|
112
|
+
</>
|
|
113
|
+
)}
|
|
114
|
+
</button>
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Confirmation for a batch delete, for both nouns.
|
|
8
|
+
*
|
|
9
|
+
* It names what actually disappears, because the two modes destroy different
|
|
10
|
+
* things and only one of them is recoverable by re-adding: deleting SESSIONS
|
|
11
|
+
* removes transcripts from disk, while deleting WORKSPACES only unregisters
|
|
12
|
+
* them (the files stay, and the folder can be added back). Saying the same
|
|
13
|
+
* sentence for both would be a lie in one direction or the other.
|
|
14
|
+
*
|
|
15
|
+
* Refusals are listed IN the dialog after the run: the server answers per-id
|
|
16
|
+
* verdicts, and a batch of twenty that removed eighteen must report the two
|
|
17
|
+
* that survived with their reasons — closing on a toast would leave the user
|
|
18
|
+
* with a count they cannot reconcile against the list.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { useEffect } from 'preact/hooks';
|
|
22
|
+
import { AlertTriangle, Loader2, Trash2 } from 'lucide-preact';
|
|
23
|
+
import { Modal } from '@/client/components/common/Modal';
|
|
24
|
+
import type { BatchDeleteOutcome } from '@/client/hooks/workspace/batch-delete';
|
|
25
|
+
import type { BatchMode } from '@/shared/lib/workspace/batch-select';
|
|
26
|
+
|
|
27
|
+
export interface BatchDeleteModalProps {
|
|
28
|
+
mode: BatchMode;
|
|
29
|
+
/** How many rows the user asked to delete; 0 closes the dialog. */
|
|
30
|
+
count: number;
|
|
31
|
+
/** The scope's name, so the sentence names the workspace. */
|
|
32
|
+
scopeLabel?: string;
|
|
33
|
+
isDeleting: boolean;
|
|
34
|
+
/** The settled outcome, once the run finished. Null while running. */
|
|
35
|
+
outcome: BatchDeleteOutcome | null;
|
|
36
|
+
onClose: () => void;
|
|
37
|
+
onConfirm: () => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function BatchDeleteModal({
|
|
41
|
+
mode,
|
|
42
|
+
count,
|
|
43
|
+
scopeLabel,
|
|
44
|
+
isDeleting,
|
|
45
|
+
outcome,
|
|
46
|
+
onClose,
|
|
47
|
+
onConfirm,
|
|
48
|
+
}: BatchDeleteModalProps) {
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
const handler = (event: globalThis.KeyboardEvent) => {
|
|
51
|
+
if (event.key !== 'Escape' || isDeleting) return;
|
|
52
|
+
event.preventDefault();
|
|
53
|
+
onClose();
|
|
54
|
+
};
|
|
55
|
+
window.addEventListener('keydown', handler);
|
|
56
|
+
return () => window.removeEventListener('keydown', handler);
|
|
57
|
+
}, [onClose, isDeleting]);
|
|
58
|
+
|
|
59
|
+
if (count === 0) return null;
|
|
60
|
+
|
|
61
|
+
const sessions = mode === 'session';
|
|
62
|
+
const noun = sessions ? 'session' : 'workspace';
|
|
63
|
+
const plural = count === 1 ? noun : `${noun}s`;
|
|
64
|
+
const where = sessions && scopeLabel ? ` in ${scopeLabel}` : '';
|
|
65
|
+
const finished = !isDeleting && outcome !== null;
|
|
66
|
+
const removed = outcome?.removed.length ?? 0;
|
|
67
|
+
const refused = outcome?.refused ?? [];
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<Modal
|
|
71
|
+
onClose={isDeleting ? () => {} : onClose}
|
|
72
|
+
zClass="z-[70]"
|
|
73
|
+
maxWidthClass="max-w-[460px]"
|
|
74
|
+
header={
|
|
75
|
+
<div className="flex items-center gap-3 min-w-0">
|
|
76
|
+
<Trash2 size={16} className="text-error shrink-0" />
|
|
77
|
+
<div className="min-w-0">
|
|
78
|
+
<h2 className="text-sm font-semibold text-ink">
|
|
79
|
+
Delete {count} {plural}?
|
|
80
|
+
</h2>
|
|
81
|
+
<p className="text-[11px] text-ink/50 truncate">{where ? where.trim() : 'Selected rows'}</p>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
}
|
|
85
|
+
footer={
|
|
86
|
+
<div className="flex items-center justify-end gap-2 px-5 py-3.5 border-t border-ink/10">
|
|
87
|
+
<button
|
|
88
|
+
type="button"
|
|
89
|
+
onClick={onClose}
|
|
90
|
+
disabled={isDeleting}
|
|
91
|
+
className="px-3.5 py-1.5 rounded-md border border-ink/20 text-xs font-medium text-ink hover:bg-ink/5 transition-colors cursor-pointer disabled:opacity-50"
|
|
92
|
+
>
|
|
93
|
+
{finished ? 'Close' : 'Cancel'}
|
|
94
|
+
</button>
|
|
95
|
+
<button
|
|
96
|
+
type="button"
|
|
97
|
+
onClick={onConfirm}
|
|
98
|
+
disabled={isDeleting || finished}
|
|
99
|
+
className="px-3.5 py-1.5 rounded-md bg-error text-canvas text-xs font-medium hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50 flex items-center gap-1.5"
|
|
100
|
+
>
|
|
101
|
+
{isDeleting ? (
|
|
102
|
+
<>
|
|
103
|
+
<Loader2 size={13} className="animate-spin" />
|
|
104
|
+
<span>Deleting…</span>
|
|
105
|
+
</>
|
|
106
|
+
) : (
|
|
107
|
+
<>
|
|
108
|
+
<Trash2 size={13} />
|
|
109
|
+
<span>Delete {count} {plural}</span>
|
|
110
|
+
</>
|
|
111
|
+
)}
|
|
112
|
+
</button>
|
|
113
|
+
</div>
|
|
114
|
+
}
|
|
115
|
+
>
|
|
116
|
+
<div className="px-5 py-4 space-y-3">
|
|
117
|
+
{sessions ? (
|
|
118
|
+
<ul className="text-[11.5px] leading-relaxed text-ink/70 space-y-1.5">
|
|
119
|
+
<li>
|
|
120
|
+
Each transcript is removed from{' '}
|
|
121
|
+
<span className="font-mono text-ink">~/.omp/agent/sessions</span> — the conversations are
|
|
122
|
+
gone from the sidebar and from oh-my-pi.
|
|
123
|
+
</li>
|
|
124
|
+
<li>Their subagent transcripts, side questions, queued messages and saved layout go too.</li>
|
|
125
|
+
<li>A session that is mid-run is refused rather than interrupted; stop it and delete again.</li>
|
|
126
|
+
</ul>
|
|
127
|
+
) : (
|
|
128
|
+
<ul className="text-[11.5px] leading-relaxed text-ink/70 space-y-1.5">
|
|
129
|
+
<li>The workspaces are removed from this sidebar.</li>
|
|
130
|
+
<li>
|
|
131
|
+
Their directories and every transcript on disk are left alone — re-add a workspace with{' '}
|
|
132
|
+
<span className="font-mono text-ink">New Workspace</span> when you want it back.
|
|
133
|
+
</li>
|
|
134
|
+
</ul>
|
|
135
|
+
)}
|
|
136
|
+
|
|
137
|
+
{outcome?.error ? (
|
|
138
|
+
<div className="flex items-start gap-2 px-3 py-2 rounded-md border border-error/30 bg-error/5 text-[11px] text-ink/75">
|
|
139
|
+
<AlertTriangle size={13} className="mt-0.5 shrink-0 text-error" />
|
|
140
|
+
<span>{outcome.error}</span>
|
|
141
|
+
</div>
|
|
142
|
+
) : finished ? (
|
|
143
|
+
<div className="flex items-start gap-2 px-3 py-2 rounded-md border border-ink/15 bg-ink/5 text-[11px] text-ink/75">
|
|
144
|
+
<span>
|
|
145
|
+
Removed {removed} {removed === 1 ? noun : `${noun}s`}
|
|
146
|
+
{refused.length > 0 ? `, ${refused.length} refused:` : '.'}
|
|
147
|
+
</span>
|
|
148
|
+
</div>
|
|
149
|
+
) : (
|
|
150
|
+
<div className="flex items-start gap-2 px-3 py-2 rounded-md border border-error/30 bg-error/5 text-[11px] text-ink/75">
|
|
151
|
+
<AlertTriangle size={13} className="mt-0.5 shrink-0 text-error" />
|
|
152
|
+
<span>
|
|
153
|
+
{sessions
|
|
154
|
+
? 'This cannot be undone from the UI. Archive a session instead to keep its transcript.'
|
|
155
|
+
: 'This removes the sidebar entry only. The folder itself is not deleted.'}
|
|
156
|
+
</span>
|
|
157
|
+
</div>
|
|
158
|
+
)}
|
|
159
|
+
|
|
160
|
+
{refused.length > 0 && (
|
|
161
|
+
<ul className="max-h-40 overflow-y-auto scrollbar-overlay-container space-y-1 text-[11px] text-ink/70">
|
|
162
|
+
{refused.map((entry) => (
|
|
163
|
+
<li key={entry.id} className="flex items-start gap-1.5">
|
|
164
|
+
{/* The whole id, truncated by CSS rather than sliced: a sliced
|
|
165
|
+
`new-…` reads `new-1792`, which names nothing. */}
|
|
166
|
+
<span className="font-mono text-ink/50 shrink-0 max-w-[7rem] truncate" title={entry.id}>
|
|
167
|
+
{entry.id}
|
|
168
|
+
</span>
|
|
169
|
+
<span className="min-w-0">{entry.error}</span>
|
|
170
|
+
</li>
|
|
171
|
+
))}
|
|
172
|
+
</ul>
|
|
173
|
+
)}
|
|
174
|
+
</div>
|
|
175
|
+
</Modal>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The checkbox a sidebar row grows while a batch selection is active.
|
|
8
|
+
*
|
|
9
|
+
* A native `<input type="checkbox">` rather than a styled span: it carries the
|
|
10
|
+
* checked/indeterminate state, the keyboard behaviour and the announcement for
|
|
11
|
+
* free, and `indeterminate` (a workspace whose sessions are partly selected) is
|
|
12
|
+
* a DOM PROPERTY with no attribute equivalent — it has to be assigned, which is
|
|
13
|
+
* what the ref below is for.
|
|
14
|
+
*
|
|
15
|
+
* The click is stopped from propagating: the control sits inside a row that
|
|
16
|
+
* selects or expands on click, so a checkbox that bubbled would do both.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { useEffect, useRef } from 'preact/hooks';
|
|
20
|
+
|
|
21
|
+
export interface SelectionCheckboxProps {
|
|
22
|
+
checked: boolean;
|
|
23
|
+
/** Draw the dash state (a header whose children are partly selected). */
|
|
24
|
+
indeterminate?: boolean;
|
|
25
|
+
/** The accessible name — every row's checkbox names the row it belongs to. */
|
|
26
|
+
label: string;
|
|
27
|
+
onChange: () => void;
|
|
28
|
+
/** `sm` is the row-sized control; `md` is the header's. */
|
|
29
|
+
size?: 'sm' | 'md';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function SelectionCheckbox({
|
|
33
|
+
checked,
|
|
34
|
+
indeterminate = false,
|
|
35
|
+
label,
|
|
36
|
+
onChange,
|
|
37
|
+
size = 'sm',
|
|
38
|
+
}: SelectionCheckboxProps) {
|
|
39
|
+
const ref = useRef<HTMLInputElement>(null);
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (ref.current) ref.current.indeterminate = indeterminate;
|
|
43
|
+
}, [indeterminate]);
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<input
|
|
47
|
+
ref={ref}
|
|
48
|
+
type="checkbox"
|
|
49
|
+
checked={checked}
|
|
50
|
+
aria-label={label}
|
|
51
|
+
onChange={onChange}
|
|
52
|
+
onClick={(event) => event.stopPropagation()}
|
|
53
|
+
className={`shrink-0 accent-ink cursor-pointer rounded border-ink/30 ${
|
|
54
|
+
size === 'md' ? 'w-3.5 h-3.5' : 'w-3 h-3'
|
|
55
|
+
}`}
|
|
56
|
+
/>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The folder-level batch rules, in isolation.
|
|
8
|
+
*
|
|
9
|
+
* This is the arithmetic both sidebars share, and every case below is a way the
|
|
10
|
+
* two could disagree — or a way "select all" could reach something the user
|
|
11
|
+
* cannot see. The pagination case is the one a browser found: a window would
|
|
12
|
+
* tick rows with no checkbox on screen and no way to untick them.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, expect, test } from 'bun:test';
|
|
16
|
+
import { folderBatchState } from '@/client/components/common/batch-selection/folder-state';
|
|
17
|
+
import type { BatchBinding } from '@/client/components/common/batch-selection/types';
|
|
18
|
+
import type { SessionItemData, WorkspaceFolderData } from '@/shared/types';
|
|
19
|
+
|
|
20
|
+
const folder = { id: 7, name: 'kabmo', isExpanded: true, sessions: [], hasMore: false, totalSessions: 0 } as WorkspaceFolderData;
|
|
21
|
+
|
|
22
|
+
function sessions(count: number, archived = 0): SessionItemData[] {
|
|
23
|
+
return Array.from({ length: count }, (_, index) => ({
|
|
24
|
+
id: `s${index}`,
|
|
25
|
+
folder_id: 7,
|
|
26
|
+
title: `session ${index}`,
|
|
27
|
+
is_archived: archived,
|
|
28
|
+
}));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A binding whose selection is `selected` over `mode`. */
|
|
32
|
+
function binding(
|
|
33
|
+
mode: 'session' | 'workspace',
|
|
34
|
+
selected: string[],
|
|
35
|
+
scopeFolderId: number | null = 7,
|
|
36
|
+
): BatchBinding {
|
|
37
|
+
const all = new Set(selected);
|
|
38
|
+
return {
|
|
39
|
+
mode,
|
|
40
|
+
scopeFolderId,
|
|
41
|
+
selected,
|
|
42
|
+
toggle: () => {},
|
|
43
|
+
selectAllOf: () => {},
|
|
44
|
+
unselectAllOf: () => {},
|
|
45
|
+
summaryOf: (available) => {
|
|
46
|
+
const count = available.filter((id) => all.has(id)).length;
|
|
47
|
+
return {
|
|
48
|
+
count,
|
|
49
|
+
all: available.length > 0 && count === available.length,
|
|
50
|
+
some: count > 0 && count < available.length,
|
|
51
|
+
};
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function state(overrides: Partial<Parameters<typeof folderBatchState>[0]> = {}) {
|
|
57
|
+
return folderBatchState({
|
|
58
|
+
folder,
|
|
59
|
+
sessions: sessions(3),
|
|
60
|
+
showArchived: false,
|
|
61
|
+
visibleCount: 1,
|
|
62
|
+
activeSessionId: null,
|
|
63
|
+
...overrides,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe('folderBatchState without a mode', () => {
|
|
68
|
+
test('no checkboxes and the window applies', () => {
|
|
69
|
+
const result = state();
|
|
70
|
+
expect(result.folderCheckbox).toBe(false);
|
|
71
|
+
expect(result.sessionCheckboxes).toBe(false);
|
|
72
|
+
expect(result.folderSummary).toBeNull();
|
|
73
|
+
// `visibleCount` 1 over 3 rows: the normal list paginates.
|
|
74
|
+
expect(result.rendered).toHaveLength(1);
|
|
75
|
+
expect(result.hiddenCount).toBe(2);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('folderBatchState in session mode', () => {
|
|
80
|
+
test('the scoped folder draws a header checkbox and row checkboxes', () => {
|
|
81
|
+
const result = state({ batch: binding('session', []) });
|
|
82
|
+
expect(result.folderCheckbox).toBe(true);
|
|
83
|
+
expect(result.sessionCheckboxes).toBe(true);
|
|
84
|
+
expect(result.selectableIds).toEqual(['s0', 's1', 's2']);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('the window is lifted so every selectable row is on screen', () => {
|
|
88
|
+
const result = state({ batch: binding('session', []) });
|
|
89
|
+
expect(result.rendered).toHaveLength(3);
|
|
90
|
+
expect(result.hiddenCount).toBe(0);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('a workspace that did not start the selection stays ordinary', () => {
|
|
94
|
+
const result = state({ batch: binding('session', [], 99) });
|
|
95
|
+
expect(result.folderCheckbox).toBe(false);
|
|
96
|
+
expect(result.sessionCheckboxes).toBe(false);
|
|
97
|
+
expect(result.rendered).toHaveLength(1);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('the summary reports partial selection against the offered rows', () => {
|
|
101
|
+
const result = state({ batch: binding('session', ['s1']) });
|
|
102
|
+
expect(result.folderSummary).toEqual({ count: 1, all: false, some: true });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('archived rows are not offered while the filter hides them', () => {
|
|
106
|
+
const result = state({
|
|
107
|
+
batch: binding('session', []),
|
|
108
|
+
sessions: [...sessions(2), ...sessions(1, 1)],
|
|
109
|
+
showArchived: false,
|
|
110
|
+
});
|
|
111
|
+
expect(result.selectableIds).toEqual(['s0', 's1']);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('folderBatchState in workspace mode', () => {
|
|
116
|
+
test('the header owns the FOLDER id, and rows stay ordinary', () => {
|
|
117
|
+
const result = state({ batch: binding('workspace', []) });
|
|
118
|
+
expect(result.folderCheckbox).toBe(true);
|
|
119
|
+
expect(result.sessionCheckboxes).toBe(false);
|
|
120
|
+
expect(result.selectableIds).toEqual(['7']);
|
|
121
|
+
// The window is untouched: workspace mode selects folders, not rows.
|
|
122
|
+
expect(result.rendered).toHaveLength(1);
|
|
123
|
+
expect(result.hiddenCount).toBe(2);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('a selected folder reads as all of its own one-id scope', () => {
|
|
127
|
+
const result = state({ batch: binding('workspace', ['7']) });
|
|
128
|
+
expect(result.folderSummary).toEqual({ count: 1, all: true, some: false });
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* One folder's batch state, shared by the desktop category and the phone's
|
|
8
|
+
* drawer item.
|
|
9
|
+
*
|
|
10
|
+
* The two layouts draw the same rules — which checkbox the header carries, what
|
|
11
|
+
* its tri-state reports, which rows are selectable, and how the pagination
|
|
12
|
+
* window behaves — and they had drifted into two copies of that arithmetic.
|
|
13
|
+
* Stating it once is what keeps "select all" counting the same rows on a phone
|
|
14
|
+
* and on a desktop.
|
|
15
|
+
*
|
|
16
|
+
* Three rules are load-bearing:
|
|
17
|
+
*
|
|
18
|
+
* - a workspace checkbox exists in BOTH modes, but means different things: the
|
|
19
|
+
* folder itself in `workspace` mode, its rows in `session` mode;
|
|
20
|
+
* - the summary is computed over the rows the ARCHIVE FILTER offers, so
|
|
21
|
+
* "select all" can never reach a row the user cannot see or unselect;
|
|
22
|
+
* - a session selection lifts the pagination window, because a window would
|
|
23
|
+
* tick rows that have no checkbox on screen.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { selectableSessionIds } from '@/shared/lib/workspace/batch-select';
|
|
27
|
+
import { selectVisibleSessions } from '@/shared/lib/workspace/session-visibility';
|
|
28
|
+
import { headerSelectable, rowsSelectable } from '@/client/components/common/batch-selection/types';
|
|
29
|
+
import type { BatchBinding } from '@/client/components/common/batch-selection/types';
|
|
30
|
+
import type { SessionItemData, WorkspaceFolderData } from '@/shared/types';
|
|
31
|
+
|
|
32
|
+
export interface FolderBatchInput {
|
|
33
|
+
batch?: BatchBinding;
|
|
34
|
+
folder: WorkspaceFolderData;
|
|
35
|
+
/** The rows the archive filter currently offers. */
|
|
36
|
+
sessions: SessionItemData[];
|
|
37
|
+
showArchived: boolean;
|
|
38
|
+
/** The list's page size (`useShowMore`), unused while selecting. */
|
|
39
|
+
visibleCount: number;
|
|
40
|
+
activeSessionId: number | string | null;
|
|
41
|
+
sessionStatus?: Record<string, string | undefined>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface FolderBatchState {
|
|
45
|
+
/** The header draws a checkbox (workspace mode, or this folder's session mode). */
|
|
46
|
+
folderCheckbox: boolean;
|
|
47
|
+
/** The rows draw checkboxes. */
|
|
48
|
+
sessionCheckboxes: boolean;
|
|
49
|
+
/** Null when the header has no checkbox. */
|
|
50
|
+
folderSummary: { count: number; all: boolean; some: boolean } | null;
|
|
51
|
+
/** What the header checkbox selects. */
|
|
52
|
+
selectableIds: string[];
|
|
53
|
+
/** Header click: select or unselect everything this header owns. */
|
|
54
|
+
toggleFolderSelection: () => void;
|
|
55
|
+
/** Rows to render — every offered row while selecting, else the window. */
|
|
56
|
+
rendered: SessionItemData[];
|
|
57
|
+
/** Rows the next "Show more" reveals; 0 while selecting. */
|
|
58
|
+
hiddenCount: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function folderBatchState({
|
|
62
|
+
batch,
|
|
63
|
+
folder,
|
|
64
|
+
sessions,
|
|
65
|
+
showArchived,
|
|
66
|
+
visibleCount,
|
|
67
|
+
activeSessionId,
|
|
68
|
+
sessionStatus,
|
|
69
|
+
}: FolderBatchInput): FolderBatchState {
|
|
70
|
+
const folderCheckbox = batch ? headerSelectable(batch, folder.id) : false;
|
|
71
|
+
const sessionCheckboxes = batch ? rowsSelectable(batch, folder.id) : false;
|
|
72
|
+
const selectableIds = sessionCheckboxes
|
|
73
|
+
? selectableSessionIds(sessions, showArchived)
|
|
74
|
+
: folderCheckbox
|
|
75
|
+
? [String(folder.id)]
|
|
76
|
+
: [];
|
|
77
|
+
const folderSummary = batch && folderCheckbox ? batch.summaryOf(selectableIds) : null;
|
|
78
|
+
|
|
79
|
+
const windowed = selectVisibleSessions(sessions, visibleCount, { activeSessionId, sessionStatus });
|
|
80
|
+
|
|
81
|
+
const toggleFolderSelection = () => {
|
|
82
|
+
if (!batch) return;
|
|
83
|
+
if (folderCheckbox && !sessionCheckboxes) {
|
|
84
|
+
batch.toggle(String(folder.id));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (!sessionCheckboxes) return;
|
|
88
|
+
if (folderSummary?.all) batch.unselectAllOf(selectableIds);
|
|
89
|
+
else batch.selectAllOf(selectableIds);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
folderCheckbox,
|
|
94
|
+
sessionCheckboxes,
|
|
95
|
+
folderSummary,
|
|
96
|
+
selectableIds,
|
|
97
|
+
toggleFolderSelection,
|
|
98
|
+
rendered: sessionCheckboxes ? sessions : windowed.rendered,
|
|
99
|
+
hiddenCount: sessionCheckboxes ? 0 : windowed.hiddenCount,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The props a category/folder hands down to its rows while a batch selection is
|
|
8
|
+
* active, shared by the desktop category and the phone's drawer item.
|
|
9
|
+
*
|
|
10
|
+
* It is one object rather than six props because every field is only meaningful
|
|
11
|
+
* together: a `selected` list without a `mode` is a selection nothing draws, and
|
|
12
|
+
* a `scopeFolderId` without the session mode is a scope nothing applies to. Both
|
|
13
|
+
* layouts read the same shape, so a row cannot grow a checkbox on one layout and
|
|
14
|
+
* not the other.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { BatchMode, SelectionSummary } from '@/shared/lib/workspace/batch-select';
|
|
18
|
+
|
|
19
|
+
export interface BatchBinding {
|
|
20
|
+
/** The active mode, or null when the sidebar is not selecting. */
|
|
21
|
+
mode: BatchMode | null;
|
|
22
|
+
/** The workspace a SESSION selection is scoped to (null in workspace mode). */
|
|
23
|
+
scopeFolderId: number | null;
|
|
24
|
+
/** Ids currently selected, in click order. */
|
|
25
|
+
selected: readonly string[];
|
|
26
|
+
toggle: (id: string) => void;
|
|
27
|
+
selectAllOf: (ids: readonly string[]) => void;
|
|
28
|
+
unselectAllOf: (ids: readonly string[]) => void;
|
|
29
|
+
/** Tri-state summary over a set of ids the folder can offer. */
|
|
30
|
+
summaryOf: (available: readonly string[]) => SelectionSummary;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** True when this folder's own header should draw a checkbox. */
|
|
34
|
+
export function headerSelectable(binding: BatchBinding, folderId: number): boolean {
|
|
35
|
+
return binding.mode === 'workspace'
|
|
36
|
+
|| (binding.mode === 'session' && binding.scopeFolderId === folderId);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** True when this folder's SESSION rows should draw checkboxes. */
|
|
40
|
+
export function rowsSelectable(binding: BatchBinding, folderId: number): boolean {
|
|
41
|
+
return binding.mode === 'session' && binding.scopeFolderId === folderId;
|
|
42
|
+
}
|