@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.
Files changed (127) hide show
  1. package/README.md +16 -11
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/index.js +333 -333
  4. package/dist/client/static/js/index-pdp5dwc8.css +1 -0
  5. package/dist/client/static/js/{index-4vyzz0z4.js → index-zsskckw7.js} +681 -681
  6. package/package.json +2 -1
  7. package/public/kabmo-wordmark.svg +31 -0
  8. package/src/cli/lib/commands/update.js +17 -2
  9. package/src/cli/lib/commands/update.test.js +36 -0
  10. package/src/client/bootstrap.d.ts +8 -0
  11. package/src/client/components/auth/login-screen/index.test.tsx +93 -0
  12. package/src/client/components/auth/login-screen/index.tsx +43 -22
  13. package/src/client/components/auth/login-screen/password-toggle.test.tsx +121 -0
  14. package/src/client/components/common/batch-selection/BatchActionBar.test.ts +132 -0
  15. package/src/client/components/common/batch-selection/BatchActionBar.tsx +118 -0
  16. package/src/client/components/common/batch-selection/BatchDeleteModal.tsx +177 -0
  17. package/src/client/components/common/batch-selection/SelectionCheckbox.tsx +58 -0
  18. package/src/client/components/common/batch-selection/folder-state.test.ts +130 -0
  19. package/src/client/components/common/batch-selection/folder-state.ts +101 -0
  20. package/src/client/components/common/batch-selection/types.ts +42 -0
  21. package/src/client/components/common/brand-wordmark/Mark.tsx +62 -0
  22. package/src/client/components/common/brand-wordmark/index.test.tsx +29 -7
  23. package/src/client/components/common/brand-wordmark/index.tsx +33 -11
  24. package/src/client/components/common/brand-wordmark/wordmark-svg.test.ts +88 -0
  25. package/src/client/components/common/header-controls/MoreMenu.tsx +136 -0
  26. package/src/client/components/common/header-controls/index.test.tsx +77 -11
  27. package/src/client/components/common/header-controls/index.tsx +53 -4
  28. package/src/client/components/common/session-state-provider/index.test.ts +22 -1
  29. package/src/client/components/common/workspace-options-menu/index.tsx +25 -11
  30. package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +169 -0
  31. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +72 -91
  32. package/src/client/components/layout/session-sidebar/Footer.tsx +20 -2
  33. package/src/client/components/layout/session-sidebar/OverflowMenu.tsx +23 -5
  34. package/src/client/components/layout/session-sidebar/SessionItem.batch.test.ts +162 -0
  35. package/src/client/components/layout/session-sidebar/SessionItem.tsx +35 -6
  36. package/src/client/components/layout/session-sidebar/SessionList.tsx +10 -1
  37. package/src/client/components/layout/session-sidebar/Toolbar.test.ts +2 -0
  38. package/src/client/components/layout/session-sidebar/Toolbar.tsx +30 -34
  39. package/src/client/components/layout/session-sidebar/index.tsx +92 -0
  40. package/src/client/components/mobile/LayoutWrapper.tsx +32 -1
  41. package/src/client/components/mobile/SessionSidebar.tsx +83 -0
  42. package/src/client/components/mobile/mobile-main-view/Header.tsx +27 -5
  43. package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +21 -1
  44. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +56 -12
  45. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +8 -0
  46. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +27 -6
  47. package/src/client/components/mobile/mobile-session-sidebar/Toolbar.test.ts +106 -0
  48. package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +178 -93
  49. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +9 -0
  50. package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +6 -0
  51. package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +4 -1
  52. package/src/client/components/workspace/chat-timeline/chat-input/DropOverlay.tsx +1 -1
  53. package/src/client/components/workspace/chat-timeline/chat-input/GoalBanner.tsx +6 -1
  54. package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +4 -1
  55. package/src/client/components/workspace/chat-timeline/chat-input/VoiceNotice.tsx +2 -2
  56. package/src/client/components/workspace/chat-timeline/chat-input/expansion.test.ts +72 -1
  57. package/src/client/components/workspace/chat-timeline/chat-input/expansion.ts +28 -4
  58. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +20 -2
  59. package/src/client/components/workspace/chat-timeline/chat-input/props.ts +7 -0
  60. package/src/client/components/workspace/chat-timeline/index.tsx +9 -3
  61. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks.test.tsx +127 -0
  62. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks.tsx +71 -0
  63. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/PluginPendingInteractions.test.ts +1 -0
  64. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -2
  65. package/src/client/components/workspace/git-panel/BranchToolbar.tsx +4 -0
  66. package/src/client/components/workspace/git-panel/index.test.ts +15 -0
  67. package/src/client/components/workspace/search-panel/Body.test.ts +149 -0
  68. package/src/client/components/workspace/search-panel/Body.tsx +53 -9
  69. package/src/client/hooks/chat/timeline/ask-frames-grouped.test.ts +46 -2
  70. package/src/client/hooks/chat/timeline/ask-frames.test.ts +1 -1
  71. package/src/client/hooks/chat/timeline/ask-frames.ts +79 -29
  72. package/src/client/hooks/chat/timeline/index.ts +2 -1
  73. package/src/client/hooks/chat/timeline/scroll-probe.test-util.ts +227 -0
  74. package/src/client/hooks/chat/timeline/scroll-reading-back.test.ts +67 -0
  75. package/src/client/hooks/chat/timeline/scroll.test.ts +84 -226
  76. package/src/client/hooks/chat/timeline/scroll.ts +34 -1
  77. package/src/client/hooks/ui/media-query.ts +41 -0
  78. package/src/client/hooks/ui/updates/android-download.test.ts +73 -0
  79. package/src/client/hooks/ui/updates/android-download.ts +106 -0
  80. package/src/client/hooks/workspace/batch-delete-run.ts +95 -0
  81. package/src/client/hooks/workspace/batch-delete.ts +63 -0
  82. package/src/client/hooks/workspace/batch-selection.ts +98 -0
  83. package/src/client/hooks/workspace/file-tabs.test.ts +165 -0
  84. package/src/client/hooks/workspace/file-tabs.ts +24 -11
  85. package/src/client/hooks/workspace/session-state/index.ts +24 -1
  86. package/src/client/hooks/workspace/toolbar-row-width.ts +43 -0
  87. package/src/client/lib/native/back.test.ts +108 -0
  88. package/src/client/lib/native/back.ts +60 -0
  89. package/src/client/main.tsx +6 -0
  90. package/src/client/tailwind.css +6 -0
  91. package/src/server/lib/fs/git-actions-branch.ts +18 -1
  92. package/src/server/lib/fs/git-actions-sync.test.ts +144 -0
  93. package/src/server/lib/fs/git-publish.test.ts +96 -0
  94. package/src/server/lib/fs/git-publish.ts +35 -3
  95. package/src/server/lib/fs/listing.test.ts +86 -0
  96. package/src/server/lib/fs/listing.ts +20 -6
  97. package/src/server/lib/lifecycle/restart.ts +52 -14
  98. package/src/server/lib/omp/session/delete-flow.server.ts +101 -0
  99. package/src/server/lib/updates/android-release.test.ts +92 -0
  100. package/src/server/lib/updates/android-release.ts +148 -0
  101. package/src/server/lib/updates/apply.ts +8 -0
  102. package/src/server/lib/updates/install-steps.ts +81 -0
  103. package/src/server/lib/updates/install.ts +19 -67
  104. package/src/server/lib/updates/installed-cli.test.ts +222 -0
  105. package/src/server/lib/updates/installed-cli.ts +139 -0
  106. package/src/server/lib/updates/omp.ts +1 -1
  107. package/src/server/plugins/ssr.ts +9 -2
  108. package/src/server/routes/folders/batch-delete.ts +68 -0
  109. package/src/server/routes/folders/index.ts +4 -0
  110. package/src/server/routes/fs/read.ts +24 -9
  111. package/src/server/routes/sessions/batch-delete.test.ts +142 -0
  112. package/src/server/routes/sessions/batch-delete.ts +60 -0
  113. package/src/server/routes/sessions/delete.ts +13 -51
  114. package/src/server/routes/sessions/index.ts +5 -0
  115. package/src/server/routes/updates/android.ts +22 -0
  116. package/src/server/routes/updates/apply.ts +1 -1
  117. package/src/server/routes/updates/index.ts +2 -0
  118. package/src/shared/lib/updates/shell-ua.test.ts +54 -0
  119. package/src/shared/lib/updates/shell-ua.ts +36 -0
  120. package/src/shared/lib/workspace/batch-select.test.ts +92 -0
  121. package/src/shared/lib/workspace/batch-select.ts +86 -0
  122. package/src/shared/lib/workspace/session-state/listeners.ts +38 -0
  123. package/src/shared/lib/workspace/session-state/replacement.test.ts +121 -0
  124. package/src/shared/lib/workspace/session-state/store.ts +13 -2
  125. package/src/shared/lib/workspace/sidebar-toolbar.test.ts +98 -0
  126. package/src/shared/lib/workspace/sidebar-toolbar.ts +84 -0
  127. 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
- onClick={isEditing ? undefined : onClick}
86
- onContextMenu={hasActions && !isEditing ? menu.openAtCursor : undefined}
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
- isActive
89
- ? 'bg-ink/10 font-medium text-ink'
90
- : 'text-ink/75 hover:text-ink hover:bg-ink/5'
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: any[];
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
  )}
@@ -75,6 +75,8 @@ async function mount() {
75
75
  scheduledCount: 0,
76
76
  onRefresh: () => {},
77
77
  refreshing: false,
78
+ onSelectWorkspaces: () => {},
79
+ selecting: false,
78
80
  }), container);
79
81
  });
80
82
  return { el: container, calls };
@@ -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
- * The row's content width, kept current as the sidebar is dragged.
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 } = useRowWidth();
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;