@kabmo-labs/kabmo 0.4.0 → 0.5.1

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 (31) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +1 -1
  3. package/dist/client/static/js/{index-twsk4wfa.js → index-za950td7.js} +665 -665
  4. package/package.json +1 -1
  5. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +5 -3
  6. package/src/client/components/layout/session-sidebar/Header.tsx +2 -1
  7. package/src/client/components/layout/session-sidebar/SessionItem.tsx +12 -1
  8. package/src/client/components/mobile/LayoutWrapper.tsx +4 -0
  9. package/src/client/components/mobile/RightSidebar.tsx +23 -32
  10. package/src/client/components/mobile/SessionSidebar.tsx +120 -103
  11. package/src/client/components/mobile/mobile-main-view/Header.tsx +3 -2
  12. package/src/client/components/mobile/mobile-right-sidebar/FullDiff.tsx +3 -2
  13. package/src/client/components/mobile/mobile-right-sidebar/Header.tsx +3 -2
  14. package/src/client/components/mobile/mobile-session-sidebar/Dialogs.tsx +106 -0
  15. package/src/client/components/mobile/mobile-session-sidebar/Dock.tsx +80 -0
  16. package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +7 -4
  17. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +6 -1
  18. package/src/client/components/mobile/mobile-session-sidebar/use-dock.ts +89 -0
  19. package/src/client/components/mobile/panel-chips.test.tsx +155 -0
  20. package/src/client/components/mobile/panel-chips.tsx +74 -0
  21. package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +1 -1
  22. package/src/client/components/workspace/chat-timeline/chat-input/compact.test.tsx +12 -4
  23. package/src/client/components/workspace/model-dropdown/index.tsx +3 -3
  24. package/src/client/components/workspace/model-dropdown/trigger.test.ts +123 -0
  25. package/src/client/hooks/chat/timeline/queue-rewind.test.ts +6 -0
  26. package/src/client/hooks/ui/toasts.ts +12 -1
  27. package/src/shared/lib/ui/titlebar.test.ts +124 -0
  28. package/src/shared/lib/ui/titlebar.ts +54 -0
  29. package/src/shared/lib/workspace/mobile-dock.test.ts +65 -0
  30. package/src/shared/lib/workspace/mobile-dock.ts +55 -0
  31. package/src/test-support/silent-socket.ts +69 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kabmo-labs/kabmo",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Web console and diagnostic workbench for AI coding agents — Elysia + Preact on Bun.",
5
5
  "license": "UNLICENSED",
6
6
  "author": "Wijanarko Putra Rajeb <wijanarko.rajeb@gmail.com>",
@@ -7,6 +7,7 @@ import { useInlineRename } from '@/client/hooks/ui/inline-rename';
7
7
  import { useSessionRenaming } from '@/client/hooks/workspace/renaming-sessions';
8
8
  import type { AgentStreamStatus } from '@/shared/lib/chat/omp/status';
9
9
  import { withChord } from '@/client/lib/ui/with-chord';
10
+ import { titlebarPaddingLeft, titlebarPaddingRight } from '@/shared/lib/ui/titlebar';
10
11
 
11
12
  interface TopNavbarProps {
12
13
  sessionTitle: string | null;
@@ -59,10 +60,11 @@ export function TopNavbar({
59
60
  );
60
61
  return (
61
62
  <header
62
- className="h-10 flex-shrink-0 border-b border-ink/10 bg-paper flex items-center justify-between pr-4 z-20 titlebar-drag-region select-none"
63
+ className="h-10 flex-shrink-0 border-b border-ink/10 bg-paper flex items-center justify-between z-20 titlebar-drag-region select-none"
63
64
  style={{
64
- paddingLeft: !showLeftPanel ? 'max(1rem, env(titlebar-area-x, 0px))' : undefined,
65
- paddingRight: 'max(1rem, calc(100vw - env(titlebar-area-width, 100vw)))',
65
+ // The sidebar's own header carries the left inset while it is open.
66
+ paddingLeft: !showLeftPanel ? titlebarPaddingLeft('1rem') : undefined,
67
+ paddingRight: titlebarPaddingRight('1rem'),
66
68
  }}
67
69
  >
68
70
  <div className="flex items-center space-x-2 px-4 min-w-0">
@@ -2,6 +2,7 @@ import { PanelLeftClose } from 'lucide-preact';
2
2
  import { BrandWordmark } from '@/client/components/common/brand-wordmark';
3
3
  import { StreamStatusDot } from '@/client/components/common/StreamStatusDot';
4
4
  import { withChord } from '@/client/lib/ui/with-chord';
5
+ import { titlebarPaddingLeft } from '@/shared/lib/ui/titlebar';
5
6
  import type { AgentStreamStatus } from '@/shared/lib/chat/omp/status';
6
7
 
7
8
  /**
@@ -29,7 +30,7 @@ export function SessionSidebarHeader({
29
30
  return (
30
31
  <div
31
32
  className="h-10 flex-shrink-0 flex items-center justify-between px-4 border-b border-ink/10 titlebar-drag-region select-none"
32
- style={{ paddingLeft: 'max(1rem, env(titlebar-area-x, 0px))' }}
33
+ style={{ paddingLeft: titlebarPaddingLeft('1rem') }}
33
34
  >
34
35
  {/* The stream indicator badges the wordmark's top-right corner, the
35
36
  same placement the phone's header uses — it reads as part of the
@@ -179,7 +179,18 @@ export function SessionItem({
179
179
  <Hourglass size={12} className="animate-pulse text-ink/60" />
180
180
  </span>
181
181
  ) : status === 'stream' ? (
182
- <Loader2 size={12} className="animate-spin" />
182
+ // The run spinner is the ONE thing on this row that says a run
183
+ // is in flight, and it was an unlabelled icon: a screen reader
184
+ // got a bare `svg`, and a test had no accessible way to ask
185
+ // "is this session running". `waiting-for.e2e.ts` needs exactly
186
+ // that as its gate — the wait picker only appears once this
187
+ // session's `stream` row has reached the client, so asserting
188
+ // the picker without asserting the row was a race measured on
189
+ // the Windows runner (the dependency's 20s run expired while the
190
+ // picker's own 20s timeout was still counting).
191
+ <span role="status" aria-label="Running">
192
+ <Loader2 size={12} className="animate-spin" />
193
+ </span>
183
194
  ) : (
184
195
  <Check size={12} />
185
196
  )}
@@ -239,6 +239,10 @@ export function MobileLayoutWrapper({ onDesktopToggle, appSettings = {} }: Mobil
239
239
  onClose={() => setCurrentScreen('main')}
240
240
  onDesktopToggle={onDesktopToggle}
241
241
  appSettings={appSettings}
242
+ rootPath={activeProjectPath ?? undefined}
243
+ refreshKey={refreshKey}
244
+ onRefresh={handleWorkspaceChanged}
245
+ onOpenFile={handleOpenFile}
242
246
  />
243
247
  </div>
244
248
 
@@ -5,9 +5,11 @@ import { PanelHostProvider } from '@kabmo-labs/kabmo-ui';
5
5
  import { useGitStatus } from '@/client/hooks/workspace/git-status';
6
6
  import { useResolvedRepo } from '@/client/hooks/workspace/repo-scope';
7
7
  import { useSessionState } from '@/client/hooks/workspace/session-state';
8
- import { PluginMark } from '@/client/components/settings/categories/panel-plugins/PluginMark';
9
- import { PANEL_MARK_SIZE } from '@/shared/lib/workspace/right-panels';
8
+ import { useLeftDock } from '@/client/hooks/workspace/left-dock';
10
9
  import { SESSIONS_VIEW_ID } from '@/shared/lib/workspace/left-dock';
10
+ import { phoneRightPanels } from '@/shared/lib/workspace/mobile-dock';
11
+ import { PanelChips } from '@/client/components/mobile/panel-chips';
12
+ import { titlebarPaddingLeft, titlebarPaddingRight } from '@/shared/lib/ui/titlebar';
11
13
 
12
14
  interface MobileRightSidebarProps {
13
15
  enabled?: boolean;
@@ -50,8 +52,17 @@ export function MobileRightSidebar({
50
52
  // panel on the left hide it from every phone. The dock's own session list is
51
53
  // filtered out below: the phone already has that as its own drawer screen,
52
54
  // and a second copy inside this one would be two lists of the same sessions.
53
- const catalog = usePanelCatalog('all').filter((panel) => panel.id !== SESSIONS_VIEW_ID);
54
- const activePanel = catalog.find((panel) => panel.id === activeTab);
55
+ //
56
+ // The exception is the one case where the LEFT drawer can now reach them
57
+ // itself: with the rail on, a placed panel gets a chip there, and listing it
58
+ // here as well would put one panel on two strips of one phone.
59
+ const dock = useLeftDock();
60
+ const catalog = phoneRightPanels(usePanelCatalog('all'), SESSIONS_VIEW_ID, dock.enabled);
61
+ // A stored tab can name a panel the user has since placed on the LEFT rail.
62
+ // It is gone from this catalog, and saying "No session selected" over a
63
+ // drawer that has views would be a wrong answer — the first view is drawn
64
+ // instead, and the stored id is replaced on the next click.
65
+ const activePanel = catalog.find((panel) => panel.id === activeTab) ?? catalog[0];
55
66
  // Same source the desktop activity bar uses — the Source Control view's own
56
67
  // repo pick, read shared so switching repos moves this dot too. Polls only
57
68
  // while this drawer is the mounted screen (the poll is visibility-gated).
@@ -84,37 +95,17 @@ export function MobileRightSidebar({
84
95
  style={{
85
96
  height: 'calc(3.5rem + env(safe-area-inset-top, 0px))',
86
97
  paddingTop: 'env(safe-area-inset-top, 0px)',
87
- paddingLeft: 'max(0.75rem, env(safe-area-inset-left, 0px))',
88
- paddingRight: 'max(0.75rem, env(safe-area-inset-right, 0px))',
98
+ paddingLeft: titlebarPaddingLeft('0.75rem', true),
99
+ paddingRight: titlebarPaddingRight('0.75rem', true),
89
100
  }}
90
101
  >
91
102
 
92
- <div className="flex items-center space-x-1.5 overflow-x-auto no-scrollbar py-1">
93
- {catalog.map((panel) => (
94
- <button
95
- key={panel.id}
96
- type="button"
97
- onClick={() => setActiveTab(panel.id)}
98
- className={`relative flex items-center transition-all cursor-pointer ${
99
- activeTab === panel.id
100
- ? 'space-x-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold bg-ink text-canvas shadow-sm'
101
- : 'p-2 rounded-lg text-ink/70 hover:bg-ink/5'
102
- }`}
103
- title={panel.title}
104
- >
105
- {panel.builtin ? panel.icon : <PluginMark name={panel.name} iconUrl={panel.iconUrl} size={PANEL_MARK_SIZE} />}
106
- {activeTab === panel.id && (
107
- <span className={panel.id === 'git' ? 'tracking-wide' : undefined}>{panel.label}</span>
108
- )}
109
- {panel.id === 'git' && hasGitChanges && (
110
- <span
111
- className="absolute top-1 right-1 w-1.5 h-1.5 rounded-full bg-info"
112
- title="Uncommitted changes"
113
- />
114
- )}
115
- </button>
116
- ))}
117
- </div>
103
+ <PanelChips
104
+ panels={catalog}
105
+ activeId={activePanel?.id ?? activeTab}
106
+ onSelect={setActiveTab}
107
+ hasGitChanges={hasGitChanges}
108
+ />
118
109
 
119
110
  <button
120
111
  type="button"
@@ -3,11 +3,7 @@ import { MobileSessionHeader } from '@/client/components/mobile/mobile-session-s
3
3
  import { MobileSessionToolbar } from '@/client/components/mobile/mobile-session-sidebar/Toolbar';
4
4
  import { MobileSessionList } from '@/client/components/mobile/mobile-session-sidebar/List';
5
5
  import { MobileSessionFooter } from '@/client/components/mobile/mobile-session-sidebar/Footer';
6
- import { ToastStack } from '@/client/components/common/ToastStack';
7
- import { AboutModal, NewWorkspaceModal } from '@/client/components/layout/session-sidebar/Modals';
8
6
  import { useAndroidDownload } from '@/client/hooks/ui/updates/android-download';
9
- import { SchedulerModal } from '@/client/components/layout/session-sidebar/scheduler-modal';
10
- import { UpdatePopup } from '@/client/components/layout/update-popup';
11
7
  import { useOnClickOutside } from '@/client/hooks/ui/on-click-outside';
12
8
  import { useKabmoEvent } from '@/client/hooks/ui/window-event';
13
9
  import { useToasts } from '@/client/hooks/ui/toasts';
@@ -17,12 +13,14 @@ import { useUpdates } from '@/client/hooks/ui/updates';
17
13
  import { UPDATE_REQUEST_EVENT } from '@/shared/lib/updates/popup-state';
18
14
  import { useSessionSidebarController } from '@/client/hooks/chat/omp/session-sidebar-controller';
19
15
  import { useScheduledTaskCount } from '@/client/hooks/workspace/scheduled-tasks';
20
- import { cloneWorkspaceFolder, createWorkspaceFolder } from '@/client/hooks/workspace/new-workspace';
21
16
  import { MobileSessionListSkeleton } from '@/client/components/mobile/mobile-session-sidebar/Skeleton';
22
17
  import { useBatchSelection } from '@/client/hooks/workspace/batch-selection';
23
18
  import { useBatchDeleteRun } from '@/client/hooks/workspace/batch-delete-run';
19
+ import { phoneLeftStripVisible } from '@/shared/lib/workspace/mobile-dock';
20
+ import { dockBodyProps, useMobileDock } from '@/client/components/mobile/mobile-session-sidebar/use-dock';
21
+ import { MobileDockBody, MobileDockStrip } from '@/client/components/mobile/mobile-session-sidebar/Dock';
22
+ import { MobileDrawerDialogs } from '@/client/components/mobile/mobile-session-sidebar/Dialogs';
24
23
  import { BatchActionBar } from '@/client/components/common/batch-selection/BatchActionBar';
25
- import { BatchDeleteModal } from '@/client/components/common/batch-selection/BatchDeleteModal';
26
24
  import type { BatchBinding } from '@/client/components/common/batch-selection/types';
27
25
  import { liveSelection, selectableSessionIds } from '@/shared/lib/workspace/batch-select';
28
26
 
@@ -34,6 +32,12 @@ interface MobileSessionSidebarProps {
34
32
  onClose: () => void;
35
33
  onDesktopToggle?: () => void;
36
34
  appSettings?: Record<string, any>;
35
+ /** The active workspace's root, for a plugin panel placed on the dock. */
36
+ rootPath?: string;
37
+ /** Bumped after a write so a placed panel re-reads, like the right drawer. */
38
+ refreshKey?: number;
39
+ onRefresh?: () => void;
40
+ onOpenFile?: (file: unknown) => void;
37
41
  }
38
42
 
39
43
  export function MobileSessionSidebar({
@@ -43,7 +47,11 @@ export function MobileSessionSidebar({
43
47
  onNewSessionForFolder,
44
48
  onClose,
45
49
  onDesktopToggle,
46
- appSettings = {}
50
+ appSettings = {},
51
+ rootPath,
52
+ refreshKey = 0,
53
+ onRefresh,
54
+ onOpenFile,
47
55
  }: MobileSessionSidebarProps) {
48
56
  const {
49
57
  folders,
@@ -190,66 +198,103 @@ export function MobileSessionSidebar({
190
198
  else batch.begin('workspace');
191
199
  };
192
200
 
201
+ const dock = useMobileDock();
202
+ const showingSessions = dock.showingSessions;
203
+ const activeView = dock.activeView;
204
+ const dockBody = dockBodyProps({
205
+ sessionId: activeSessionId,
206
+ refreshKey,
207
+ rootPath,
208
+ onRefresh,
209
+ onOpenFile,
210
+ onClose,
211
+ });
212
+
193
213
  return (
194
214
  <div className="flex flex-col h-full w-full bg-canvas text-ink relative select-none">
195
-
196
- <MobileSessionHeader onClose={onClose} />
197
215
 
198
- <MobileSessionToolbar
199
- searchQuery={searchQuery}
200
- sortOption={sortOption}
201
- optionsOpen={optionsOpen}
202
- showArchived={showArchived}
203
- optionsRef={optionsRef}
204
- onNewSession={handleNewSessionAndClose}
205
- onNewWorkspace={() => setNewWorkspaceOpen(true)}
206
- onScheduler={() => setSchedulerOpen(true)}
207
- scheduledCount={scheduledCount}
208
- onRefresh={refreshNow}
209
- refreshing={isRefreshing}
210
- onToggleOptions={() => setOptionsOpen(!optionsOpen)}
211
- onSortChange={handleSortChange}
212
- onResetSort={() => handleSortChange('A-Z')}
213
- onToggleArchived={handleToggleArchived}
214
- onHideArchived={() => setShowArchived(false)}
215
- onSearchChange={setSearchQuery}
216
- onClearSearch={() => setSearchQuery('')}
217
- onSelectWorkspaces={startWorkspaceSelection}
218
- selecting={batch.mode !== null}
216
+ <MobileSessionHeader
217
+ onClose={onClose}
218
+ viewLabel={showingSessions ? 'Sessions' : dock.activePanel?.label ?? 'Panel'}
219
219
  />
220
220
 
221
- {initializing ? (
222
- <MobileSessionListSkeleton />
223
- ) : (
224
- <MobileSessionList
225
- folders={processedFolders}
226
- activeSessionId={activeSessionId}
227
- expandedFolders={expandedFolders}
228
- showArchived={showArchived}
229
- sessionStatus={sessionStatus}
230
- onSelectSession={handleSelectSession}
231
- onNewSessionForFolder={handleNewSessionForFolderAndClose}
232
- onToggleFolder={toggleFolder}
233
- onToast={pushToast}
234
- batch={batchBinding}
235
- onStartSessionSelection={(folderId) => batch.begin('session', folderId)}
236
- onCancelWait={cancelWait}
237
- />
238
- )}
221
+ {/* The strip appears only once there is something to switch TO: with the
222
+ rail off, or with nothing placed on it, a row holding one chip would
223
+ be chrome that never changes. The exception is a stored view that no
224
+ longer resolves — the body is a notice, and without the strip the
225
+ session list would be unreachable (the desktop has the same escape in
226
+ its rail, which always holds Sessions). */}
227
+ {phoneLeftStripVisible(dock.enabled, dock.placed.length, showingSessions) ? (
228
+ <MobileDockStrip panels={dock.catalog} activeId={activeView} onSelect={dock.setActiveView} />
229
+ ) : null}
230
+
231
+ {/* The list is HIDDEN, never unmounted, while a panel is on screen — the
232
+ same rule the desktop dock follows (`LeftDockView`): its folder
233
+ expansion, scroll position and batch selection must survive a look at
234
+ another view, and a re-mount would also re-seed every expansion from
235
+ the server's flags. */}
236
+ <div className={showingSessions ? 'contents' : 'hidden'}>
237
+ <>
238
+ <MobileSessionToolbar
239
+ searchQuery={searchQuery}
240
+ sortOption={sortOption}
241
+ optionsOpen={optionsOpen}
242
+ showArchived={showArchived}
243
+ optionsRef={optionsRef}
244
+ onNewSession={handleNewSessionAndClose}
245
+ onNewWorkspace={() => setNewWorkspaceOpen(true)}
246
+ onScheduler={() => setSchedulerOpen(true)}
247
+ scheduledCount={scheduledCount}
248
+ onRefresh={refreshNow}
249
+ refreshing={isRefreshing}
250
+ onToggleOptions={() => setOptionsOpen(!optionsOpen)}
251
+ onSortChange={handleSortChange}
252
+ onResetSort={() => handleSortChange('A-Z')}
253
+ onToggleArchived={handleToggleArchived}
254
+ onHideArchived={() => setShowArchived(false)}
255
+ onSearchChange={setSearchQuery}
256
+ onClearSearch={() => setSearchQuery('')}
257
+ onSelectWorkspaces={startWorkspaceSelection}
258
+ selecting={batch.mode !== null}
259
+ />
239
260
 
240
- {batch.mode && (
241
- <BatchActionBar
242
- drawer
243
- mode={batch.mode}
244
- count={batchSelected.length}
245
- total={batchAvailable.length}
246
- busy={batchDelete.isDeleting}
247
- scopeLabel={scopeFolder?.name}
248
- onSelectAll={() => batch.selectAllOf(batchAvailable)}
249
- onUnselectAll={() => batch.unselectAllOf(batchAvailable)}
250
- onDelete={() => batchDelete.request(batchSelected)}
251
- onCancel={batch.end}
252
- />
261
+ {initializing ? (
262
+ <MobileSessionListSkeleton />
263
+ ) : (
264
+ <MobileSessionList
265
+ folders={processedFolders}
266
+ activeSessionId={activeSessionId}
267
+ expandedFolders={expandedFolders}
268
+ showArchived={showArchived}
269
+ sessionStatus={sessionStatus}
270
+ onSelectSession={handleSelectSession}
271
+ onNewSessionForFolder={handleNewSessionForFolderAndClose}
272
+ onToggleFolder={toggleFolder}
273
+ onToast={pushToast}
274
+ batch={batchBinding}
275
+ onStartSessionSelection={(folderId) => batch.begin('session', folderId)}
276
+ onCancelWait={cancelWait}
277
+ />
278
+ )}
279
+
280
+ {batch.mode && (
281
+ <BatchActionBar
282
+ drawer
283
+ mode={batch.mode}
284
+ count={batchSelected.length}
285
+ total={batchAvailable.length}
286
+ busy={batchDelete.isDeleting}
287
+ scopeLabel={scopeFolder?.name}
288
+ onSelectAll={() => batch.selectAllOf(batchAvailable)}
289
+ onUnselectAll={() => batch.unselectAllOf(batchAvailable)}
290
+ onDelete={() => batchDelete.request(batchSelected)}
291
+ onCancel={batch.end}
292
+ />
293
+ )}
294
+ </>
295
+ </div>
296
+ {showingSessions ? null : (
297
+ <MobileDockBody panelKey={activeView} panel={dock.activePanel} body={dockBody} />
253
298
  )}
254
299
 
255
300
  <MobileSessionFooter
@@ -264,52 +309,24 @@ export function MobileSessionSidebar({
264
309
  }}
265
310
  />
266
311
 
267
- {/* Reusable Modals */}
268
- <NewWorkspaceModal
269
- isOpen={newWorkspaceOpen}
270
- onClose={() => setNewWorkspaceOpen(false)}
271
- onCreate={createWorkspaceFolder}
272
- onClone={cloneWorkspaceFolder}
273
- />
274
- <SchedulerModal
275
- isOpen={schedulerOpen}
276
- onClose={() => setSchedulerOpen(false)}
277
- activeSessionId={activeSessionId === null ? null : String(activeSessionId)}
278
- onToast={pushToast}
279
- />
280
- <AboutModal
281
- isOpen={aboutOpen}
282
- onClose={() => setAboutOpen(false)}
312
+ <MobileDrawerDialogs
313
+ activeSessionId={activeSessionId}
314
+ newWorkspaceOpen={newWorkspaceOpen}
315
+ onCloseNewWorkspace={() => setNewWorkspaceOpen(false)}
316
+ schedulerOpen={schedulerOpen}
317
+ onCloseScheduler={() => setSchedulerOpen(false)}
318
+ aboutOpen={aboutOpen}
319
+ onCloseAbout={() => setAboutOpen(false)}
283
320
  updates={updates}
284
- onToast={pushToast}
285
- />
286
-
287
- {/* "Update available" announcement — once per version, gated on the check.
288
- Its "Update now" hands the run to the About modal below. */}
289
- <UpdatePopup updates={updates} />
290
-
291
- {/* Batch delete confirmation — same dialog the desktop draws. */}
292
- <BatchDeleteModal
293
- mode={batch.mode ?? 'session'}
294
- count={batchDelete.pending.length}
321
+ pushToast={pushToast}
322
+ batchDelete={batchDelete}
323
+ batchMode={batch.mode}
324
+ onEndBatch={batch.end}
295
325
  scopeLabel={scopeFolder?.name}
296
- isDeleting={batchDelete.isDeleting}
297
- outcome={batchDelete.outcome}
298
- onClose={() => {
299
- const refusedCount = batchDelete.outcome?.refused.length ?? 0;
300
- batchDelete.cancel();
301
- if (batchDelete.outcome && refusedCount === 0) batch.end();
302
- }}
303
- onConfirm={() => {
304
- const mode = batch.mode ?? 'session';
305
- void batchDelete.confirm(mode).then((result) => {
306
- if (result && result.refused.length === 0) batch.end();
307
- });
308
- }}
326
+ toasts={toasts}
327
+ dismissToast={dismissToast}
309
328
  />
310
329
 
311
- <ToastStack toasts={toasts} onDismiss={dismissToast} />
312
-
313
330
  </div>
314
331
  );
315
332
  }
@@ -8,6 +8,7 @@ import { useSessionRenaming } from '@/client/hooks/workspace/renaming-sessions';
8
8
  import { StreamStatusDot } from '@/client/components/common/StreamStatusDot';
9
9
  import { BrandWordmark } from '@/client/components/common/brand-wordmark';
10
10
  import { HeaderControls } from '@/client/components/common/header-controls';
11
+ import { titlebarPaddingLeft, titlebarPaddingRight } from '@/shared/lib/ui/titlebar';
11
12
 
12
13
  interface MobileHeaderProps {
13
14
  activeSessionTitle: string;
@@ -91,8 +92,8 @@ export function MobileHeader({
91
92
  className="flex-shrink-0 bg-canvas border-b border-ink/10 flex items-center justify-between gap-2 px-3 z-20 titlebar-drag-region select-none"
92
93
  style={{
93
94
  height: 'calc(3.5rem + env(safe-area-inset-top, 0px))',
94
- paddingLeft: 'max(0.75rem, env(safe-area-inset-left, 0px), env(titlebar-area-x, 0px))',
95
- paddingRight: 'max(0.75rem, env(safe-area-inset-right, 0px), calc(100vw - env(titlebar-area-width, 100vw)))',
95
+ paddingLeft: titlebarPaddingLeft('0.75rem', true),
96
+ paddingRight: titlebarPaddingRight('0.75rem', true),
96
97
  paddingTop: 'env(safe-area-inset-top, 0px)',
97
98
  }}
98
99
  >
@@ -3,6 +3,7 @@ import { FileIcon } from '@/client/components/common/file-icon';
3
3
  import { DiffPanel } from '@/client/components/workspace/diff-panel';
4
4
  import { getGitStatusInfo } from '@/shared/lib/fs/git-status';
5
5
  import { diffTabId } from '@/shared/lib/workspace/file-tab-id';
6
+ import { titlebarPaddingLeft, titlebarPaddingRight } from '@/shared/lib/ui/titlebar';
6
7
 
7
8
  interface MobileFullDiffProps {
8
9
  diff: {
@@ -37,8 +38,8 @@ export function MobileFullDiff({ diff, onClose, onFileSaved }: MobileFullDiffPro
37
38
  style={{
38
39
  height: 'calc(3rem + env(safe-area-inset-top, 0px))',
39
40
  paddingTop: 'env(safe-area-inset-top, 0px)',
40
- paddingLeft: 'max(0.75rem, env(safe-area-inset-left, 0px))',
41
- paddingRight: 'max(0.75rem, env(safe-area-inset-right, 0px))',
41
+ paddingLeft: titlebarPaddingLeft('0.75rem', true),
42
+ paddingRight: titlebarPaddingRight('0.75rem', true),
42
43
  }}
43
44
  >
44
45
  <div className="flex items-center space-x-2 min-w-0 pr-2">
@@ -1,5 +1,6 @@
1
1
  import { ArrowLeft, Check, Copy, Download, Eye, EyeOff, Save, WrapText, ZoomIn, ZoomOut } from 'lucide-preact';
2
2
  import { FileIcon } from '@/client/components/common/file-icon';
3
+ import { titlebarPaddingLeft, titlebarPaddingRight } from '@/shared/lib/ui/titlebar';
3
4
 
4
5
  interface EditorHeaderProps {
5
6
  fileName: string;
@@ -47,8 +48,8 @@ export function EditorHeader({
47
48
  style={{
48
49
  height: 'calc(3rem + env(safe-area-inset-top, 0px))',
49
50
  paddingTop: 'env(safe-area-inset-top, 0px)',
50
- paddingLeft: 'max(0.75rem, env(safe-area-inset-left, 0px))',
51
- paddingRight: 'max(0.75rem, env(safe-area-inset-right, 0px))',
51
+ paddingLeft: titlebarPaddingLeft('0.75rem', true),
52
+ paddingRight: titlebarPaddingRight('0.75rem', true),
52
53
  }}
53
54
  >
54
55
  <div className="flex items-center space-x-2 min-w-0 pr-2">
@@ -0,0 +1,106 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The phone session drawer's dialogs.
8
+ *
9
+ * Split out of `SessionSidebar` for the file ceiling, and it is a clean seam:
10
+ * every one of these is app-level (a workspace, a schedule, the console's own
11
+ * version and update, a batch delete confirmation, the toast stack) and none of
12
+ * them reads the drawer's own view state. Keeping them here also means the
13
+ * drawer's body — session list or a placed panel — stays readable as one
14
+ * expression rather than being buried between modals.
15
+ */
16
+
17
+ import { ToastStack } from '@/client/components/common/ToastStack';
18
+ import { AboutModal, NewWorkspaceModal } from '@/client/components/layout/session-sidebar/Modals';
19
+ import { SchedulerModal } from '@/client/components/layout/session-sidebar/scheduler-modal';
20
+ import { UpdatePopup } from '@/client/components/layout/update-popup';
21
+ import { BatchDeleteModal } from '@/client/components/common/batch-selection/BatchDeleteModal';
22
+ import { createWorkspaceFolder, cloneWorkspaceFolder } from '@/client/hooks/workspace/new-workspace';
23
+ import type { BatchDeleteRun } from '@/client/hooks/workspace/batch-delete-run';
24
+ import type { UseUpdatesResult } from '@/client/hooks/ui/updates';
25
+ import type { ToastsHandle } from '@/client/hooks/ui/toasts';
26
+
27
+ interface MobileDrawerDialogsProps {
28
+ activeSessionId: number | string | null;
29
+ newWorkspaceOpen: boolean;
30
+ onCloseNewWorkspace: () => void;
31
+ schedulerOpen: boolean;
32
+ onCloseScheduler: () => void;
33
+ aboutOpen: boolean;
34
+ onCloseAbout: () => void;
35
+ updates: UseUpdatesResult;
36
+ pushToast: ToastsHandle['pushToast'];
37
+ batchDelete: BatchDeleteRun;
38
+ /** The batch mode the confirmation names, and the way out of it. */
39
+ batchMode: 'workspace' | 'session' | null;
40
+ onEndBatch: () => void;
41
+ scopeLabel?: string;
42
+ toasts: ToastsHandle['toasts'];
43
+ dismissToast: ToastsHandle['dismissToast'];
44
+ }
45
+
46
+ export function MobileDrawerDialogs({
47
+ activeSessionId,
48
+ newWorkspaceOpen,
49
+ onCloseNewWorkspace,
50
+ schedulerOpen,
51
+ onCloseScheduler,
52
+ aboutOpen,
53
+ onCloseAbout,
54
+ updates,
55
+ pushToast,
56
+ batchDelete,
57
+ batchMode,
58
+ onEndBatch,
59
+ scopeLabel,
60
+ toasts,
61
+ dismissToast,
62
+ }: MobileDrawerDialogsProps) {
63
+ return (
64
+ <>
65
+ <NewWorkspaceModal
66
+ isOpen={newWorkspaceOpen}
67
+ onClose={onCloseNewWorkspace}
68
+ onCreate={createWorkspaceFolder}
69
+ onClone={cloneWorkspaceFolder}
70
+ />
71
+ <SchedulerModal
72
+ isOpen={schedulerOpen}
73
+ onClose={onCloseScheduler}
74
+ activeSessionId={activeSessionId === null ? null : String(activeSessionId)}
75
+ onToast={pushToast}
76
+ />
77
+ <AboutModal isOpen={aboutOpen} onClose={onCloseAbout} updates={updates} onToast={pushToast} />
78
+
79
+ {/* "Update available" announcement — once per version, gated on the check.
80
+ Its "Update now" hands the run to the About modal. */}
81
+ <UpdatePopup updates={updates} />
82
+
83
+ {/* Batch delete confirmation — same dialog the desktop draws. */}
84
+ <BatchDeleteModal
85
+ mode={batchMode ?? 'session'}
86
+ count={batchDelete.pending.length}
87
+ scopeLabel={scopeLabel}
88
+ isDeleting={batchDelete.isDeleting}
89
+ outcome={batchDelete.outcome}
90
+ onClose={() => {
91
+ const refusedCount = batchDelete.outcome?.refused.length ?? 0;
92
+ batchDelete.cancel();
93
+ if (batchDelete.outcome && refusedCount === 0) onEndBatch();
94
+ }}
95
+ onConfirm={() => {
96
+ const mode = batchMode ?? 'session';
97
+ void batchDelete.confirm(mode).then((result) => {
98
+ if (result && result.refused.length === 0) onEndBatch();
99
+ });
100
+ }}
101
+ />
102
+
103
+ <ToastStack toasts={toasts} onDismiss={dismissToast} />
104
+ </>
105
+ );
106
+ }