@kabmo-labs/kabmo 0.1.0 → 0.3.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 +285 -285
- package/dist/client/static/js/{index-enkbbffs.js → index-8x5j8zrr.js} +671 -671
- package/dist/client/static/js/index-pdp5dwc8.css +1 -0
- package/package.json +1 -1
- package/src/client/bootstrap.d.ts +17 -0
- package/src/client/components/common/header-controls/MoreMenu.tsx +136 -0
- package/src/client/components/common/header-controls/index.test.tsx +77 -11
- package/src/client/components/common/header-controls/index.tsx +53 -4
- package/src/client/components/common/session-state-provider/index.test.ts +22 -1
- package/src/client/components/layout/session-sidebar/Footer.tsx +24 -2
- package/src/client/components/layout/session-sidebar/OverflowMenu.tsx +23 -5
- package/src/client/components/layout/session-sidebar/Toolbar.tsx +2 -33
- package/src/client/components/layout/session-sidebar/index.tsx +8 -0
- package/src/client/components/mobile/LayoutWrapper.tsx +32 -1
- package/src/client/components/mobile/SessionSidebar.tsx +8 -0
- package/src/client/components/mobile/mobile-main-view/Header.tsx +27 -5
- package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +29 -1
- package/src/client/components/mobile/mobile-session-sidebar/Toolbar.test.ts +104 -0
- package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +166 -106
- package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +9 -0
- package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +6 -0
- package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +4 -1
- package/src/client/components/workspace/chat-timeline/chat-input/DropOverlay.tsx +1 -1
- package/src/client/components/workspace/chat-timeline/chat-input/GoalBanner.tsx +6 -1
- package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +4 -1
- package/src/client/components/workspace/chat-timeline/chat-input/VoiceNotice.tsx +2 -2
- package/src/client/components/workspace/chat-timeline/chat-input/expansion.test.ts +72 -1
- package/src/client/components/workspace/chat-timeline/chat-input/expansion.ts +28 -4
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +20 -2
- package/src/client/components/workspace/chat-timeline/chat-input/props.ts +7 -0
- package/src/client/components/workspace/chat-timeline/index.tsx +9 -3
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks.test.tsx +127 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks.tsx +71 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/PluginPendingInteractions.test.ts +1 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -2
- package/src/client/components/workspace/git-panel/BranchToolbar.tsx +4 -0
- package/src/client/components/workspace/git-panel/index.test.ts +15 -0
- package/src/client/components/workspace/search-panel/Body.test.ts +149 -0
- package/src/client/components/workspace/search-panel/Body.tsx +53 -9
- package/src/client/data/settings/omp-schema.json +89 -4
- package/src/client/hooks/chat/timeline/ask-frames-grouped.test.ts +46 -2
- package/src/client/hooks/chat/timeline/ask-frames.test.ts +1 -1
- package/src/client/hooks/chat/timeline/ask-frames.ts +79 -29
- package/src/client/hooks/chat/timeline/index.ts +2 -1
- package/src/client/hooks/chat/timeline/scroll-probe.test-util.ts +227 -0
- package/src/client/hooks/chat/timeline/scroll-reading-back.test.ts +67 -0
- package/src/client/hooks/chat/timeline/scroll.test.ts +84 -226
- package/src/client/hooks/chat/timeline/scroll.ts +34 -1
- package/src/client/hooks/ui/media-query.ts +41 -0
- package/src/client/hooks/ui/updates/android-download.test.ts +112 -0
- package/src/client/hooks/ui/updates/android-download.ts +143 -0
- package/src/client/hooks/workspace/file-tabs.test.ts +165 -0
- package/src/client/hooks/workspace/file-tabs.ts +24 -11
- package/src/client/hooks/workspace/session-state/index.ts +24 -1
- package/src/client/hooks/workspace/toolbar-row-width.ts +43 -0
- package/src/client/lib/native/back.test.ts +108 -0
- package/src/client/lib/native/back.ts +60 -0
- package/src/client/main.tsx +6 -0
- package/src/server/lib/fs/git-actions-branch.ts +18 -1
- package/src/server/lib/fs/git-actions-sync.test.ts +144 -0
- package/src/server/lib/fs/git-publish.test.ts +96 -0
- package/src/server/lib/fs/git-publish.ts +35 -3
- package/src/server/lib/fs/listing.test.ts +86 -0
- package/src/server/lib/fs/listing.ts +20 -6
- package/src/server/lib/updates/android-release.test.ts +138 -0
- package/src/server/lib/updates/android-release.ts +183 -0
- package/src/server/plugins/ssr.ts +14 -2
- package/src/server/routes/fs/read.ts +24 -9
- package/src/server/routes/sessions/batch-delete.test.ts +14 -11
- package/src/server/routes/updates/android.ts +22 -0
- package/src/server/routes/updates/index.ts +2 -0
- package/src/shared/lib/updates/shell-ua.test.ts +121 -0
- package/src/shared/lib/updates/shell-ua.ts +56 -0
- package/src/shared/lib/workspace/session-state/listeners.ts +38 -0
- package/src/shared/lib/workspace/session-state/replacement.test.ts +121 -0
- package/src/shared/lib/workspace/session-state/store.ts +13 -2
- package/src/shared/lib/workspace/sidebar-toolbar.test.ts +98 -0
- package/src/shared/lib/workspace/sidebar-toolbar.ts +78 -0
- package/dist/client/static/js/index-yn17t04b.css +0 -1
|
@@ -157,7 +157,26 @@ function controlNames(): string[] {
|
|
|
157
157
|
return names;
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
-
|
|
160
|
+
/**
|
|
161
|
+
* Mount the cluster at a chosen viewport width.
|
|
162
|
+
*
|
|
163
|
+
* The width is load-bearing since the phone folds two controls into `⋯` only
|
|
164
|
+
* while the row cannot hold them (`MOBILE_FULL_ROW_PX`), so a case that does
|
|
165
|
+
* not say where it is mounted is asserting a layout nobody chose. happy-dom
|
|
166
|
+
* resolves `matchMedia` against `innerWidth`, so setting it is the same signal
|
|
167
|
+
* a real resize raises.
|
|
168
|
+
*/
|
|
169
|
+
async function mount(
|
|
170
|
+
layout: 'desktop' | 'mobile',
|
|
171
|
+
props: { editor?: boolean; switch?: boolean; width?: number } = {},
|
|
172
|
+
): Promise<void> {
|
|
173
|
+
// happy-dom resolves `matchMedia` against `innerWidth`, so writing it is the
|
|
174
|
+
// same signal a real resize raises.
|
|
175
|
+
Object.defineProperty(window, 'innerWidth', {
|
|
176
|
+
value: props.width ?? 1024,
|
|
177
|
+
configurable: true,
|
|
178
|
+
writable: true,
|
|
179
|
+
});
|
|
161
180
|
container = document.createElement('div');
|
|
162
181
|
document.body.appendChild(container);
|
|
163
182
|
await act(async () => {
|
|
@@ -205,12 +224,42 @@ describe('HeaderControls', () => {
|
|
|
205
224
|
]);
|
|
206
225
|
});
|
|
207
226
|
|
|
208
|
-
test('
|
|
209
|
-
await mount('mobile');
|
|
227
|
+
test('a narrow phone draws the row, minus the desktop-only pair', async () => {
|
|
228
|
+
await mount('mobile', { width: 390 });
|
|
210
229
|
|
|
211
230
|
// Install is desktop-only by decision: a phone's browser never fires the
|
|
212
231
|
// prompt (iOS has its own guide), so the control is not part of the phone's
|
|
213
232
|
// row. The editor column does not exist on the phone at all.
|
|
233
|
+
//
|
|
234
|
+
// At 390px reload and sign-out are rows of the `⋯` menu rather than
|
|
235
|
+
// standing in the row: that is what pays for the wordmark and the session
|
|
236
|
+
// title (see `HeaderControls`' own doc).
|
|
237
|
+
expect(controlNames()).toEqual([
|
|
238
|
+
'Demo Stats',
|
|
239
|
+
'Context usage',
|
|
240
|
+
'Open Right Panel',
|
|
241
|
+
'More actions',
|
|
242
|
+
]);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test('a narrow phone folds reload and sign-out into the ⋯ menu', async () => {
|
|
246
|
+
await mount('mobile', { width: 360 });
|
|
247
|
+
|
|
248
|
+
// Below `MOBILE_FULL_ROW_PX` the row cannot hold them beside a full-width
|
|
249
|
+
// picker, so they are rows of the `⋯` menu instead.
|
|
250
|
+
expect(controlNames()).toEqual([
|
|
251
|
+
'Demo Stats',
|
|
252
|
+
'Context usage',
|
|
253
|
+
'Open Right Panel',
|
|
254
|
+
'More actions',
|
|
255
|
+
]);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test('a phone wide enough for the whole row draws every control, no menu', async () => {
|
|
259
|
+
// 500px is past both thresholds (400 open, 436 with the lock), so nothing
|
|
260
|
+
// is folded: the fold is a width, not a device class.
|
|
261
|
+
await mount('mobile', { width: 500 });
|
|
262
|
+
|
|
214
263
|
expect(controlNames()).toEqual([
|
|
215
264
|
'Demo Stats',
|
|
216
265
|
'Reload page',
|
|
@@ -226,15 +275,32 @@ describe('HeaderControls', () => {
|
|
|
226
275
|
render(null, container);
|
|
227
276
|
container.remove();
|
|
228
277
|
|
|
229
|
-
await mount('mobile');
|
|
278
|
+
await mount('mobile', { width: 500 });
|
|
230
279
|
const mobile = controlNames();
|
|
231
280
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
expect(
|
|
235
|
-
expect(
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
281
|
+
// Context before the panel on both — the two controls a user presses while
|
|
282
|
+
// reading a run.
|
|
283
|
+
expect(desktop.indexOf('Context usage')).toBeLessThan(desktop.indexOf('Toggle Right Panel'));
|
|
284
|
+
expect(mobile.indexOf('Context usage')).toBeLessThan(mobile.indexOf('Open Right Panel'));
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test('the folded controls stay one click away, not absent', async () => {
|
|
288
|
+
await mount('mobile', { width: 360 });
|
|
289
|
+
|
|
290
|
+
expect(controlNames()).not.toContain('Reload page');
|
|
291
|
+
expect(controlNames()).not.toContain('Sign out');
|
|
292
|
+
|
|
293
|
+
const trigger = container.querySelector('button[aria-label="More actions"]') as HTMLElement;
|
|
294
|
+
expect(trigger).toBeTruthy();
|
|
295
|
+
expect(trigger.getAttribute('aria-expanded')).toBe('false');
|
|
296
|
+
await act(async () => {
|
|
297
|
+
trigger.click();
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
const menu = container.querySelector('[role="menu"]');
|
|
301
|
+
expect(menu).toBeTruthy();
|
|
302
|
+
expect(menu!.textContent).toContain('Reload page');
|
|
303
|
+
expect(menu!.textContent).toContain('Sign out');
|
|
304
|
+
expect(trigger.getAttribute('aria-expanded')).toBe('true');
|
|
239
305
|
});
|
|
240
306
|
});
|
|
@@ -4,10 +4,29 @@ import { ReloadPageButton } from '@/client/components/common/ReloadPageButton';
|
|
|
4
4
|
import { SignOutButton } from '@/client/components/common/SignOutButton';
|
|
5
5
|
import { ContextUsageButton } from '@/client/components/common/context-usage-button';
|
|
6
6
|
import { HeaderPanelButtons } from '@/client/components/common/header-panel-buttons';
|
|
7
|
+
import { HeaderMoreMenu } from '@/client/components/common/header-controls/MoreMenu';
|
|
8
|
+
import { useAuthEnabled, useAuthState } from '@/client/hooks/ui/auth';
|
|
9
|
+
import { useViewportAtLeast } from '@/client/hooks/ui/media-query';
|
|
7
10
|
import { withChord } from '@/client/lib/ui/with-chord';
|
|
8
11
|
|
|
9
12
|
export type HeaderLayout = 'desktop' | 'mobile';
|
|
10
13
|
|
|
14
|
+
/**
|
|
15
|
+
* The narrowest phone header that still holds every control in the row.
|
|
16
|
+
*
|
|
17
|
+
* Measured against the real header, with a long session title so the picker is
|
|
18
|
+
* at its 150px cap and cannot absorb the difference:
|
|
19
|
+
*
|
|
20
|
+
* - open instance, reload in the row: the picker reaches 150px at **400px**;
|
|
21
|
+
* - with a UI password, reload + lock: at **436px**.
|
|
22
|
+
*
|
|
23
|
+
* Below those the picker would start truncating the session title to pay for a
|
|
24
|
+
* control that is pressed once a session, so those two move into `⋯` instead.
|
|
25
|
+
* A wider phone or a rotated one gets them back — the fold is not a device
|
|
26
|
+
* class, it is the width at which the row stops fitting.
|
|
27
|
+
*/
|
|
28
|
+
const MOBILE_FULL_ROW_PX = { without: 400, withSignOut: 436 } as const;
|
|
29
|
+
|
|
11
30
|
interface HeaderControlsProps {
|
|
12
31
|
layout: HeaderLayout;
|
|
13
32
|
sessionId: string | null;
|
|
@@ -40,14 +59,30 @@ const ICON_SIZE: Record<HeaderLayout, number> = { desktop: 16, mobile: 18 };
|
|
|
40
59
|
*
|
|
41
60
|
* 1. Install (PWA) — desktop only
|
|
42
61
|
* 2. Plugin header slots
|
|
43
|
-
* 3. Reload page
|
|
62
|
+
* 3. Reload page — desktop; behind `⋯` on a phone
|
|
44
63
|
* 4. Context usage
|
|
45
|
-
* 5. Sign out (the lock)
|
|
64
|
+
* 5. Sign out (the lock) — desktop; behind `⋯` on a phone
|
|
46
65
|
* 6. Editor toggle — desktop only
|
|
47
66
|
* 7. Right panel
|
|
67
|
+
* 8. More actions (`⋯`) — phone only, in the slot the two moved controls
|
|
68
|
+
* left behind
|
|
48
69
|
*
|
|
49
70
|
* The dev-only layout switch sits AFTER that list: it is not part of the
|
|
50
71
|
* product's navbar and must not push a listed control out of the agreed order.
|
|
72
|
+
*
|
|
73
|
+
* ## Why a narrow phone folds two of them into a menu
|
|
74
|
+
*
|
|
75
|
+
* The row is 320–412px wide and already shares it with the wordmark, the slash
|
|
76
|
+
* and the session title — the only functional thing in the left group. Reload
|
|
77
|
+
* and sign-out are rare (a reload is Cmd/Ctrl+R; a sign-out happens once a
|
|
78
|
+
* session), while context and the right panel are read and pressed mid-turn.
|
|
79
|
+
* Measured with a UI password set and all four standing in the row, the title
|
|
80
|
+
* got 89px at 390 and 19px at 320 — one letter and an ellipsis.
|
|
81
|
+
*
|
|
82
|
+
* The fold is therefore a WIDTH, not a layout: `MOBILE_FULL_ROW_PX` is the
|
|
83
|
+
* narrowest header that still fits every control beside a full 150px picker,
|
|
84
|
+
* and above it the phone draws the same row the desktop does. Nothing is
|
|
85
|
+
* permanently unreachable, and nothing is hidden at a width that can hold it.
|
|
51
86
|
*/
|
|
52
87
|
export function HeaderControls({
|
|
53
88
|
layout,
|
|
@@ -60,14 +95,27 @@ export function HeaderControls({
|
|
|
60
95
|
}: HeaderControlsProps) {
|
|
61
96
|
const iconClass = ICON_BUTTON_CLASS[layout];
|
|
62
97
|
const size = ICON_SIZE[layout];
|
|
98
|
+
const authEnabled = useAuthEnabled();
|
|
99
|
+
const authState = useAuthState();
|
|
100
|
+
const signOutShown = authEnabled && authState !== 'required';
|
|
101
|
+
// Called unconditionally: a hook inside the ternary below would run in a
|
|
102
|
+
// different order on the two layouts.
|
|
103
|
+
const wideRow = useViewportAtLeast(signOutShown ? MOBILE_FULL_ROW_PX.withSignOut : MOBILE_FULL_ROW_PX.without);
|
|
104
|
+
const wideEnoughForAll = layout === 'desktop' || wideRow;
|
|
105
|
+
// A phone folds the two rare controls only while the row cannot hold them.
|
|
106
|
+
const folded = layout === 'mobile' && !wideEnoughForAll;
|
|
63
107
|
|
|
64
108
|
return (
|
|
65
109
|
<div className="flex items-center space-x-1">
|
|
66
110
|
{layout === 'desktop' && <PWAInstallButton />}
|
|
67
111
|
<HeaderPanelButtons layout={layout} />
|
|
68
|
-
|
|
112
|
+
{/* The cluster's ORDER is fixed; what changes on a narrow phone is that
|
|
113
|
+
reload and sign-out move behind `⋯`, which sits in the slot they
|
|
114
|
+
left. Nothing is hidden at a width that can hold it: measured, the
|
|
115
|
+
row fits every control at 400px open and 436px with the lock. */}
|
|
116
|
+
{(layout === 'desktop' || !folded) && <ReloadPageButton size={size} className={iconClass} />}
|
|
69
117
|
<ContextUsageButton sessionId={sessionId} layout={layout} />
|
|
70
|
-
<SignOutButton size={size} className={iconClass} />
|
|
118
|
+
{(layout === 'desktop' || !folded) && <SignOutButton size={size} className={iconClass} />}
|
|
71
119
|
{layout === 'desktop' && onToggleEditor && (
|
|
72
120
|
<button
|
|
73
121
|
type="button"
|
|
@@ -102,6 +150,7 @@ export function HeaderControls({
|
|
|
102
150
|
{showRightPanel ? <PanelRightClose size={size} /> : <PanelRight size={size} />}
|
|
103
151
|
</button>
|
|
104
152
|
)}
|
|
153
|
+
{folded && <HeaderMoreMenu />}
|
|
105
154
|
{layout === 'desktop' && onSwitchToMobile && (
|
|
106
155
|
<button
|
|
107
156
|
type="button"
|
|
@@ -25,7 +25,8 @@ import { h, render } from 'preact';
|
|
|
25
25
|
import { act } from 'preact/test-utils';
|
|
26
26
|
import { SessionStateProvider } from '@/client/components/common/session-state-provider/index';
|
|
27
27
|
import { useSessionState } from '@/client/hooks/workspace/session-state';
|
|
28
|
-
import {
|
|
28
|
+
import { adoptPendingSession } from '@/client/hooks/chat/omp/pending-sessions';
|
|
29
|
+
import { flushSession, forgetSession } from '@/shared/lib/workspace/session-state/store';
|
|
29
30
|
|
|
30
31
|
const DOM_GLOBALS = ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'HTMLInputElement', 'Event', 'MouseEvent', 'KeyboardEvent'] as const;
|
|
31
32
|
/** The runner's own globals, restored on teardown — deleting them would strip natives (Event/CustomEvent) every later file needs. */const nativeGlobals: Partial<Record<(typeof DOM_GLOBALS)[number], unknown>> = {};
|
|
@@ -139,4 +140,24 @@ describe('SessionStateProvider on a pending session', () => {
|
|
|
139
140
|
expect(calls.some((c) => c.url === '/api/sessions/real-9/state' && c.method === 'GET')).toBe(true);
|
|
140
141
|
expect(calls.some((c) => c.method === 'POST')).toBe(false);
|
|
141
142
|
});
|
|
143
|
+
|
|
144
|
+
test('a spawn adoption carries the panels the user opened onto the real id', async () => {
|
|
145
|
+
// The reported bug: open a new chat, open some right-panel views, send the
|
|
146
|
+
// first prompt — the spawn renames the session and everything the user had
|
|
147
|
+
// opened closed again. The provider migrates the pending slot in its OWN
|
|
148
|
+
// effect, which Preact runs AFTER its children's: each `useSessionState`
|
|
149
|
+
// copy had already re-read the (still empty) adopted slot, landed on its
|
|
150
|
+
// fallback, and never re-read because `ready` does not move across an
|
|
151
|
+
// adoption.
|
|
152
|
+
const pending = 'new-test-adopt';
|
|
153
|
+
const el = await mountPending(pending);
|
|
154
|
+
(el.querySelector('button') as HTMLButtonElement).click();
|
|
155
|
+
await act(async () => {});
|
|
156
|
+
|
|
157
|
+
adoptPendingSession(pending, 'omp-adopted', 1);
|
|
158
|
+
await switchTo(el, 'omp-adopted');
|
|
159
|
+
|
|
160
|
+
expect(el.textContent).toContain('git');
|
|
161
|
+
forgetSession('omp-adopted');
|
|
162
|
+
});
|
|
142
163
|
});
|
|
@@ -1,10 +1,16 @@
|
|
|
1
|
-
import { Info, Settings } from 'lucide-preact';
|
|
1
|
+
import { Download, Info, Settings } from 'lucide-preact';
|
|
2
|
+
import {
|
|
3
|
+
androidDownloadLabel,
|
|
4
|
+
type AndroidDownload,
|
|
5
|
+
} from '@/client/hooks/ui/updates/android-download';
|
|
2
6
|
|
|
3
7
|
interface SessionSidebarFooterProps {
|
|
4
8
|
onSettings: () => void;
|
|
5
9
|
onInfo: () => void;
|
|
6
10
|
updateAvailable?: boolean;
|
|
7
11
|
onUpdateClick?: () => void;
|
|
12
|
+
/** The APK to offer, or null. Set inside the app (an update) or on an Android browser (an install). */
|
|
13
|
+
androidDownload?: AndroidDownload | null;
|
|
8
14
|
}
|
|
9
15
|
|
|
10
16
|
/**
|
|
@@ -17,7 +23,7 @@ interface SessionSidebarFooterProps {
|
|
|
17
23
|
* is unreachable by keyboard"). It is also what lets a browser spec address
|
|
18
24
|
* these controls by role and name instead of by their position in the DOM.
|
|
19
25
|
*/
|
|
20
|
-
export function SessionSidebarFooter({ onSettings, onInfo, updateAvailable, onUpdateClick }: SessionSidebarFooterProps) {
|
|
26
|
+
export function SessionSidebarFooter({ onSettings, onInfo, updateAvailable, onUpdateClick, androidDownload }: SessionSidebarFooterProps) {
|
|
21
27
|
return (
|
|
22
28
|
<div className="h-10 px-3 border-t border-ink/10 flex items-center justify-between text-ink/60 shrink-0">
|
|
23
29
|
<div className="flex space-x-1">
|
|
@@ -39,6 +45,22 @@ export function SessionSidebarFooter({ onSettings, onInfo, updateAvailable, onUp
|
|
|
39
45
|
>
|
|
40
46
|
<Info size={16} />
|
|
41
47
|
</button>
|
|
48
|
+
{androidDownload && (
|
|
49
|
+
// Next to About. Two reasons to be here, and the label says which:
|
|
50
|
+
// an installed APK that is behind (an update), or an Android browser
|
|
51
|
+
// whose reader may not know the shell exists (an install). A desktop
|
|
52
|
+
// browser gets neither — see `useAndroidDownload`.
|
|
53
|
+
<a
|
|
54
|
+
href={androidDownload.url}
|
|
55
|
+
target="_blank"
|
|
56
|
+
rel="noopener noreferrer"
|
|
57
|
+
title={androidDownloadLabel(androidDownload)}
|
|
58
|
+
aria-label={androidDownloadLabel(androidDownload)}
|
|
59
|
+
className="p-1 rounded hover:text-ink hover:bg-ink/5 cursor-pointer transition-colors"
|
|
60
|
+
>
|
|
61
|
+
<Download size={16} />
|
|
62
|
+
</a>
|
|
63
|
+
)}
|
|
42
64
|
</div>
|
|
43
65
|
{updateAvailable && (
|
|
44
66
|
<button
|
|
@@ -39,6 +39,14 @@ interface ToolbarOverflowMenuProps {
|
|
|
39
39
|
sortHidden: boolean;
|
|
40
40
|
sortOption: SessionSortOption;
|
|
41
41
|
onSortChange: (option: SessionSortOption) => void;
|
|
42
|
+
/**
|
|
43
|
+
* The row this menu sits in. Both layouts share the RULE and the menu, but
|
|
44
|
+
* their controls are different sizes — the desktop row is `w-7` in a 34px
|
|
45
|
+
* step, the phone's drawer is `w-9.5` in a 46px one — so the trigger and the
|
|
46
|
+
* sort rows have to match the controls beside them. A `⋯` drawn at desktop
|
|
47
|
+
* metrics in a drawer of 38px buttons reads as a control from another screen.
|
|
48
|
+
*/
|
|
49
|
+
layout?: 'desktop' | 'mobile';
|
|
42
50
|
}
|
|
43
51
|
|
|
44
52
|
export function ToolbarOverflowMenu({
|
|
@@ -46,7 +54,9 @@ export function ToolbarOverflowMenu({
|
|
|
46
54
|
sortHidden,
|
|
47
55
|
sortOption,
|
|
48
56
|
onSortChange,
|
|
57
|
+
layout = 'desktop',
|
|
49
58
|
}: ToolbarOverflowMenuProps) {
|
|
59
|
+
const mobile = layout === 'mobile';
|
|
50
60
|
const [open, setOpen] = useState(false);
|
|
51
61
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
52
62
|
useOnClickOutside(rootRef, () => setOpen(false));
|
|
@@ -75,19 +85,25 @@ export function ToolbarOverflowMenu({
|
|
|
75
85
|
aria-haspopup="menu"
|
|
76
86
|
aria-expanded={open}
|
|
77
87
|
onClick={() => setOpen(!open)}
|
|
78
|
-
className={`
|
|
88
|
+
className={`inline-flex items-center justify-center transition-colors cursor-pointer ${
|
|
89
|
+
mobile
|
|
90
|
+
? 'w-9.5 h-9.5 rounded-xl border active:scale-95 shadow-xs'
|
|
91
|
+
: 'w-7 h-7 rounded border'
|
|
92
|
+
} ${
|
|
79
93
|
open
|
|
80
94
|
? 'border-ink/30 bg-ink/10 text-ink'
|
|
81
95
|
: 'border-ink/15 bg-paper text-ink/70 hover:bg-ink/5 hover:text-ink'
|
|
82
96
|
}`}
|
|
83
97
|
>
|
|
84
|
-
<Ellipsis size={14} />
|
|
98
|
+
<Ellipsis size={mobile ? 16 : 14} />
|
|
85
99
|
</button>
|
|
86
100
|
|
|
87
101
|
{open && (
|
|
88
102
|
<div
|
|
89
103
|
role="menu"
|
|
90
|
-
className=
|
|
104
|
+
className={`absolute right-0 top-full mt-2 w-52 bg-paper border border-ink/15 shadow-lg z-50 py-1 text-xs ${
|
|
105
|
+
mobile ? 'rounded-xl p-2' : 'rounded-lg'
|
|
106
|
+
}`}
|
|
91
107
|
>
|
|
92
108
|
{actions.map((action) => (
|
|
93
109
|
<button
|
|
@@ -96,7 +112,9 @@ export function ToolbarOverflowMenu({
|
|
|
96
112
|
role="menuitem"
|
|
97
113
|
onClick={() => run(action)}
|
|
98
114
|
aria-pressed={action.active}
|
|
99
|
-
className={`w-full text-left
|
|
115
|
+
className={`w-full text-left flex items-center gap-2 transition-colors ${
|
|
116
|
+
mobile ? 'px-2.5 py-1.5 rounded-lg' : 'px-3 py-1.5'
|
|
117
|
+
} ${
|
|
100
118
|
action.active ? 'bg-ink/5 text-ink font-medium' : 'text-ink/75 hover:bg-ink/5 hover:text-ink'
|
|
101
119
|
}`}
|
|
102
120
|
>
|
|
@@ -107,7 +125,7 @@ export function ToolbarOverflowMenu({
|
|
|
107
125
|
|
|
108
126
|
{sortHidden && (
|
|
109
127
|
<div className="mt-1 border-t border-ink/10 pt-1">
|
|
110
|
-
<SortMenu variant=
|
|
128
|
+
<SortMenu variant={layout} sortOption={sortOption} onSortChange={onSortChange} />
|
|
111
129
|
</div>
|
|
112
130
|
)}
|
|
113
131
|
</div>
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { Archive, ArrowUpDown, Calendar, FolderPlus, ListChecks, Plus, RefreshCw, Search, X } from 'lucide-preact';
|
|
2
|
-
import { useLayoutEffect, useRef, useState } from 'preact/hooks';
|
|
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,
|
|
@@ -38,37 +38,6 @@ interface SessionSidebarToolbarProps {
|
|
|
38
38
|
selecting: boolean;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
/**
|
|
42
|
-
* The row's content width, kept current as the sidebar is dragged.
|
|
43
|
-
*
|
|
44
|
-
* The capacity decision needs the width the row actually has, and the sidebar
|
|
45
|
-
* is resizable — a `ResizeObserver` is what makes the set of visible controls
|
|
46
|
-
* follow a drag instead of being decided once at mount.
|
|
47
|
-
*/
|
|
48
|
-
function useRowWidth() {
|
|
49
|
-
const ref = useRef<HTMLDivElement>(null);
|
|
50
|
-
const [width, setWidth] = useState(0);
|
|
51
|
-
// A layout effect, so the first measure lands BEFORE paint: with a passive
|
|
52
|
-
// effect the row drew every control and then dropped the ones that did not
|
|
53
|
-
// fit on the next frame.
|
|
54
|
-
useLayoutEffect(() => {
|
|
55
|
-
const element = ref.current;
|
|
56
|
-
if (!element) return;
|
|
57
|
-
// `clientWidth` includes the row's own `px-1`, and the capacity budget is
|
|
58
|
-
// a CONTENT width — leaving the padding in made the row 8px optimistic,
|
|
59
|
-
// which is exactly the margin an extra icon needs.
|
|
60
|
-
const measure = () => setWidth(Math.max(0, element.clientWidth - TOOLBAR_ROW_PADDING_PX));
|
|
61
|
-
measure();
|
|
62
|
-
if (typeof ResizeObserver === 'undefined') return;
|
|
63
|
-
// The sidebar is resizable, so the visible set has to follow a drag
|
|
64
|
-
// instead of being decided once at mount.
|
|
65
|
-
const observer = new ResizeObserver(measure);
|
|
66
|
-
observer.observe(element);
|
|
67
|
-
return () => observer.disconnect();
|
|
68
|
-
}, []);
|
|
69
|
-
return { ref, width };
|
|
70
|
-
}
|
|
71
|
-
|
|
72
41
|
const ICON_CLASS = 'shrink-0 w-7 h-7 inline-flex items-center justify-center rounded border transition-colors cursor-pointer';
|
|
73
42
|
const ICON_IDLE = 'border-ink/15 bg-paper text-ink/70 hover:bg-ink/5 hover:text-ink';
|
|
74
43
|
const ICON_ACTIVE = 'border-ink/30 bg-ink/10 text-ink';
|
|
@@ -95,7 +64,7 @@ export function SessionSidebarToolbar({
|
|
|
95
64
|
onSelectWorkspaces,
|
|
96
65
|
selecting,
|
|
97
66
|
}: SessionSidebarToolbarProps) {
|
|
98
|
-
const { ref: rowRef, width } =
|
|
67
|
+
const { ref: rowRef, width } = useToolbarRowWidth(TOOLBAR_ROW_PADDING_PX);
|
|
99
68
|
const capacity = sidebarToolbarCapacity(width);
|
|
100
69
|
const visible = new Set(SIDEBAR_TOOLBAR_PRIORITY.slice(0, capacity));
|
|
101
70
|
const hidden = SIDEBAR_TOOLBAR_PRIORITY.slice(capacity);
|
|
@@ -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';
|
|
@@ -56,6 +57,12 @@ export function SessionSidebar({ className = '', onClose, appSettings = {}, stre
|
|
|
56
57
|
const [schedulerOpen, setSchedulerOpen] = useState(false);
|
|
57
58
|
const scheduledCount = useScheduledTaskCount();
|
|
58
59
|
const updates = useUpdates();
|
|
60
|
+
// The Android offer: an UPDATE inside the APK, an INSTALL on an Android
|
|
61
|
+
// browser, and nothing anywhere else — see `useAndroidDownload`.
|
|
62
|
+
const androidDownload = useAndroidDownload(
|
|
63
|
+
window.__OMP_BOOTSTRAP__?.shellVersion,
|
|
64
|
+
window.__OMP_BOOTSTRAP__?.isAndroid,
|
|
65
|
+
);
|
|
59
66
|
const { toasts, pushToast, dismissToast } = useToasts();
|
|
60
67
|
|
|
61
68
|
// Sidebar inline states
|
|
@@ -212,6 +219,7 @@ export function SessionSidebar({ className = '', onClose, appSettings = {}, stre
|
|
|
212
219
|
<SessionSidebarFooter
|
|
213
220
|
onSettings={() => setSettingsOpen(true)}
|
|
214
221
|
onInfo={() => setInfoOpen(true)}
|
|
222
|
+
androidDownload={androidDownload}
|
|
215
223
|
updateAvailable={updates.hasUpdate}
|
|
216
224
|
onUpdateClick={() => {
|
|
217
225
|
setInfoOpen(true);
|
|
@@ -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;
|
|
@@ -5,6 +5,7 @@ import { MobileSessionList } from '@/client/components/mobile/mobile-session-sid
|
|
|
5
5
|
import { MobileSessionFooter } from '@/client/components/mobile/mobile-session-sidebar/Footer';
|
|
6
6
|
import { ToastStack } from '@/client/components/common/ToastStack';
|
|
7
7
|
import { AboutModal, NewWorkspaceModal } from '@/client/components/layout/session-sidebar/Modals';
|
|
8
|
+
import { useAndroidDownload } from '@/client/hooks/ui/updates/android-download';
|
|
8
9
|
import { SchedulerModal } from '@/client/components/layout/session-sidebar/scheduler-modal';
|
|
9
10
|
import { UpdatePopup } from '@/client/components/layout/update-popup';
|
|
10
11
|
import { useOnClickOutside } from '@/client/hooks/ui/on-click-outside';
|
|
@@ -72,6 +73,12 @@ export function MobileSessionSidebar({
|
|
|
72
73
|
const scheduledCount = useScheduledTaskCount();
|
|
73
74
|
const [aboutOpen, setAboutOpen] = useState(false);
|
|
74
75
|
const updates = useUpdates();
|
|
76
|
+
// An update inside the APK, an install on an Android browser, nothing
|
|
77
|
+
// elsewhere — see `useAndroidDownload`.
|
|
78
|
+
const androidDownload = useAndroidDownload(
|
|
79
|
+
window.__OMP_BOOTSTRAP__?.shellVersion,
|
|
80
|
+
window.__OMP_BOOTSTRAP__?.isAndroid,
|
|
81
|
+
);
|
|
75
82
|
const { toasts, pushToast, dismissToast } = useToasts();
|
|
76
83
|
|
|
77
84
|
// The popup's "Update now" hands the run over rather than starting its own —
|
|
@@ -242,6 +249,7 @@ export function MobileSessionSidebar({
|
|
|
242
249
|
<MobileSessionFooter
|
|
243
250
|
onSettings={() => window.dispatchEvent(new CustomEvent('omp:open-settings', { detail: {} }))}
|
|
244
251
|
onAbout={() => setAboutOpen(true)}
|
|
252
|
+
androidDownload={androidDownload}
|
|
245
253
|
onDesktopToggle={onDesktopToggle}
|
|
246
254
|
updateAvailable={updates.hasUpdate}
|
|
247
255
|
onUpdateClick={() => {
|
|
@@ -88,7 +88,7 @@ export function MobileHeader({
|
|
|
88
88
|
|
|
89
89
|
return (
|
|
90
90
|
<header
|
|
91
|
-
className="flex-shrink-0 bg-canvas border-b border-ink/10 flex items-center justify-between px-3 z-20 titlebar-drag-region select-none"
|
|
91
|
+
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
92
|
style={{
|
|
93
93
|
height: 'calc(3.5rem + env(safe-area-inset-top, 0px))',
|
|
94
94
|
paddingLeft: 'max(0.75rem, env(safe-area-inset-left, 0px), env(titlebar-area-x, 0px))',
|
|
@@ -96,8 +96,22 @@ export function MobileHeader({
|
|
|
96
96
|
paddingTop: 'env(safe-area-inset-top, 0px)',
|
|
97
97
|
}}
|
|
98
98
|
>
|
|
99
|
-
{/* Left: hamburger, app title & session dropdown
|
|
100
|
-
|
|
99
|
+
{/* Left: hamburger, app title & session dropdown.
|
|
100
|
+
`flex-1 min-w-0` is what makes the title the ONLY part that gives up
|
|
101
|
+
width: the hamburger, wordmark and slash are `flex-shrink-0`, so the
|
|
102
|
+
row shrinks to the control cluster's width and the picker truncates
|
|
103
|
+
inside it. Without `flex-1` the row kept its max-content width and
|
|
104
|
+
the picker's 150px button spilled over the reload control.
|
|
105
|
+
|
|
106
|
+
The wordmark and its slash are DROPPED below 420px. They are fixed
|
|
107
|
+
chrome (53px + 4px + 16px of gaps = 73px, 19% of a 390px header) that
|
|
108
|
+
competes with the one functional thing in the row: the session title.
|
|
109
|
+
With a password set the cluster is 140px wide (reload, context, lock,
|
|
110
|
+
panel), which leaves the title `vw − 285px` — measured 89px at 390,
|
|
111
|
+
59px at 360 and 19px at 320. The brand is not lost: the drawer header
|
|
112
|
+
draws it, as does the desktop navbar. Same decision, same reason, as
|
|
113
|
+
the desktop navbar's `hidden sm:flex` on its own wordmark. */}
|
|
114
|
+
<div className="flex items-center space-x-2 flex-1 min-w-0 titlebar-no-drag">
|
|
101
115
|
<button
|
|
102
116
|
type="button"
|
|
103
117
|
onClick={onOpenSessionSidebar}
|
|
@@ -108,6 +122,10 @@ export function MobileHeader({
|
|
|
108
122
|
<Menu size={20} strokeWidth={2} />
|
|
109
123
|
</button>
|
|
110
124
|
|
|
125
|
+
{/* The wordmark is drawn at EVERY width. The width it needs was bought
|
|
126
|
+
back from the control cluster instead: reload and sign out moved
|
|
127
|
+
into the `⋯` menu (`HeaderControls`), which is what pays for the
|
|
128
|
+
wordmark plus the slash beside it. */}
|
|
111
129
|
<span className="relative font-bold text-sm tracking-tight flex items-center flex-shrink-0">
|
|
112
130
|
<BrandWordmark />
|
|
113
131
|
<span className="absolute -top-0.5 -right-1.5 flex">
|
|
@@ -117,11 +135,15 @@ export function MobileHeader({
|
|
|
117
135
|
|
|
118
136
|
<span className="text-ink/25 font-light text-xs flex-shrink-0 select-none">/</span>
|
|
119
137
|
|
|
120
|
-
<div
|
|
138
|
+
<div
|
|
139
|
+
className="relative min-w-0 flex-1 max-w-[150px]"
|
|
140
|
+
ref={sessionPickerRef}
|
|
141
|
+
data-testid="session-picker"
|
|
142
|
+
>
|
|
121
143
|
<button
|
|
122
144
|
type="button"
|
|
123
145
|
onClick={() => setShowSessionPicker(!showSessionPicker)}
|
|
124
|
-
className="flex items-center space-x-1 text-xs font-semibold text-ink hover:text-ink/80 transition-colors py-1
|
|
146
|
+
className="flex w-full items-center space-x-1 text-xs font-semibold text-ink hover:text-ink/80 transition-colors py-1 min-w-0"
|
|
125
147
|
title={displayTitle}
|
|
126
148
|
>
|
|
127
149
|
<span className="truncate">{displayTitle}</span>
|
|
@@ -1,11 +1,17 @@
|
|
|
1
|
-
import { Info, Monitor, Settings } from 'lucide-preact';
|
|
1
|
+
import { Download, Info, Monitor, Settings } from 'lucide-preact';
|
|
2
2
|
import packageJson from '@/../package.json';
|
|
3
|
+
import {
|
|
4
|
+
androidDownloadLabel,
|
|
5
|
+
type AndroidDownload,
|
|
6
|
+
} from '@/client/hooks/ui/updates/android-download';
|
|
3
7
|
|
|
4
8
|
interface MobileSessionFooterProps {
|
|
5
9
|
onSettings: () => void;
|
|
6
10
|
onAbout: () => void;
|
|
7
11
|
updateAvailable?: boolean;
|
|
8
12
|
onUpdateClick?: () => void;
|
|
13
|
+
/** The APK to offer, or null. Set inside the app (an update) or on an Android browser (an install). */
|
|
14
|
+
androidDownload?: AndroidDownload | null;
|
|
9
15
|
/** Switch the app to the desktop layout for this page session only. The
|
|
10
16
|
* choice is not persisted, so the next load re-detects the device. */
|
|
11
17
|
onDesktopToggle?: () => void;
|
|
@@ -17,6 +23,7 @@ export function MobileSessionFooter({
|
|
|
17
23
|
updateAvailable,
|
|
18
24
|
onUpdateClick,
|
|
19
25
|
onDesktopToggle,
|
|
26
|
+
androidDownload,
|
|
20
27
|
}: MobileSessionFooterProps) {
|
|
21
28
|
return (
|
|
22
29
|
<div
|
|
@@ -40,6 +47,27 @@ export function MobileSessionFooter({
|
|
|
40
47
|
<Info size={14} />
|
|
41
48
|
<span>About</span>
|
|
42
49
|
</button>
|
|
50
|
+
{androidDownload && (
|
|
51
|
+
// Directly after About, matching the desktop footer. An `<a>`, not a
|
|
52
|
+
// button: the download is the browser's (or the WebView's) job, and
|
|
53
|
+
// `target="_blank"` hands it to the system browser so the console is
|
|
54
|
+
// not replaced by a file download.
|
|
55
|
+
//
|
|
56
|
+
// The visible word is the OFFER, not the file type: `APK` told a
|
|
57
|
+
// reader what the bytes are but not what the control does, and on a
|
|
58
|
+
// browser it is the only place the native shell is ever mentioned.
|
|
59
|
+
<a
|
|
60
|
+
href={androidDownload.url}
|
|
61
|
+
target="_blank"
|
|
62
|
+
rel="noopener noreferrer"
|
|
63
|
+
title={androidDownloadLabel(androidDownload)}
|
|
64
|
+
aria-label={androidDownloadLabel(androidDownload)}
|
|
65
|
+
className="flex items-center space-x-1 hover:text-ink transition-colors"
|
|
66
|
+
>
|
|
67
|
+
<Download size={14} />
|
|
68
|
+
<span>{androidDownload.kind === 'update' ? 'Update APK' : 'Get APK'}</span>
|
|
69
|
+
</a>
|
|
70
|
+
)}
|
|
43
71
|
{onDesktopToggle && (
|
|
44
72
|
<button
|
|
45
73
|
type="button"
|