@kabmo-labs/kabmo 0.7.0 → 0.8.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/dist/client/index.html +1 -1
- package/dist/client/index.js +403 -384
- package/dist/client/static/js/index-gtbpewr7.css +1 -0
- package/dist/client/static/js/{index-0wvbca0b.js → index-qwpvd9fv.js} +779 -779
- package/package.json +2 -1
- package/src/client/components/common/header-controls/index.tsx +24 -1
- package/src/client/components/common/header-controls/shell-chrome.test.tsx +272 -0
- package/src/client/components/common/workspace-options-menu/index.tsx +19 -1
- package/src/client/components/layout/desktop-layout/TopNavbar.tsx +5 -6
- package/src/client/components/layout/desktop-layout/index.tsx +8 -2
- package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +24 -0
- package/src/client/components/layout/session-sidebar/CategoryHeader.worktree.test.tsx +126 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +4 -0
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +12 -0
- package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
- package/src/client/components/layout/session-sidebar/SessionsView.tsx +24 -0
- package/src/client/components/layout/session-sidebar/WorktreeBadge.test.ts +48 -0
- package/src/client/components/layout/session-sidebar/WorktreeBadge.tsx +55 -0
- package/src/client/components/mobile/LayoutWrapper.tsx +22 -16
- package/src/client/components/mobile/RightSidebar.tsx +21 -10
- package/src/client/components/mobile/SessionSidebar.tsx +8 -0
- package/src/client/components/mobile/header-titlebar.test.tsx +172 -0
- package/src/client/components/mobile/mobile-main-view/Header.tsx +10 -1
- package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +6 -2
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +16 -0
- package/src/client/components/mobile/mobile-session-sidebar/List.tsx +4 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +5 -0
- package/src/client/components/mobile/mobile-session-sidebar/WorktreeDialog.tsx +49 -0
- package/src/client/components/mobile/worktree-session.ts +51 -0
- package/src/client/components/settings/categories/ProjectSettings.tsx +22 -8
- package/src/client/components/settings/categories/project-settings/WorktreeSettings.tsx +140 -0
- package/src/client/components/settings/categories/project-settings/worktree-row.test.ts +83 -0
- package/src/client/components/settings/categories/project-settings/worktree-row.ts +47 -0
- package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +29 -56
- package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +8 -0
- package/src/client/components/workspace/chat-timeline/index.tsx +5 -0
- package/src/client/components/workspace/chat-timeline/target-picker/index.test.tsx +227 -0
- package/src/client/components/workspace/chat-timeline/target-picker/index.tsx +260 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.test.tsx +146 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.tsx +24 -6
- package/src/client/components/workspace/file-explorer/ContextMenu.tsx +2 -1
- package/src/client/components/workspace/file-explorer/Modals.tsx +40 -12
- package/src/client/components/workspace/file-explorer/RootMenu.tsx +14 -5
- package/src/client/components/workspace/file-explorer/Toolbar.tsx +74 -0
- package/src/client/components/workspace/file-explorer/TreeItem.tsx +38 -3
- package/src/client/components/workspace/file-explorer/filter.ts +47 -0
- package/src/client/components/workspace/file-explorer/index.tsx +77 -64
- package/src/client/components/workspace/file-explorer/local-edit.test.ts +135 -0
- package/src/client/components/workspace/worktree-modal/index.tsx +261 -0
- package/src/client/hooks/chat/timeline/index.ts +9 -5
- package/src/client/hooks/chat/timeline/send-execute.test.ts +2 -0
- package/src/client/hooks/chat/timeline/send-steer.test.ts +2 -0
- package/src/client/hooks/chat/timeline/send.test.ts +2 -0
- package/src/client/hooks/chat/timeline/send.ts +11 -1
- package/src/client/hooks/chat/timeline/spawn-target.ts +52 -0
- package/src/client/hooks/chat/timeline/tool-open.test.ts +74 -0
- package/src/client/hooks/chat/timeline/tool-open.ts +52 -8
- package/src/client/hooks/ui/long-press.test.ts +174 -0
- package/src/client/hooks/ui/long-press.ts +112 -0
- package/src/client/hooks/ui/updates/shell-download.test.ts +73 -0
- package/src/client/hooks/ui/updates/shell-download.ts +112 -28
- package/src/client/hooks/ui/updates/shell-offer.test.ts +187 -0
- package/src/client/hooks/workspace/file-create.ts +41 -11
- package/src/client/hooks/workspace/file-listing.ts +56 -1
- package/src/client/hooks/workspace/file-tree-actions.ts +37 -5
- package/src/client/hooks/workspace/worktree/index.ts +150 -0
- package/src/client/tailwind.css +19 -0
- package/src/server/index.ts +7 -2
- package/src/server/lib/bundler/titlebar-css.test.ts +42 -1
- package/src/server/lib/db/schema.ts +4 -0
- package/src/server/lib/db/worktree-registry.server.ts +91 -0
- package/src/server/lib/fs/canonical.ts +52 -0
- package/src/server/lib/fs/relative-path.ts +48 -0
- package/src/server/lib/fs/root-worktree.test.ts +116 -0
- package/src/server/lib/fs/root.ts +39 -10
- package/src/server/lib/fs/worktree/carry.ts +134 -0
- package/src/server/lib/fs/worktree/create.test.ts +249 -0
- package/src/server/lib/fs/worktree/create.ts +263 -0
- package/src/server/lib/fs/worktree/errors.ts +43 -0
- package/src/server/lib/fs/worktree/list.test.ts +207 -0
- package/src/server/lib/fs/worktree/list.ts +129 -0
- package/src/server/lib/fs/worktree/location.test.ts +206 -0
- package/src/server/lib/fs/worktree/location.ts +114 -0
- package/src/server/lib/fs/worktree/project-id.ts +53 -0
- package/src/server/lib/fs/worktree/remove.test.ts +242 -0
- package/src/server/lib/fs/worktree/remove.ts +152 -0
- package/src/server/lib/fs/worktree/settings.server.ts +56 -0
- package/src/server/lib/fs/worktree/topology.ts +142 -0
- package/src/server/lib/omp/session/sidebar-data.canonical.test.ts +155 -0
- package/src/server/lib/omp/session/sidebar-data.server.ts +28 -1
- package/src/server/lib/panels/local-edits.server.ts +33 -0
- package/src/server/lib/panels/sync.fixture.test-util.ts +92 -0
- package/src/server/lib/panels/sync.local-edits.test.ts +150 -0
- package/src/server/lib/panels/sync.server.test.ts +125 -147
- package/src/server/lib/panels/sync.server.ts +153 -48
- package/src/server/lib/updates/shell-assets.test.ts +114 -0
- package/src/server/lib/updates/shell-assets.ts +109 -0
- package/src/server/lib/updates/shell-pages.test.ts +156 -0
- package/src/server/lib/updates/shell-pages.ts +145 -0
- package/src/server/lib/updates/shell-release.test.ts +75 -70
- package/src/server/lib/updates/shell-release.ts +120 -136
- package/src/server/routes/fs/action-create.test.ts +18 -13
- package/src/server/routes/fs/action-nested.test.ts +184 -0
- package/src/server/routes/fs/action.ts +47 -16
- package/src/server/routes/index.ts +2 -0
- package/src/server/routes/updates/desktop.ts +37 -0
- package/src/server/routes/updates/index.ts +2 -0
- package/src/server/routes/worktrees/create.ts +138 -0
- package/src/server/routes/worktrees/index.ts +25 -0
- package/src/server/routes/worktrees/list.ts +124 -0
- package/src/server/routes/worktrees/remove.ts +102 -0
- package/src/server/routes/worktrees/routes.test.ts +309 -0
- package/src/server/routes/worktrees/shared.ts +90 -0
- package/src/shared/lib/fs/file-tree.test.ts +183 -0
- package/src/shared/lib/fs/file-tree.ts +159 -0
- package/src/shared/lib/fs/paths.test.ts +24 -1
- package/src/shared/lib/fs/paths.ts +17 -0
- package/src/shared/lib/fs/worktree/settings-keys.ts +18 -0
- package/src/shared/lib/omp/session/prewarm.ts +7 -0
- package/src/shared/lib/updates/shell-ua.ts +24 -4
- package/src/shared/lib/workspace/active-project.ts +23 -0
- package/src/shared/lib/workspace/workspace-modules.test.ts +49 -1
- package/src/shared/lib/workspace/worktree/branch-name.test.ts +95 -0
- package/src/shared/lib/workspace/worktree/branch-name.ts +115 -0
- package/src/shared/lib/workspace/worktree/types.ts +104 -0
- package/src/shared/types/fs.ts +10 -0
- package/src/shared/types/workspace.ts +14 -0
- package/src/test-support/file-explorer-disk.test-util.ts +76 -0
- package/src/test-support/file-explorer.test-util.ts +71 -40
- package/dist/client/static/js/index-nzyjf92m.css +0 -1
|
@@ -4,6 +4,8 @@ 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
6
|
import { SelectionCheckbox } from '@/client/components/common/batch-selection/SelectionCheckbox';
|
|
7
|
+
import { WorktreeBadge } from '@/client/components/layout/session-sidebar/WorktreeBadge';
|
|
8
|
+
import type { SessionWorktreeInfo } from '@/shared/lib/workspace/worktree/types';
|
|
7
9
|
|
|
8
10
|
export interface SessionItemProps {
|
|
9
11
|
/** The session this row draws; the key its in-flight rename is tracked under. */
|
|
@@ -38,6 +40,11 @@ export interface SessionItemProps {
|
|
|
38
40
|
waitingFor?: { title: string | null };
|
|
39
41
|
/** Drop this row's held prompt, from the row's own menu. */
|
|
40
42
|
onCancelWait?: () => void;
|
|
43
|
+
/**
|
|
44
|
+
* The worktree this session runs in, when its cwd is a linked checkout. The
|
|
45
|
+
* server sends it with the row, so no layout and no plugin asks git.
|
|
46
|
+
*/
|
|
47
|
+
worktree?: SessionWorktreeInfo;
|
|
41
48
|
/** Draw a checkbox and let the row's click toggle the selection instead of
|
|
42
49
|
* opening the session (batch mode). */
|
|
43
50
|
selectable?: boolean;
|
|
@@ -64,6 +71,7 @@ export function SessionItem({
|
|
|
64
71
|
onToggleExpand,
|
|
65
72
|
waitingFor,
|
|
66
73
|
onCancelWait,
|
|
74
|
+
worktree,
|
|
67
75
|
selectable = false,
|
|
68
76
|
selected = false,
|
|
69
77
|
onToggleSelect,
|
|
@@ -224,6 +232,10 @@ export function SessionItem({
|
|
|
224
232
|
waiting for {waitingFor?.title ?? 'another session'}
|
|
225
233
|
</span>
|
|
226
234
|
)}
|
|
235
|
+
{/* A worktree session runs in its own checkout. The chip is the only
|
|
236
|
+
thing on the row saying so — the folder above it is the project,
|
|
237
|
+
which is where a reader would otherwise assume the chat writes. */}
|
|
238
|
+
{worktree && !isEditing && <WorktreeBadge worktree={worktree} />}
|
|
227
239
|
</span>
|
|
228
240
|
)}
|
|
229
241
|
|
|
@@ -10,6 +10,8 @@ interface SessionSidebarSessionListProps {
|
|
|
10
10
|
sessionStatus: Record<string, 'stream' | 'finish' | 'abort'>;
|
|
11
11
|
onSelectSession: (id: number | string) => void;
|
|
12
12
|
onNewSessionForFolder: (id: number) => void;
|
|
13
|
+
/** Start a chat in a worktree made from this folder. */
|
|
14
|
+
onNewWorktreeSession?: (folderId: number, worktreePath: string) => void;
|
|
13
15
|
/** Surfaces a refused folder action (the reveal endpoint's own reason). */
|
|
14
16
|
onToast?: (message: string, type?: 'success' | 'error') => void;
|
|
15
17
|
/** Present only while a batch selection is active. */
|
|
@@ -27,6 +29,7 @@ export function SessionSidebarSessionList({
|
|
|
27
29
|
sessionStatus,
|
|
28
30
|
onSelectSession,
|
|
29
31
|
onNewSessionForFolder,
|
|
32
|
+
onNewWorktreeSession,
|
|
30
33
|
onToast,
|
|
31
34
|
batch,
|
|
32
35
|
onStartSessionSelection,
|
|
@@ -49,6 +52,7 @@ export function SessionSidebarSessionList({
|
|
|
49
52
|
activeSessionId={activeSessionId}
|
|
50
53
|
onSelectSession={onSelectSession}
|
|
51
54
|
onNewSessionForFolder={onNewSessionForFolder}
|
|
55
|
+
onNewWorktreeSession={onNewWorktreeSession}
|
|
52
56
|
forceExpanded={!!searchQuery}
|
|
53
57
|
showArchived={showArchived}
|
|
54
58
|
sessionStatus={sessionStatus}
|
|
@@ -12,6 +12,7 @@ import { useSessionSidebarController } from '@/client/hooks/chat/omp/session-sid
|
|
|
12
12
|
import { useCancelWait } from '@/client/hooks/chat/waiting/cancel';
|
|
13
13
|
import { useWaitingDispatch } from '@/client/hooks/chat/waiting/dispatch';
|
|
14
14
|
import { startPendingSession } from '@/client/hooks/chat/omp/pending-sessions';
|
|
15
|
+
import { setSessionKey } from '@/shared/lib/workspace/session-state/store';
|
|
15
16
|
import { useScheduledTaskCount } from '@/client/hooks/workspace/scheduled-tasks';
|
|
16
17
|
import { cloneWorkspaceFolder, createWorkspaceFolder } from '@/client/hooks/workspace/new-workspace';
|
|
17
18
|
import { useBatchSelection } from '@/client/hooks/workspace/batch-selection';
|
|
@@ -150,6 +151,28 @@ export function SessionsView({
|
|
|
150
151
|
}, { replace: true });
|
|
151
152
|
};
|
|
152
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Start a chat in a worktree made from this folder.
|
|
156
|
+
*
|
|
157
|
+
* The pick is written into the PENDING session's own state before the URL
|
|
158
|
+
* names it, so the composer's target picker draws it and the send path reads
|
|
159
|
+
* it through its ref — the same key the picker writes. Starting the chat here
|
|
160
|
+
* rather than only reporting the folder is what makes the entry point useful:
|
|
161
|
+
* a worktree created from the sidebar with no chat in it is a folder the user
|
|
162
|
+
* then has to go and find.
|
|
163
|
+
*/
|
|
164
|
+
const handleNewWorktreeSession = (folderId: number, worktreePath: string) => {
|
|
165
|
+
prewarmForNewSession(folderId);
|
|
166
|
+
const pendingId = startPendingSession(folderId);
|
|
167
|
+
setSessionKey(pendingId, 'chat.spawnTarget', { folderId, worktreePath });
|
|
168
|
+
setSearchParams(prev => {
|
|
169
|
+
const next = new URLSearchParams(prev);
|
|
170
|
+
next.set('sessionId', pendingId);
|
|
171
|
+
next.set('folderId', folderId.toString());
|
|
172
|
+
return next;
|
|
173
|
+
}, { replace: true });
|
|
174
|
+
};
|
|
175
|
+
|
|
153
176
|
const handleNewSessionForFolder = (folderId: number) => {
|
|
154
177
|
prewarmForNewSession(folderId);
|
|
155
178
|
setSearchParams(prev => {
|
|
@@ -197,6 +220,7 @@ export function SessionsView({
|
|
|
197
220
|
sessionStatus={sessionStatus}
|
|
198
221
|
onSelectSession={handleSelectSession}
|
|
199
222
|
onNewSessionForFolder={handleNewSessionForFolder}
|
|
223
|
+
onNewWorktreeSession={handleNewWorktreeSession}
|
|
200
224
|
onToast={pushToast}
|
|
201
225
|
batch={batchBinding}
|
|
202
226
|
onStartSessionSelection={(folderId) => batch.begin('session', folderId)}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The worktree chip on a session row.
|
|
8
|
+
*
|
|
9
|
+
* What it must say, and the one state that changes the words rather than only
|
|
10
|
+
* the colour: a worktree whose folder was deleted outside Kabmo keeps its row
|
|
11
|
+
* (that is how the transcript stays reachable) and must not read as an ordinary
|
|
12
|
+
* checkout.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, expect, test } from 'bun:test';
|
|
16
|
+
import {
|
|
17
|
+
WORKTREE_BADGE_MISSING_SUFFIX,
|
|
18
|
+
worktreeBadgeLabel,
|
|
19
|
+
worktreeBadgeTitle,
|
|
20
|
+
} from '@/client/components/layout/session-sidebar/WorktreeBadge';
|
|
21
|
+
import type { SessionWorktreeInfo } from '@/shared/lib/workspace/worktree/types';
|
|
22
|
+
|
|
23
|
+
const ready: SessionWorktreeInfo = {
|
|
24
|
+
branch: 'wt/cosmic-dolphin',
|
|
25
|
+
path: '/data/worktrees/abc/cosmic-dolphin',
|
|
26
|
+
primaryRoot: '/repo',
|
|
27
|
+
status: 'ready',
|
|
28
|
+
detached: false,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
describe('worktree badge', () => {
|
|
32
|
+
test('names the branch', () => {
|
|
33
|
+
expect(worktreeBadgeLabel(ready)).toBe('wt/cosmic-dolphin');
|
|
34
|
+
expect(worktreeBadgeTitle(ready)).toContain('/data/worktrees/abc/cosmic-dolphin');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('a missing worktree says so in the label, not only in the colour', () => {
|
|
38
|
+
const missing: SessionWorktreeInfo = { ...ready, status: 'missing' };
|
|
39
|
+
expect(worktreeBadgeLabel(missing)).toContain(WORKTREE_BADGE_MISSING_SUFFIX);
|
|
40
|
+
expect(worktreeBadgeTitle(missing)).toMatch(/missing/i);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('a detached HEAD has a name instead of an empty chip', () => {
|
|
44
|
+
const detached: SessionWorktreeInfo = { ...ready, branch: '', detached: true };
|
|
45
|
+
expect(worktreeBadgeLabel(detached)).toBe('detached');
|
|
46
|
+
expect(worktreeBadgeTitle(detached)).toMatch(/detached/i);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The `⑂ wt/branch` chip under a worktree session's title.
|
|
8
|
+
*
|
|
9
|
+
* It is a LABEL, not a control: the row's own click opens the chat, and the
|
|
10
|
+
* project's panels already follow the session's root, so a second click target
|
|
11
|
+
* here would only be a way to open the same thing twice. The absolute path lives
|
|
12
|
+
* in `title`, which is where a reader looks when they want to know where the
|
|
13
|
+
* chat actually writes.
|
|
14
|
+
*
|
|
15
|
+
* `missing` is the one state that changes the wording rather than the colour
|
|
16
|
+
* alone: a worktree whose directory was removed outside Kabmo keeps its row (so
|
|
17
|
+
* its transcript is reachable) but must not read as an ordinary checkout.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { SessionWorktreeInfo } from '@/shared/lib/workspace/worktree/types';
|
|
21
|
+
|
|
22
|
+
export const WORKTREE_BADGE_MISSING_SUFFIX = 'missing';
|
|
23
|
+
|
|
24
|
+
/** What the chip says, without the markup — exported so a test asserts the words. */
|
|
25
|
+
export function worktreeBadgeLabel(worktree: SessionWorktreeInfo): string {
|
|
26
|
+
const name = worktree.branch || (worktree.detached ? 'detached' : 'worktree');
|
|
27
|
+
return worktree.status === 'missing' ? `${name} · ${WORKTREE_BADGE_MISSING_SUFFIX}` : name;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function worktreeBadgeTitle(worktree: SessionWorktreeInfo): string {
|
|
31
|
+
if (worktree.status === 'missing') {
|
|
32
|
+
return `Worktree folder is missing: ${worktree.path}`;
|
|
33
|
+
}
|
|
34
|
+
return worktree.detached
|
|
35
|
+
? `Detached HEAD in worktree ${worktree.path}`
|
|
36
|
+
: `Runs in worktree ${worktree.path}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function WorktreeBadge({ worktree }: { worktree: SessionWorktreeInfo }) {
|
|
40
|
+
const missing = worktree.status === 'missing';
|
|
41
|
+
return (
|
|
42
|
+
<span
|
|
43
|
+
className={`flex min-w-0 items-center gap-1 truncate text-[10px] font-normal ${
|
|
44
|
+
missing ? 'text-warning' : 'text-ink/45'
|
|
45
|
+
}`}
|
|
46
|
+
title={worktreeBadgeTitle(worktree)}
|
|
47
|
+
data-worktree-status={worktree.status}
|
|
48
|
+
>
|
|
49
|
+
<span aria-hidden="true" className="shrink-0 font-mono">
|
|
50
|
+
⑂
|
|
51
|
+
</span>
|
|
52
|
+
<span className="truncate">{worktreeBadgeLabel(worktree)}</span>
|
|
53
|
+
</span>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -9,7 +9,7 @@ import { MobileRightSidebar } from '@/client/components/mobile/RightSidebar';
|
|
|
9
9
|
import { MobileFullEditor } from '@/client/components/mobile/mobile-right-sidebar/FullEditor';
|
|
10
10
|
import { MobileFullDiff } from '@/client/components/mobile/mobile-right-sidebar/FullDiff';
|
|
11
11
|
import { SettingsModal } from '@/client/components/settings/LazyModal';
|
|
12
|
-
import { activeProjectForSession } from '@/shared/lib/workspace/active-project';
|
|
12
|
+
import { activeProjectForSession, effectiveRootFor } from '@/shared/lib/workspace/active-project';
|
|
13
13
|
import { spawnCwdForNewSession, triggerSessionPrewarm } from '@/shared/lib/omp/session/prewarm';
|
|
14
14
|
import { useSidebarData } from '@/client/hooks/chat/omp/session-list';
|
|
15
15
|
import { useMissingFiles } from '@/client/hooks/workspace/missing-files';
|
|
@@ -23,6 +23,7 @@ import { mobileEditorFileFrom, type MobileEditorFile } from '@/client/components
|
|
|
23
23
|
import { useLeftDock } from '@/client/hooks/workspace/left-dock';
|
|
24
24
|
import { useSessionStateContext } from '@/client/hooks/workspace/session-state/context';
|
|
25
25
|
import { setSessionKey } from '@/shared/lib/workspace/session-state/store';
|
|
26
|
+
import { startWorktreeSession } from '@/client/components/mobile/worktree-session';
|
|
26
27
|
import { OPEN_PANEL_EVENT, phoneDrawerForPanel, type OpenPanelDetail } from '@/shared/lib/workspace/panel-open';
|
|
27
28
|
|
|
28
29
|
interface MobileLayoutWrapperProps {
|
|
@@ -106,7 +107,9 @@ export function MobileLayoutWrapper({ onDesktopToggle, appSettings = {} }: Mobil
|
|
|
106
107
|
|
|
107
108
|
const { folder: sessionFolder } = activeProjectForSession(folders, sessionId);
|
|
108
109
|
const contextFolder = sessionFolder ?? (folderId ? folders.find(f => String(f.id) === String(folderId)) ?? null : null);
|
|
109
|
-
|
|
110
|
+
// A worktree session runs outside its folder's project path: the phone draws
|
|
111
|
+
// the same panels as the desktop, so it resolves the same root.
|
|
112
|
+
const activeProjectPath = effectiveRootFor(folders, sessionId) ?? contextFolder?.project_path ?? null;
|
|
110
113
|
const hasContext = !!contextFolder;
|
|
111
114
|
|
|
112
115
|
// A phone drop is rare but real (an iPad with a trackpad). The target is the
|
|
@@ -145,20 +148,13 @@ export function MobileLayoutWrapper({ onDesktopToggle, appSettings = {} }: Mobil
|
|
|
145
148
|
useKabmoEvent('omp:view-subagent', () => setCurrentScreen('main'));
|
|
146
149
|
|
|
147
150
|
/**
|
|
148
|
-
* A plugin's navbar readout asking for its own panel.
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
* not list it.
|
|
156
|
-
*
|
|
157
|
-
* The tab is written through the session store rather than through a prop:
|
|
158
|
-
* the drawer mounts on this screen change and reads the stored tab, so the
|
|
159
|
-
* selection has to be in the store before it mounts. An `editor`-slot
|
|
160
|
-
* takeover resolves to null — the phone has no editor column — and the click
|
|
161
|
-
* then does nothing rather than opening a drawer that cannot show it.
|
|
151
|
+
* A plugin's navbar readout asking for its own panel. A phone has no rail, so
|
|
152
|
+
* "show this panel" is two decisions — which DRAWER reaches it and which chip
|
|
153
|
+
* inside it — both answered by `phoneDrawerForPanel` from the PLACEMENT, never
|
|
154
|
+
* from the panel's slot. The tab is written through the store because the
|
|
155
|
+
* drawer mounts on this screen change and reads the stored tab; an `editor`
|
|
156
|
+
* takeover resolves to null and the click does nothing rather than opening a
|
|
157
|
+
* drawer that cannot show it.
|
|
162
158
|
*/
|
|
163
159
|
useKabmoEvent(OPEN_PANEL_EVENT, (event) => {
|
|
164
160
|
const panelKey = (event as CustomEvent<OpenPanelDetail>).detail?.panelKey;
|
|
@@ -228,6 +224,15 @@ export function MobileLayoutWrapper({ onDesktopToggle, appSettings = {} }: Mobil
|
|
|
228
224
|
setCurrentScreen('main');
|
|
229
225
|
};
|
|
230
226
|
|
|
227
|
+
// Desktop parity: a worktree made from the phone's folder menu starts its chat
|
|
228
|
+
// in it. The sequence lives in one module so both layouts share it.
|
|
229
|
+
const handleNewWorktreeSession = (targetFolderId: number, worktreePath: string) =>
|
|
230
|
+
startWorktreeSession(
|
|
231
|
+
{ setSearchParams, afterNavigate: () => setCurrentScreen('main') },
|
|
232
|
+
targetFolderId,
|
|
233
|
+
worktreePath,
|
|
234
|
+
);
|
|
235
|
+
|
|
231
236
|
const handleOpenFile = useCallback((file: unknown) => {
|
|
232
237
|
const next = mobileEditorFileFrom(file, activeProjectPath);
|
|
233
238
|
if (next) setMobileEditorFile(next);
|
|
@@ -272,6 +277,7 @@ export function MobileLayoutWrapper({ onDesktopToggle, appSettings = {} }: Mobil
|
|
|
272
277
|
onSelectSession={handleSelectSession}
|
|
273
278
|
onNewSession={handleNewSession}
|
|
274
279
|
onNewSessionForFolder={handleNewSessionForFolder}
|
|
280
|
+
onNewWorktreeSession={handleNewWorktreeSession}
|
|
275
281
|
onClose={() => setCurrentScreen('main')}
|
|
276
282
|
onDesktopToggle={onDesktopToggle}
|
|
277
283
|
appSettings={appSettings}
|
|
@@ -10,6 +10,7 @@ import { SESSIONS_VIEW_ID } from '@/shared/lib/workspace/left-dock';
|
|
|
10
10
|
import { phoneRightPanels } from '@/shared/lib/workspace/mobile-dock';
|
|
11
11
|
import { PanelChips } from '@/client/components/mobile/panel-chips';
|
|
12
12
|
import { titlebarPaddingLeft, titlebarPaddingRight } from '@/shared/lib/ui/titlebar';
|
|
13
|
+
import { dragRegionProps, titlebarInsetLeft } from '@/client/lib/desktop/titlebar';
|
|
13
14
|
|
|
14
15
|
interface MobileRightSidebarProps {
|
|
15
16
|
enabled?: boolean;
|
|
@@ -89,28 +90,38 @@ export function MobileRightSidebar({
|
|
|
89
90
|
return (
|
|
90
91
|
<div className="flex flex-col h-full w-full bg-paper text-ink relative select-none">
|
|
91
92
|
|
|
92
|
-
{/* Top Header & Tab Navigation Bar
|
|
93
|
+
{/* Top Header & Tab Navigation Bar. It is the window's title bar while
|
|
94
|
+
this drawer is the mounted screen: a desktop shell window narrowed
|
|
95
|
+
past the layout breakpoint shows it, and without `data-drag-region`
|
|
96
|
+
there is nothing left to move the window by. The chip strip inside is
|
|
97
|
+
the `titlebar-no-drag` cluster. */}
|
|
93
98
|
<div
|
|
94
|
-
className="border-b border-ink/10 flex items-center justify-between px-3 flex-shrink-0 bg-canvas"
|
|
99
|
+
className="border-b border-ink/10 flex items-center justify-between px-3 flex-shrink-0 bg-canvas titlebar-drag-region select-none"
|
|
100
|
+
{...dragRegionProps()}
|
|
95
101
|
style={{
|
|
96
102
|
height: 'calc(3.5rem + env(safe-area-inset-top, 0px))',
|
|
97
103
|
paddingTop: 'env(safe-area-inset-top, 0px)',
|
|
98
|
-
paddingLeft: titlebarPaddingLeft('0.75rem', true),
|
|
104
|
+
paddingLeft: titlebarInsetLeft(titlebarPaddingLeft('0.75rem', true)),
|
|
99
105
|
paddingRight: titlebarPaddingRight('0.75rem', true),
|
|
100
106
|
}}
|
|
101
107
|
>
|
|
102
108
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
+
{/* The header is now a drag region, so its controls must say so —
|
|
110
|
+
`app-region` inherits, and a control inside a drag region is a drag
|
|
111
|
+
handle that swallows its own click on Windows. */}
|
|
112
|
+
<div className="flex items-center min-w-0 titlebar-no-drag">
|
|
113
|
+
<PanelChips
|
|
114
|
+
panels={catalog}
|
|
115
|
+
activeId={activePanel?.id ?? activeTab}
|
|
116
|
+
onSelect={setActiveTab}
|
|
117
|
+
hasGitChanges={hasGitChanges}
|
|
118
|
+
/>
|
|
119
|
+
</div>
|
|
109
120
|
|
|
110
121
|
<button
|
|
111
122
|
type="button"
|
|
112
123
|
onClick={onClose}
|
|
113
|
-
className="p-1.5 rounded-lg hover:bg-ink/5 active:bg-ink/10 text-ink transition-colors flex-shrink-0 ml-2"
|
|
124
|
+
className="p-1.5 rounded-lg hover:bg-ink/5 active:bg-ink/10 text-ink transition-colors flex-shrink-0 ml-2 titlebar-no-drag"
|
|
114
125
|
title="Close right sidebar"
|
|
115
126
|
aria-label="Close right sidebar"
|
|
116
127
|
>
|
|
@@ -29,6 +29,8 @@ interface MobileSessionSidebarProps {
|
|
|
29
29
|
onSelectSession: (id: number | string) => void;
|
|
30
30
|
onNewSession: () => void;
|
|
31
31
|
onNewSessionForFolder: (folderId: number) => void;
|
|
32
|
+
/** Start a chat in a worktree made from this folder. */
|
|
33
|
+
onNewWorktreeSession?: (folderId: number, worktreePath: string) => void;
|
|
32
34
|
onClose: () => void;
|
|
33
35
|
onDesktopToggle?: () => void;
|
|
34
36
|
appSettings?: Record<string, any>;
|
|
@@ -45,6 +47,7 @@ export function MobileSessionSidebar({
|
|
|
45
47
|
onSelectSession,
|
|
46
48
|
onNewSession,
|
|
47
49
|
onNewSessionForFolder,
|
|
50
|
+
onNewWorktreeSession,
|
|
48
51
|
onClose,
|
|
49
52
|
onDesktopToggle,
|
|
50
53
|
appSettings = {},
|
|
@@ -264,6 +267,11 @@ export function MobileSessionSidebar({
|
|
|
264
267
|
sessionStatus={sessionStatus}
|
|
265
268
|
onSelectSession={handleSelectSession}
|
|
266
269
|
onNewSessionForFolder={handleNewSessionForFolderAndClose}
|
|
270
|
+
onNewWorktreeSession={
|
|
271
|
+
onNewWorktreeSession
|
|
272
|
+
? (id, path) => { onNewWorktreeSession(id, path); onClose(); }
|
|
273
|
+
: undefined
|
|
274
|
+
}
|
|
267
275
|
onToggleFolder={toggleFolder}
|
|
268
276
|
onToast={pushToast}
|
|
269
277
|
batch={batchBinding}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The phone's headers are title bars, and they say so.
|
|
8
|
+
*
|
|
9
|
+
* A desktop shell window may be narrower than `App`'s 768px layout breakpoint —
|
|
10
|
+
* macOS, Windows and Linux all allow a 720px window — so a narrowed shell
|
|
11
|
+
* window renders the PHONE layout, and whichever header is on screen becomes
|
|
12
|
+
* the window's top strip. Three things follow, and all three were missing:
|
|
13
|
+
*
|
|
14
|
+
* 1. **`data-drag-region`.** macOS and Linux decide a drag from that attribute,
|
|
15
|
+
* so a header without it is a strip the window cannot be moved by. The main
|
|
16
|
+
* screen had none, and the right drawer had none at all.
|
|
17
|
+
* 2. **The shell's own left inset.** `titlebarPaddingLeft` reads
|
|
18
|
+
* `env(titlebar-area-x)`, which a native WKWebView never defines, so the
|
|
19
|
+
* 76px macOS reserves for its traffic lights has to come from
|
|
20
|
+
* `titlebarInsetLeft` — without it the hamburger and the wordmark sat under
|
|
21
|
+
* the buttons.
|
|
22
|
+
* 3. **`titlebar-no-drag` on the controls.** `app-region` INHERITS, so marking
|
|
23
|
+
* a header as a drag region turns every control in it into a drag handle on
|
|
24
|
+
* Windows unless the control says otherwise — and a drag handle swallows its
|
|
25
|
+
* own click.
|
|
26
|
+
*
|
|
27
|
+
* Asserted on the RENDERED markup, because a header's class list and attributes
|
|
28
|
+
* are what the shells read; a source-level check would pass on a component that
|
|
29
|
+
* rendered something else.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
|
|
33
|
+
import { Window } from 'happy-dom';
|
|
34
|
+
import { h, render } from 'preact';
|
|
35
|
+
import { act } from 'preact/test-utils';
|
|
36
|
+
import { MobileHeader } from '@/client/components/mobile/mobile-main-view/Header';
|
|
37
|
+
import { MobileSessionHeader } from '@/client/components/mobile/mobile-session-sidebar/Header';
|
|
38
|
+
import { MobileRightSidebar } from '@/client/components/mobile/RightSidebar';
|
|
39
|
+
import { SessionStateProvider } from '@/client/components/common/session-state-provider/index';
|
|
40
|
+
import { silenceRealtime, restoreRealtime } from '@/test-support/silent-socket';
|
|
41
|
+
|
|
42
|
+
const DOM_GLOBALS = [
|
|
43
|
+
'window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement',
|
|
44
|
+
'Event', 'MouseEvent', 'KeyboardEvent',
|
|
45
|
+
] as const;
|
|
46
|
+
const nativeGlobals: Partial<Record<(typeof DOM_GLOBALS)[number], unknown>> = {};
|
|
47
|
+
|
|
48
|
+
let win: Window;
|
|
49
|
+
let container: HTMLElement;
|
|
50
|
+
|
|
51
|
+
/** The shell's globals, which the DOM's `window` type does not declare. */
|
|
52
|
+
type Shell = {
|
|
53
|
+
__kabmoCall?: (method: string, params?: Record<string, unknown>) => Promise<unknown>;
|
|
54
|
+
__kabmoDesktop?: Record<string, unknown>;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
beforeAll(() => {
|
|
58
|
+
win = new Window({ url: 'http://localhost' });
|
|
59
|
+
const target = globalThis as unknown as Record<string, unknown>;
|
|
60
|
+
for (const key of DOM_GLOBALS) {
|
|
61
|
+
if (!(key in nativeGlobals)) nativeGlobals[key] = target[key];
|
|
62
|
+
target[key] = (win as unknown as Record<string, unknown>)[key];
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
afterAll(() => {
|
|
67
|
+
const target = globalThis as unknown as Record<string, unknown>;
|
|
68
|
+
for (const key of DOM_GLOBALS) {
|
|
69
|
+
const value = nativeGlobals[key];
|
|
70
|
+
if (value === undefined) delete target[key];
|
|
71
|
+
else target[key] = value;
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
beforeEach(() => {
|
|
76
|
+
// The headers subscribe to the realtime channel on mount; this window's
|
|
77
|
+
// origin answers nothing, and a refused dial fails asynchronously — after
|
|
78
|
+
// this suite has restored the globals, which reports against the NEXT file.
|
|
79
|
+
silenceRealtime();
|
|
80
|
+
container = document.createElement('div');
|
|
81
|
+
document.body.appendChild(container);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
afterEach(() => {
|
|
85
|
+
if (container) {
|
|
86
|
+
render(null, container);
|
|
87
|
+
container.remove();
|
|
88
|
+
}
|
|
89
|
+
restoreRealtime();
|
|
90
|
+
const shell = win as unknown as Shell;
|
|
91
|
+
delete shell.__kabmoDesktop;
|
|
92
|
+
delete shell.__kabmoCall;
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
/** Mount a header as a macOS shell window would see it. */
|
|
96
|
+
async function mountAsMacShell(node: () => unknown): Promise<void> {
|
|
97
|
+
const shell = win as unknown as Shell;
|
|
98
|
+
shell.__kabmoCall = async () => null;
|
|
99
|
+
shell.__kabmoDesktop = {
|
|
100
|
+
platform: 'macos', shellVersion: '0.1.0', overlayTitlebar: true, trafficLightInset: '5rem',
|
|
101
|
+
};
|
|
102
|
+
// The headers embed the shared control cluster, which reads the session's
|
|
103
|
+
// state (the editor/panel toggles and the plugin slots) — the real provider is
|
|
104
|
+
// what a shell window mounts them inside.
|
|
105
|
+
await act(async () => {
|
|
106
|
+
render(
|
|
107
|
+
h(SessionStateProvider, { sessionId: 'session-shell-header', children: node() as never }),
|
|
108
|
+
container,
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
describe('phone headers as a shell title bar', () => {
|
|
114
|
+
test('the main header carries the drag region and the shell inset', async () => {
|
|
115
|
+
await mountAsMacShell(() =>
|
|
116
|
+
h(MobileHeader, {
|
|
117
|
+
activeSessionTitle: 'Shell',
|
|
118
|
+
activeSessionId: 'session-shell-header',
|
|
119
|
+
onOpenSessionSidebar: () => {},
|
|
120
|
+
onOpenRightSidebar: () => {},
|
|
121
|
+
onNewSession: () => {},
|
|
122
|
+
onSelectSession: () => {},
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
const header = container.querySelector('header') as HTMLElement;
|
|
126
|
+
expect(header).toBeTruthy();
|
|
127
|
+
expect(header.hasAttribute('data-drag-region')).toBe(true);
|
|
128
|
+
// `5rem` is what this shell reported; the caller's own 0.75rem would leave
|
|
129
|
+
// the hamburger under the traffic lights.
|
|
130
|
+
expect(header.style.paddingLeft).toContain('5rem');
|
|
131
|
+
// And the controls inside it must not inherit the drag region.
|
|
132
|
+
expect(header.querySelector('.titlebar-no-drag')).toBeTruthy();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('the sessions drawer header carries the drag region and the shell inset', async () => {
|
|
136
|
+
await mountAsMacShell(() => h(MobileSessionHeader, { onClose: () => {} }));
|
|
137
|
+
const header = container.firstElementChild as HTMLElement;
|
|
138
|
+
expect(header.hasAttribute('data-drag-region')).toBe(true);
|
|
139
|
+
expect(header.style.paddingLeft).toContain('5rem');
|
|
140
|
+
expect(header.querySelector('.titlebar-no-drag')).toBeTruthy();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('the right drawer header is a drag region whose controls are not', async () => {
|
|
144
|
+
await mountAsMacShell(() =>
|
|
145
|
+
h(MobileRightSidebar, { enabled: false, onClose: () => {} }),
|
|
146
|
+
);
|
|
147
|
+
const drag = container.querySelector('[data-drag-region]') as HTMLElement;
|
|
148
|
+
expect(drag).toBeTruthy();
|
|
149
|
+
expect(drag.style.paddingLeft).toContain('5rem');
|
|
150
|
+
const close = drag.querySelector('button[aria-label="Close right sidebar"]') as HTMLElement;
|
|
151
|
+
expect(close).toBeTruthy();
|
|
152
|
+
// The close button is a control in a drag region: without this it is a
|
|
153
|
+
// drag handle on Windows and its click never fires.
|
|
154
|
+
expect(close.className).toContain('titlebar-no-drag');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
/** A browser tab has no shell, so nothing is reserved and nothing is marked. */
|
|
158
|
+
test('a browser marks no drag region and reserves nothing', async () => {
|
|
159
|
+
await act(async () => {
|
|
160
|
+
render(
|
|
161
|
+
h(SessionStateProvider, {
|
|
162
|
+
sessionId: 'session-shell-header',
|
|
163
|
+
children: h(MobileSessionHeader, { onClose: () => {} }),
|
|
164
|
+
}),
|
|
165
|
+
container,
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
const header = container.firstElementChild as HTMLElement;
|
|
169
|
+
expect(header.hasAttribute('data-drag-region')).toBe(false);
|
|
170
|
+
expect(header.style.paddingLeft).not.toContain('5rem');
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -9,6 +9,7 @@ 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
11
|
import { titlebarPaddingLeft, titlebarPaddingRight } from '@/shared/lib/ui/titlebar';
|
|
12
|
+
import { dragRegionProps, titlebarInsetLeft } from '@/client/lib/desktop/titlebar';
|
|
12
13
|
|
|
13
14
|
interface MobileHeaderProps {
|
|
14
15
|
activeSessionTitle: string;
|
|
@@ -90,9 +91,17 @@ export function MobileHeader({
|
|
|
90
91
|
return (
|
|
91
92
|
<header
|
|
92
93
|
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"
|
|
94
|
+
{...dragRegionProps()}
|
|
93
95
|
style={{
|
|
94
96
|
height: 'calc(3.5rem + env(safe-area-inset-top, 0px))',
|
|
95
|
-
|
|
97
|
+
// The shell's own inset, not only the PWA's. `titlebarPaddingLeft` reads
|
|
98
|
+
// `env(titlebar-area-x)`, which a native WKWebView never defines — so on
|
|
99
|
+
// macOS this header's hamburger and wordmark were drawn UNDER the
|
|
100
|
+
// traffic lights, in the 76px the shell reserves for them. `titlebarInsetLeft`
|
|
101
|
+
// is what the desktop navbar and the sidebar header already use; a
|
|
102
|
+
// desktop shell window narrowed past the layout breakpoint lands on this
|
|
103
|
+
// header, which is why it needs the same treatment.
|
|
104
|
+
paddingLeft: titlebarInsetLeft(titlebarPaddingLeft('0.75rem', true)),
|
|
96
105
|
paddingRight: titlebarPaddingRight('0.75rem', true),
|
|
97
106
|
paddingTop: 'env(safe-area-inset-top, 0px)',
|
|
98
107
|
}}
|
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
import { X } from 'lucide-preact';
|
|
2
2
|
import { BrandWordmark } from '@/client/components/common/brand-wordmark';
|
|
3
3
|
import { titlebarPaddingLeft, titlebarPaddingRight } from '@/shared/lib/ui/titlebar';
|
|
4
|
+
import { dragRegionProps, titlebarInsetLeft } from '@/client/lib/desktop/titlebar';
|
|
4
5
|
|
|
5
6
|
export function MobileSessionHeader({ onClose, viewLabel = 'Sessions' }: { onClose: () => void; viewLabel?: string }) {
|
|
6
7
|
return (
|
|
7
8
|
<div
|
|
8
9
|
className="border-b border-ink/10 flex items-center justify-between px-3 flex-shrink-0 bg-canvas titlebar-drag-region select-none"
|
|
10
|
+
{...dragRegionProps()}
|
|
9
11
|
style={{
|
|
10
12
|
height: 'calc(3.5rem + env(safe-area-inset-top, 0px))',
|
|
11
13
|
// `px-3`, the toolbar's own inset: the wordmark above and the New
|
|
12
14
|
// Session button below share one left edge, which the header's old
|
|
13
|
-
// 14px did not
|
|
14
|
-
|
|
15
|
+
// 14px did not — and the SHELL's own inset, because a desktop shell
|
|
16
|
+
// window narrowed past the layout breakpoint shows this drawer, whose
|
|
17
|
+
// wordmark would otherwise sit under macOS's traffic lights.
|
|
18
|
+
paddingLeft: titlebarInsetLeft(titlebarPaddingLeft('0.75rem', true)),
|
|
15
19
|
// The drawer is the full window width, so on a desktop showing the
|
|
16
20
|
// mobile layout its close button sits under the window controls too.
|
|
17
21
|
paddingRight: titlebarPaddingRight('0.75rem', true),
|