@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,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
|
+
});
|
|
@@ -3,6 +3,7 @@ import { Check, ChevronDown, ChevronRight, CircleQuestionMark, Loader2, MoreHori
|
|
|
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 SessionItemProps {
|
|
8
9
|
/** The session this row draws; the key its in-flight rename is tracked under. */
|
|
@@ -29,6 +30,11 @@ export interface SessionItemProps {
|
|
|
29
30
|
isExpanded?: boolean;
|
|
30
31
|
hasSubagents?: boolean;
|
|
31
32
|
onToggleExpand?: (e: TargetedMouseEvent<HTMLElement>) => void;
|
|
33
|
+
/** Draw a checkbox and let the row's click toggle the selection instead of
|
|
34
|
+
* opening the session (batch mode). */
|
|
35
|
+
selectable?: boolean;
|
|
36
|
+
selected?: boolean;
|
|
37
|
+
onToggleSelect?: () => void;
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
export function SessionItem({
|
|
@@ -48,6 +54,9 @@ export function SessionItem({
|
|
|
48
54
|
isExpanded = false,
|
|
49
55
|
hasSubagents = false,
|
|
50
56
|
onToggleExpand,
|
|
57
|
+
selectable = false,
|
|
58
|
+
selected = false,
|
|
59
|
+
onToggleSelect,
|
|
51
60
|
}: SessionItemProps) {
|
|
52
61
|
// A rename in flight is drawn in the same 16px slot the run spinner uses, so
|
|
53
62
|
// a row the user just acted on says so where they are already looking. It
|
|
@@ -82,14 +91,32 @@ export function SessionItem({
|
|
|
82
91
|
<div
|
|
83
92
|
data-testid="session-item"
|
|
84
93
|
data-session-id={String(sessionId)}
|
|
85
|
-
|
|
86
|
-
|
|
94
|
+
data-selected={selectable && selected ? 'true' : undefined}
|
|
95
|
+
onClick={isEditing ? undefined : selectable ? onToggleSelect : onClick}
|
|
96
|
+
onContextMenu={hasActions && !isEditing && !selectable ? menu.openAtCursor : undefined}
|
|
87
97
|
className={`group/item relative flex items-center w-full rounded-lg px-2 py-1.5 cursor-pointer text-xs transition-colors select-none ${
|
|
88
|
-
|
|
89
|
-
? 'bg-ink/10
|
|
90
|
-
:
|
|
98
|
+
selectable && selected
|
|
99
|
+
? 'bg-ink/10 text-ink ring-1 ring-inset ring-ink/20'
|
|
100
|
+
: isActive
|
|
101
|
+
? 'bg-ink/10 font-medium text-ink'
|
|
102
|
+
: 'text-ink/75 hover:text-ink hover:bg-ink/5'
|
|
91
103
|
}`}
|
|
92
104
|
>
|
|
105
|
+
{/* The checkbox takes the row's leading slot in batch mode. It replaces
|
|
106
|
+
the status/chevron column rather than sitting beside it: a row in
|
|
107
|
+
this mode is a choice, and a run spinner next to a checkbox reads as
|
|
108
|
+
a second, unrelated control. */}
|
|
109
|
+
{selectable ? (
|
|
110
|
+
<>
|
|
111
|
+
<SelectionCheckbox
|
|
112
|
+
checked={selected}
|
|
113
|
+
label={`Select ${title}`}
|
|
114
|
+
onChange={() => onToggleSelect?.()}
|
|
115
|
+
/>
|
|
116
|
+
<span className="w-2 shrink-0" />
|
|
117
|
+
</>
|
|
118
|
+
) : (
|
|
119
|
+
<>
|
|
93
120
|
{/* One 16px slot owns both icons — never two side by side. A live run
|
|
94
121
|
signal (blocked question, run spinner) holds the slot; otherwise the
|
|
95
122
|
chevron is pinned, collapsed or expanded. While the signal holds it,
|
|
@@ -147,6 +174,8 @@ export function SessionItem({
|
|
|
147
174
|
|
|
148
175
|
{/* Gap between icon and text */}
|
|
149
176
|
<span className="w-2 shrink-0" />
|
|
177
|
+
</>
|
|
178
|
+
)}
|
|
150
179
|
|
|
151
180
|
{/* Session Title - aligned straight with Folder Name */}
|
|
152
181
|
{isEditing ? (
|
|
@@ -181,7 +210,7 @@ export function SessionItem({
|
|
|
181
210
|
`ml-1` on top of the button's own `p-1` is the same 8px the title
|
|
182
211
|
keeps from the time column, so the row's two gaps read as one
|
|
183
212
|
rhythm instead of the glyph crowding the age. */}
|
|
184
|
-
{!isEditing && hasActions && (
|
|
213
|
+
{!isEditing && hasActions && !selectable && (
|
|
185
214
|
<button
|
|
186
215
|
type="button"
|
|
187
216
|
onClick={menu.openBelow}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { Category } from '@/client/components/layout/session-sidebar/CategoryItem';
|
|
2
|
+
import type { BatchBinding } from '@/client/components/common/batch-selection/types';
|
|
3
|
+
import type { WorkspaceFolderData } from '@/shared/types';
|
|
2
4
|
|
|
3
5
|
interface SessionSidebarSessionListProps {
|
|
4
|
-
folders:
|
|
6
|
+
folders: WorkspaceFolderData[];
|
|
5
7
|
activeSessionId: number | string | null;
|
|
6
8
|
searchQuery: string;
|
|
7
9
|
showArchived: boolean;
|
|
@@ -10,6 +12,9 @@ interface SessionSidebarSessionListProps {
|
|
|
10
12
|
onNewSessionForFolder: (id: number) => void;
|
|
11
13
|
/** Surfaces a refused folder action (the reveal endpoint's own reason). */
|
|
12
14
|
onToast?: (message: string, type?: 'success' | 'error') => void;
|
|
15
|
+
/** Present only while a batch selection is active. */
|
|
16
|
+
batch?: BatchBinding;
|
|
17
|
+
onStartSessionSelection?: (folderId: number) => void;
|
|
13
18
|
}
|
|
14
19
|
|
|
15
20
|
export function SessionSidebarSessionList({
|
|
@@ -21,6 +26,8 @@ export function SessionSidebarSessionList({
|
|
|
21
26
|
onSelectSession,
|
|
22
27
|
onNewSessionForFolder,
|
|
23
28
|
onToast,
|
|
29
|
+
batch,
|
|
30
|
+
onStartSessionSelection,
|
|
24
31
|
}: SessionSidebarSessionListProps) {
|
|
25
32
|
// No fade wiring here: the app-wide scrollbar rule reveals this list's bar
|
|
26
33
|
// from the document's own capture-phase listener (`lib/scrollbar-activity`),
|
|
@@ -43,6 +50,8 @@ export function SessionSidebarSessionList({
|
|
|
43
50
|
showArchived={showArchived}
|
|
44
51
|
sessionStatus={sessionStatus}
|
|
45
52
|
onToast={onToast}
|
|
53
|
+
batch={batch}
|
|
54
|
+
onStartSessionSelection={onStartSessionSelection}
|
|
46
55
|
/>
|
|
47
56
|
))
|
|
48
57
|
)}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { Archive, ArrowUpDown, Calendar, FolderPlus, Plus, RefreshCw, Search, X } from 'lucide-preact';
|
|
2
|
-
import { useLayoutEffect, useRef, useState } from 'preact/hooks';
|
|
1
|
+
import { Archive, ArrowUpDown, Calendar, FolderPlus, ListChecks, Plus, RefreshCw, Search, X } from 'lucide-preact';
|
|
3
2
|
import type { SessionSortOption } from '@/shared/types';
|
|
4
3
|
import { SortMenu } from '@/client/components/common/sort-menu';
|
|
5
4
|
import { ToolbarOverflowMenu } from '@/client/components/layout/session-sidebar/OverflowMenu';
|
|
6
5
|
import type { OverflowAction } from '@/client/components/layout/session-sidebar/OverflowMenu';
|
|
6
|
+
import { useToolbarRowWidth } from '@/client/hooks/workspace/toolbar-row-width';
|
|
7
7
|
import {
|
|
8
8
|
SIDEBAR_TOOLBAR_PRIORITY,
|
|
9
9
|
TOOLBAR_ROW_PADDING_PX,
|
|
@@ -32,37 +32,10 @@ interface SessionSidebarToolbarProps {
|
|
|
32
32
|
onRefresh: () => void;
|
|
33
33
|
/** True while the user-initiated refresh is in flight — spins the icon. */
|
|
34
34
|
refreshing: boolean;
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
|
|
39
|
-
*
|
|
40
|
-
* The capacity decision needs the width the row actually has, and the sidebar
|
|
41
|
-
* is resizable — a `ResizeObserver` is what makes the set of visible controls
|
|
42
|
-
* follow a drag instead of being decided once at mount.
|
|
43
|
-
*/
|
|
44
|
-
function useRowWidth() {
|
|
45
|
-
const ref = useRef<HTMLDivElement>(null);
|
|
46
|
-
const [width, setWidth] = useState(0);
|
|
47
|
-
// A layout effect, so the first measure lands BEFORE paint: with a passive
|
|
48
|
-
// effect the row drew every control and then dropped the ones that did not
|
|
49
|
-
// fit on the next frame.
|
|
50
|
-
useLayoutEffect(() => {
|
|
51
|
-
const element = ref.current;
|
|
52
|
-
if (!element) return;
|
|
53
|
-
// `clientWidth` includes the row's own `px-1`, and the capacity budget is
|
|
54
|
-
// a CONTENT width — leaving the padding in made the row 8px optimistic,
|
|
55
|
-
// which is exactly the margin an extra icon needs.
|
|
56
|
-
const measure = () => setWidth(Math.max(0, element.clientWidth - TOOLBAR_ROW_PADDING_PX));
|
|
57
|
-
measure();
|
|
58
|
-
if (typeof ResizeObserver === 'undefined') return;
|
|
59
|
-
// The sidebar is resizable, so the visible set has to follow a drag
|
|
60
|
-
// instead of being decided once at mount.
|
|
61
|
-
const observer = new ResizeObserver(measure);
|
|
62
|
-
observer.observe(element);
|
|
63
|
-
return () => observer.disconnect();
|
|
64
|
-
}, []);
|
|
65
|
-
return { ref, width };
|
|
35
|
+
/** Enter the workspace batch-selection mode (checkbox on each workspace). */
|
|
36
|
+
onSelectWorkspaces: () => void;
|
|
37
|
+
/** True while a batch selection is active — the control reads pressed. */
|
|
38
|
+
selecting: boolean;
|
|
66
39
|
}
|
|
67
40
|
|
|
68
41
|
const ICON_CLASS = 'shrink-0 w-7 h-7 inline-flex items-center justify-center rounded border transition-colors cursor-pointer';
|
|
@@ -88,8 +61,10 @@ export function SessionSidebarToolbar({
|
|
|
88
61
|
scheduledCount,
|
|
89
62
|
onRefresh,
|
|
90
63
|
refreshing,
|
|
64
|
+
onSelectWorkspaces,
|
|
65
|
+
selecting,
|
|
91
66
|
}: SessionSidebarToolbarProps) {
|
|
92
|
-
const { ref: rowRef, width } =
|
|
67
|
+
const { ref: rowRef, width } = useToolbarRowWidth(TOOLBAR_ROW_PADDING_PX);
|
|
93
68
|
const capacity = sidebarToolbarCapacity(width);
|
|
94
69
|
const visible = new Set(SIDEBAR_TOOLBAR_PRIORITY.slice(0, capacity));
|
|
95
70
|
const hidden = SIDEBAR_TOOLBAR_PRIORITY.slice(capacity);
|
|
@@ -111,6 +86,15 @@ export function SessionSidebarToolbar({
|
|
|
111
86
|
if (hidden.includes('archive')) {
|
|
112
87
|
overflowActions.push({ id: 'archive', label: archiveLabel, icon: Archive, onSelect: onToggleArchived, active: showArchived });
|
|
113
88
|
}
|
|
89
|
+
if (hidden.includes('select')) {
|
|
90
|
+
overflowActions.push({
|
|
91
|
+
id: 'select',
|
|
92
|
+
label: selecting ? 'Cancel selection' : 'Select workspaces',
|
|
93
|
+
icon: ListChecks,
|
|
94
|
+
onSelect: onSelectWorkspaces,
|
|
95
|
+
active: selecting,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
114
98
|
if (hidden.includes('scheduler')) {
|
|
115
99
|
overflowActions.push({ id: 'scheduler', label: schedulerLabel, icon: Calendar, onSelect: onScheduler });
|
|
116
100
|
}
|
|
@@ -199,6 +183,18 @@ export function SessionSidebarToolbar({
|
|
|
199
183
|
<Search size={14} />
|
|
200
184
|
</button>
|
|
201
185
|
)}
|
|
186
|
+
{visible.has('select') && (
|
|
187
|
+
<button
|
|
188
|
+
type="button"
|
|
189
|
+
title={selecting ? 'Cancel selection' : 'Select workspaces'}
|
|
190
|
+
aria-label={selecting ? 'Cancel selection' : 'Select workspaces'}
|
|
191
|
+
aria-pressed={selecting}
|
|
192
|
+
onClick={onSelectWorkspaces}
|
|
193
|
+
className={`${ICON_CLASS} ${selecting ? ICON_ACTIVE : ICON_IDLE}`}
|
|
194
|
+
>
|
|
195
|
+
<ListChecks size={14} />
|
|
196
|
+
</button>
|
|
197
|
+
)}
|
|
202
198
|
{visible.has('archive') && (
|
|
203
199
|
<button
|
|
204
200
|
type="button"
|
|
@@ -6,6 +6,7 @@ import { UpdatePopup } from '@/client/components/layout/update-popup';
|
|
|
6
6
|
import { SessionSidebarHeader } from '@/client/components/layout/session-sidebar/Header';
|
|
7
7
|
import { SessionSidebarToolbar } from '@/client/components/layout/session-sidebar/Toolbar';
|
|
8
8
|
import { SessionSidebarFooter } from '@/client/components/layout/session-sidebar/Footer';
|
|
9
|
+
import { useAndroidDownload } from '@/client/hooks/ui/updates/android-download';
|
|
9
10
|
import { SessionSidebarSessionList } from '@/client/components/layout/session-sidebar/SessionList';
|
|
10
11
|
import { SessionListSkeleton } from '@/client/components/layout/session-sidebar/Skeleton';
|
|
11
12
|
import { ToastStack } from '@/client/components/common/ToastStack';
|
|
@@ -18,6 +19,12 @@ import { useSessionSidebarController } from '@/client/hooks/chat/omp/session-sid
|
|
|
18
19
|
import { startPendingSession } from '@/client/hooks/chat/omp/pending-sessions';
|
|
19
20
|
import { useScheduledTaskCount } from '@/client/hooks/workspace/scheduled-tasks';
|
|
20
21
|
import { cloneWorkspaceFolder, createWorkspaceFolder } from '@/client/hooks/workspace/new-workspace';
|
|
22
|
+
import { useBatchSelection } from '@/client/hooks/workspace/batch-selection';
|
|
23
|
+
import { useBatchDeleteRun } from '@/client/hooks/workspace/batch-delete-run';
|
|
24
|
+
import { BatchActionBar } from '@/client/components/common/batch-selection/BatchActionBar';
|
|
25
|
+
import { BatchDeleteModal } from '@/client/components/common/batch-selection/BatchDeleteModal';
|
|
26
|
+
import type { BatchBinding } from '@/client/components/common/batch-selection/types';
|
|
27
|
+
import { liveSelection, selectableSessionIds } from '@/shared/lib/workspace/batch-select';
|
|
21
28
|
import type { AgentStreamStatus } from '@/shared/lib/chat/omp/status';
|
|
22
29
|
|
|
23
30
|
export function SessionSidebar({ className = '', onClose, appSettings = {}, streamStatus }: { className?: string, onClose?: () => void, appSettings?: Record<string, any>, streamStatus?: AgentStreamStatus }) {
|
|
@@ -50,11 +57,51 @@ export function SessionSidebar({ className = '', onClose, appSettings = {}, stre
|
|
|
50
57
|
const [schedulerOpen, setSchedulerOpen] = useState(false);
|
|
51
58
|
const scheduledCount = useScheduledTaskCount();
|
|
52
59
|
const updates = useUpdates();
|
|
60
|
+
// The Android download offer: null in a browser and null when this APK is
|
|
61
|
+
// already the newest published one, so the footer draws nothing.
|
|
62
|
+
const androidDownload = useAndroidDownload(window.__OMP_BOOTSTRAP__?.shellVersion);
|
|
53
63
|
const { toasts, pushToast, dismissToast } = useToasts();
|
|
54
64
|
|
|
55
65
|
// Sidebar inline states
|
|
56
66
|
const [isSearchVisible, setIsSearchVisible] = useState(false);
|
|
57
67
|
|
|
68
|
+
// Batch selection: two separate modes (sessions from a workspace's `⋯` menu,
|
|
69
|
+
// workspaces from the toolbar). `batchDelete.pending` holds the ids the
|
|
70
|
+
// confirmation dialog is about; the bar's Delete opens it and never deletes
|
|
71
|
+
// on its own.
|
|
72
|
+
const batch = useBatchSelection();
|
|
73
|
+
const batchDelete = useBatchDeleteRun(refreshNow);
|
|
74
|
+
|
|
75
|
+
const scopeFolder = batch.scopeFolderId === null
|
|
76
|
+
? null
|
|
77
|
+
: processedFolders.find((folder) => folder.id === batch.scopeFolderId) ?? null;
|
|
78
|
+
const batchAvailable = batch.mode === 'workspace'
|
|
79
|
+
? processedFolders.map((folder) => String(folder.id))
|
|
80
|
+
: batch.mode === 'session'
|
|
81
|
+
? selectableSessionIds(scopeFolder?.sessions ?? [], showArchived)
|
|
82
|
+
: [];
|
|
83
|
+
// The count and the payload are the LIVE selection: an id that vanished from
|
|
84
|
+
// the list (deleted in another tab, filtered away) is not something this bar
|
|
85
|
+
// may still claim, and not something Delete may still send.
|
|
86
|
+
const batchSelected = liveSelection(batch.selected, batchAvailable);
|
|
87
|
+
|
|
88
|
+
const batchBinding: BatchBinding | undefined = batch.mode
|
|
89
|
+
? {
|
|
90
|
+
mode: batch.mode,
|
|
91
|
+
scopeFolderId: batch.scopeFolderId,
|
|
92
|
+
selected: batch.selected,
|
|
93
|
+
toggle: batch.toggle,
|
|
94
|
+
selectAllOf: batch.selectAllOf,
|
|
95
|
+
unselectAllOf: batch.unselectAllOf,
|
|
96
|
+
summaryOf: batch.summaryOf,
|
|
97
|
+
}
|
|
98
|
+
: undefined;
|
|
99
|
+
|
|
100
|
+
const startWorkspaceSelection = () => {
|
|
101
|
+
if (batch.mode === 'workspace') batch.end();
|
|
102
|
+
else batch.begin('workspace');
|
|
103
|
+
};
|
|
104
|
+
|
|
58
105
|
// The popup's "Update now" hands the run over instead of starting its own: the
|
|
59
106
|
// About modal is where an update has always been driven from and it owns the
|
|
60
107
|
// live output, so this opens it and starts the run it will draw. The popup
|
|
@@ -131,6 +178,8 @@ export function SessionSidebar({ className = '', onClose, appSettings = {}, stre
|
|
|
131
178
|
scheduledCount={scheduledCount}
|
|
132
179
|
onRefresh={refreshNow}
|
|
133
180
|
refreshing={isRefreshing}
|
|
181
|
+
onSelectWorkspaces={startWorkspaceSelection}
|
|
182
|
+
selecting={batch.mode !== null}
|
|
134
183
|
/>
|
|
135
184
|
|
|
136
185
|
{initializing ? (
|
|
@@ -145,12 +194,29 @@ export function SessionSidebar({ className = '', onClose, appSettings = {}, stre
|
|
|
145
194
|
onSelectSession={handleSelectSession}
|
|
146
195
|
onNewSessionForFolder={handleNewSessionForFolder}
|
|
147
196
|
onToast={pushToast}
|
|
197
|
+
batch={batchBinding}
|
|
198
|
+
onStartSessionSelection={(folderId) => batch.begin('session', folderId)}
|
|
199
|
+
/>
|
|
200
|
+
)}
|
|
201
|
+
|
|
202
|
+
{batch.mode && (
|
|
203
|
+
<BatchActionBar
|
|
204
|
+
mode={batch.mode}
|
|
205
|
+
count={batchSelected.length}
|
|
206
|
+
total={batchAvailable.length}
|
|
207
|
+
busy={batchDelete.isDeleting}
|
|
208
|
+
scopeLabel={scopeFolder?.name}
|
|
209
|
+
onSelectAll={() => batch.selectAllOf(batchAvailable)}
|
|
210
|
+
onUnselectAll={() => batch.unselectAllOf(batchAvailable)}
|
|
211
|
+
onDelete={() => batchDelete.request(batchSelected)}
|
|
212
|
+
onCancel={batch.end}
|
|
148
213
|
/>
|
|
149
214
|
)}
|
|
150
215
|
|
|
151
216
|
<SessionSidebarFooter
|
|
152
217
|
onSettings={() => setSettingsOpen(true)}
|
|
153
218
|
onInfo={() => setInfoOpen(true)}
|
|
219
|
+
androidDownload={androidDownload}
|
|
154
220
|
updateAvailable={updates.hasUpdate}
|
|
155
221
|
onUpdateClick={() => {
|
|
156
222
|
setInfoOpen(true);
|
|
@@ -174,6 +240,32 @@ export function SessionSidebar({ className = '', onClose, appSettings = {}, stre
|
|
|
174
240
|
Its "Update now" hands the run to the About modal below. */}
|
|
175
241
|
<UpdatePopup updates={updates} />
|
|
176
242
|
|
|
243
|
+
{/* Batch delete confirmation. It stays open on a refusal so the reasons
|
|
244
|
+
can be read; a fully successful run closes itself and leaves the mode. */}
|
|
245
|
+
<BatchDeleteModal
|
|
246
|
+
mode={batch.mode ?? 'session'}
|
|
247
|
+
count={batchDelete.pending.length}
|
|
248
|
+
scopeLabel={scopeFolder?.name}
|
|
249
|
+
isDeleting={batchDelete.isDeleting}
|
|
250
|
+
outcome={batchDelete.outcome}
|
|
251
|
+
onClose={() => {
|
|
252
|
+
// A run that finished cleanly also ends the mode; one that refused
|
|
253
|
+
// ids keeps it, so the rows that survived are still selected for a
|
|
254
|
+
// second attempt.
|
|
255
|
+
const refusedCount = batchDelete.outcome?.refused.length ?? 0;
|
|
256
|
+
batchDelete.cancel();
|
|
257
|
+
if (batchDelete.outcome && refusedCount === 0) batch.end();
|
|
258
|
+
}}
|
|
259
|
+
onConfirm={() => {
|
|
260
|
+
const mode = batch.mode ?? 'session';
|
|
261
|
+
void batchDelete.confirm(mode).then((result) => {
|
|
262
|
+
// A finished run leaves the mode; a refused one keeps it so the
|
|
263
|
+
// remaining rows are still selectable for a second attempt.
|
|
264
|
+
if (result && result.refused.length === 0) batch.end();
|
|
265
|
+
});
|
|
266
|
+
}}
|
|
267
|
+
/>
|
|
268
|
+
|
|
177
269
|
{/* New Workspace Modal */}
|
|
178
270
|
<NewWorkspaceModal
|
|
179
271
|
isOpen={newWorkspaceOpen}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { useCallback, useState } from 'preact/hooks';
|
|
1
|
+
import { useCallback, useEffect, useState } from 'preact/hooks';
|
|
2
2
|
import { useSearchParams } from '@/client/lib/router/search-params';
|
|
3
3
|
import { useKabmoEvent } from '@/client/hooks/ui/window-event';
|
|
4
|
+
import { registerBackHandler } from '@/client/lib/native/back';
|
|
4
5
|
import type { SettingsCategoryId } from '@/shared/types';
|
|
5
6
|
import { MobileMainView } from '@/client/components/mobile/MainView';
|
|
6
7
|
import { MobileSessionSidebar } from '@/client/components/mobile/SessionSidebar';
|
|
@@ -64,6 +65,36 @@ export function MobileLayoutWrapper({ onDesktopToggle, appSettings = {} }: Mobil
|
|
|
64
65
|
);
|
|
65
66
|
const mobileEditorMissing = mobileEditorMissingIds.size > 0;
|
|
66
67
|
|
|
68
|
+
// The Android shell's back button asks this layout first.
|
|
69
|
+
//
|
|
70
|
+
// Order is what the user sees, innermost first: the full-screen editor and
|
|
71
|
+
// diff cover everything, then the settings modal, then the drawer screens,
|
|
72
|
+
// and only then does the shell walk WebView history. Without this, back
|
|
73
|
+
// reopened the previous session while leaving the drawer that opened it on
|
|
74
|
+
// screen — the screens live in React state, not in the URL.
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
return registerBackHandler(() => {
|
|
77
|
+
if (mobileEditorFile) {
|
|
78
|
+
setMobileEditorFile(null);
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
if (mobileDiff) {
|
|
82
|
+
setMobileDiff(null);
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
if (settingsOpen) {
|
|
86
|
+
setSettingsOpen(false);
|
|
87
|
+
setAutoOpenAddProvider(false);
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
if (currentScreen !== 'main') {
|
|
91
|
+
setCurrentScreen('main');
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
return false;
|
|
95
|
+
});
|
|
96
|
+
}, [mobileEditorFile, mobileDiff, settingsOpen, currentScreen]);
|
|
97
|
+
|
|
67
98
|
const { folder: sessionFolder } = activeProjectForSession(folders, sessionId);
|
|
68
99
|
const contextFolder = sessionFolder ?? (folderId ? folders.find(f => String(f.id) === String(folderId)) ?? null : null);
|
|
69
100
|
const activeProjectPath = contextFolder?.project_path ?? null;
|