@kabmo-labs/kabmo 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +285 -285
  3. package/dist/client/static/js/index-pdp5dwc8.css +1 -0
  4. package/dist/client/static/js/{index-enkbbffs.js → index-zsskckw7.js} +671 -671
  5. package/package.json +1 -1
  6. package/src/client/bootstrap.d.ts +8 -0
  7. package/src/client/components/common/header-controls/MoreMenu.tsx +136 -0
  8. package/src/client/components/common/header-controls/index.test.tsx +77 -11
  9. package/src/client/components/common/header-controls/index.tsx +53 -4
  10. package/src/client/components/common/session-state-provider/index.test.ts +22 -1
  11. package/src/client/components/layout/session-sidebar/Footer.tsx +20 -2
  12. package/src/client/components/layout/session-sidebar/OverflowMenu.tsx +23 -5
  13. package/src/client/components/layout/session-sidebar/Toolbar.tsx +2 -33
  14. package/src/client/components/layout/session-sidebar/index.tsx +5 -0
  15. package/src/client/components/mobile/LayoutWrapper.tsx +32 -1
  16. package/src/client/components/mobile/SessionSidebar.tsx +5 -0
  17. package/src/client/components/mobile/mobile-main-view/Header.tsx +27 -5
  18. package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +21 -1
  19. package/src/client/components/mobile/mobile-session-sidebar/Toolbar.test.ts +104 -0
  20. package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +166 -106
  21. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +9 -0
  22. package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +6 -0
  23. package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +4 -1
  24. package/src/client/components/workspace/chat-timeline/chat-input/DropOverlay.tsx +1 -1
  25. package/src/client/components/workspace/chat-timeline/chat-input/GoalBanner.tsx +6 -1
  26. package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +4 -1
  27. package/src/client/components/workspace/chat-timeline/chat-input/VoiceNotice.tsx +2 -2
  28. package/src/client/components/workspace/chat-timeline/chat-input/expansion.test.ts +72 -1
  29. package/src/client/components/workspace/chat-timeline/chat-input/expansion.ts +28 -4
  30. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +20 -2
  31. package/src/client/components/workspace/chat-timeline/chat-input/props.ts +7 -0
  32. package/src/client/components/workspace/chat-timeline/index.tsx +9 -3
  33. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks.test.tsx +127 -0
  34. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks.tsx +71 -0
  35. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/PluginPendingInteractions.test.ts +1 -0
  36. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -2
  37. package/src/client/components/workspace/git-panel/BranchToolbar.tsx +4 -0
  38. package/src/client/components/workspace/git-panel/index.test.ts +15 -0
  39. package/src/client/components/workspace/search-panel/Body.test.ts +149 -0
  40. package/src/client/components/workspace/search-panel/Body.tsx +53 -9
  41. package/src/client/hooks/chat/timeline/ask-frames-grouped.test.ts +46 -2
  42. package/src/client/hooks/chat/timeline/ask-frames.test.ts +1 -1
  43. package/src/client/hooks/chat/timeline/ask-frames.ts +79 -29
  44. package/src/client/hooks/chat/timeline/index.ts +2 -1
  45. package/src/client/hooks/chat/timeline/scroll-probe.test-util.ts +227 -0
  46. package/src/client/hooks/chat/timeline/scroll-reading-back.test.ts +67 -0
  47. package/src/client/hooks/chat/timeline/scroll.test.ts +84 -226
  48. package/src/client/hooks/chat/timeline/scroll.ts +34 -1
  49. package/src/client/hooks/ui/media-query.ts +41 -0
  50. package/src/client/hooks/ui/updates/android-download.test.ts +73 -0
  51. package/src/client/hooks/ui/updates/android-download.ts +106 -0
  52. package/src/client/hooks/workspace/file-tabs.test.ts +165 -0
  53. package/src/client/hooks/workspace/file-tabs.ts +24 -11
  54. package/src/client/hooks/workspace/session-state/index.ts +24 -1
  55. package/src/client/hooks/workspace/toolbar-row-width.ts +43 -0
  56. package/src/client/lib/native/back.test.ts +108 -0
  57. package/src/client/lib/native/back.ts +60 -0
  58. package/src/client/main.tsx +6 -0
  59. package/src/server/lib/fs/git-actions-branch.ts +18 -1
  60. package/src/server/lib/fs/git-actions-sync.test.ts +144 -0
  61. package/src/server/lib/fs/git-publish.test.ts +96 -0
  62. package/src/server/lib/fs/git-publish.ts +35 -3
  63. package/src/server/lib/fs/listing.test.ts +86 -0
  64. package/src/server/lib/fs/listing.ts +20 -6
  65. package/src/server/lib/updates/android-release.test.ts +92 -0
  66. package/src/server/lib/updates/android-release.ts +148 -0
  67. package/src/server/plugins/ssr.ts +9 -2
  68. package/src/server/routes/fs/read.ts +24 -9
  69. package/src/server/routes/sessions/batch-delete.test.ts +14 -11
  70. package/src/server/routes/updates/android.ts +22 -0
  71. package/src/server/routes/updates/index.ts +2 -0
  72. package/src/shared/lib/updates/shell-ua.test.ts +54 -0
  73. package/src/shared/lib/updates/shell-ua.ts +36 -0
  74. package/src/shared/lib/workspace/session-state/listeners.ts +38 -0
  75. package/src/shared/lib/workspace/session-state/replacement.test.ts +121 -0
  76. package/src/shared/lib/workspace/session-state/store.ts +13 -2
  77. package/src/shared/lib/workspace/sidebar-toolbar.test.ts +98 -0
  78. package/src/shared/lib/workspace/sidebar-toolbar.ts +78 -0
  79. package/dist/client/static/js/index-yn17t04b.css +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kabmo-labs/kabmo",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
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>",
@@ -22,6 +22,14 @@ export interface KabmoBootstrap {
22
22
  * while this fact was missing, and the only way to ask was a second request.
23
23
  */
24
24
  authEnabled?: boolean;
25
+ /**
26
+ * The Android shell's version, when this page was requested by the APK
27
+ * (`KabmoAndroid/<version>` in the User-Agent). Null in every browser.
28
+ *
29
+ * The sidebar uses it to decide whether a download button applies at all, and
30
+ * whether this install is behind the published APK.
31
+ */
32
+ shellVersion?: string | null;
25
33
  }
26
34
 
27
35
  declare global {
@@ -0,0 +1,136 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The phone header's `⋯` menu: the two controls that do not need a slot.
8
+ *
9
+ * Reload and sign-out are rare actions, and each costs 36px of a header that is
10
+ * already sharing 320–412px between the wordmark, the session title and the
11
+ * four controls that ARE used mid-conversation (context, panel, and whatever a
12
+ * plugin contributes). Behind `⋯` they still cost 36px — but 36px ONCE, for
13
+ * both, against 72px standing in the row.
14
+ *
15
+ * This is the same answer the sidebar toolbar reached when its row ran out of
16
+ * room (`ToolbarOverflowMenu`): hiding a control outright makes it unreachable
17
+ * at one width, and letting the row overflow hides it behind a scroll with
18
+ * nothing on screen to say so. A menu keeps it one click away and names it in
19
+ * words rather than as a glyph.
20
+ *
21
+ * ## Sign-out confirms HERE, not in the row it came from
22
+ *
23
+ * `SignOutButton` is a two-press control because it sits a pixel from reload.
24
+ * A menu already costs an open and a click, so the confirmation is the first
25
+ * row press: the row turns into `Click again to sign out` and the second press
26
+ * ends the session. The safety property is unchanged — one stray click still
27
+ * cannot sign the user out — and focus never leaves the menu.
28
+ */
29
+
30
+ import { Ellipsis, Lock, LockOpen, RefreshCw } from 'lucide-preact';
31
+ import { useEffect, useRef, useState } from 'preact/hooks';
32
+ import { useOnClickOutside } from '@/client/hooks/ui/on-click-outside';
33
+ import { logout, useAuthEnabled, useAuthState } from '@/client/hooks/ui/auth';
34
+
35
+ const ROW_CLASS =
36
+ 'w-full text-left px-3 py-2 flex items-center gap-2 transition-colors text-ink/75 hover:bg-ink/5 hover:text-ink cursor-pointer';
37
+
38
+ export function HeaderMoreMenu() {
39
+ const authEnabled = useAuthEnabled();
40
+ const authState = useAuthState();
41
+ const [open, setOpen] = useState(false);
42
+ const [confirming, setConfirming] = useState(false);
43
+ const [busy, setBusy] = useState(false);
44
+ const rootRef = useRef<HTMLDivElement>(null);
45
+ useOnClickOutside(rootRef, () => setOpen(false));
46
+
47
+ const canSignOut = authEnabled && authState !== 'required';
48
+ // The open flag is mirrored into a ref so the Escape listener can be bound
49
+ // ONCE, at mount. Binding it from an effect keyed on `open` lost the first
50
+ // keypress after the click that opened the menu — the effect had not run yet
51
+ // — and a menu that swallows Escape intermittently is worse than one that
52
+ // never does, because the user cannot tell which they are looking at.
53
+ const openRef = useRef(open);
54
+ openRef.current = open;
55
+
56
+ // A menu left open while the session ends elsewhere would offer an action for
57
+ // a session that no longer exists — the same rule `SignOutButton` applies.
58
+ useEffect(() => {
59
+ if (!canSignOut) setConfirming(false);
60
+ }, [canSignOut]);
61
+
62
+ useEffect(() => {
63
+ const onKeyDown = (event: globalThis.KeyboardEvent) => {
64
+ if (event.key !== 'Escape' || !openRef.current) return;
65
+ setOpen(false);
66
+ setConfirming(false);
67
+ };
68
+ document.addEventListener('keydown', onKeyDown);
69
+ return () => document.removeEventListener('keydown', onKeyDown);
70
+ }, []);
71
+
72
+ const signOut = async () => {
73
+ if (busy) return;
74
+ if (!confirming) {
75
+ setConfirming(true);
76
+ return;
77
+ }
78
+ setBusy(true);
79
+ // `logout` reloads into the login screen; a failure still leaves no usable
80
+ // session, so there is nothing to recover here.
81
+ await logout();
82
+ };
83
+
84
+ return (
85
+ <div className="relative" ref={rootRef}>
86
+ <button
87
+ type="button"
88
+ onClick={() => setOpen((current) => !current)}
89
+ className={`w-8 h-8 rounded-lg flex items-center justify-center transition-colors cursor-pointer ${
90
+ open ? 'bg-ink/10 text-ink' : 'text-ink hover:bg-ink/5 active:bg-ink/10'
91
+ }`}
92
+ title="More actions"
93
+ aria-label="More actions"
94
+ aria-haspopup="menu"
95
+ aria-expanded={open}
96
+ >
97
+ <Ellipsis size={18} />
98
+ </button>
99
+
100
+ {open && (
101
+ <div
102
+ role="menu"
103
+ className="absolute right-0 top-full mt-1 w-44 bg-paper border border-ink/15 rounded-xl shadow-lg z-50 py-1 text-xs"
104
+ >
105
+ <button
106
+ type="button"
107
+ role="menuitem"
108
+ onClick={() => {
109
+ setOpen(false);
110
+ window.location.reload();
111
+ }}
112
+ className={ROW_CLASS}
113
+ >
114
+ <RefreshCw size={14} />
115
+ <span>Reload page</span>
116
+ </button>
117
+
118
+ {canSignOut && (
119
+ <button
120
+ type="button"
121
+ role="menuitem"
122
+ onClick={signOut}
123
+ disabled={busy}
124
+ className={ROW_CLASS}
125
+ aria-expanded={confirming}
126
+ data-confirming={confirming ? 'true' : undefined}
127
+ >
128
+ {confirming ? <LockOpen size={14} /> : <Lock size={14} />}
129
+ <span>{confirming ? 'Click again to sign out' : 'Sign out'}</span>
130
+ </button>
131
+ )}
132
+ </div>
133
+ )}
134
+ </div>
135
+ );
136
+ }
@@ -157,7 +157,26 @@ function controlNames(): string[] {
157
157
  return names;
158
158
  }
159
159
 
160
- async function mount(layout: 'desktop' | 'mobile', props: { editor?: boolean; switch?: boolean } = {}): Promise<void> {
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('mobile draws the same controls in the same order, minus the desktop-only pair', async () => {
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
- const shared = ['Reload page', 'Context usage', 'Sign out'];
233
- const positions = (names: string[]) => shared.map((name) => names.indexOf(name));
234
- expect(positions(desktop)).toEqual([2, 3, 4]);
235
- expect(positions(mobile)).toEqual([1, 2, 3]);
236
- // Each list is ordered, which is the property that was wrong.
237
- expect(positions(desktop)).toEqual([...positions(desktop)].sort((a, b) => a - b));
238
- expect(positions(mobile)).toEqual([...positions(mobile)].sort((a, b) => a - b));
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
- <ReloadPageButton size={size} className={iconClass} />
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 { flushSession } from '@/shared/lib/workspace/session-state/store';
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,12 @@
1
- import { Info, Settings } from 'lucide-preact';
1
+ import { Download, Info, Settings } from 'lucide-preact';
2
2
 
3
3
  interface SessionSidebarFooterProps {
4
4
  onSettings: () => void;
5
5
  onInfo: () => void;
6
6
  updateAvailable?: boolean;
7
7
  onUpdateClick?: () => void;
8
+ /** The newer APK to offer, or null. Only ever set inside the Android app. */
9
+ androidDownload?: { url: string; latest: string; releaseUrl: string | null } | null;
8
10
  }
9
11
 
10
12
  /**
@@ -17,7 +19,7 @@ interface SessionSidebarFooterProps {
17
19
  * is unreachable by keyboard"). It is also what lets a browser spec address
18
20
  * these controls by role and name instead of by their position in the DOM.
19
21
  */
20
- export function SessionSidebarFooter({ onSettings, onInfo, updateAvailable, onUpdateClick }: SessionSidebarFooterProps) {
22
+ export function SessionSidebarFooter({ onSettings, onInfo, updateAvailable, onUpdateClick, androidDownload }: SessionSidebarFooterProps) {
21
23
  return (
22
24
  <div className="h-10 px-3 border-t border-ink/10 flex items-center justify-between text-ink/60 shrink-0">
23
25
  <div className="flex space-x-1">
@@ -39,6 +41,22 @@ export function SessionSidebarFooter({ onSettings, onInfo, updateAvailable, onUp
39
41
  >
40
42
  <Info size={16} />
41
43
  </button>
44
+ {androidDownload && (
45
+ // Next to About, and only when BOTH facts hold: this page is inside
46
+ // the Android app, and a newer APK is published. An up-to-date app
47
+ // shows nothing here, and a browser never does — see
48
+ // `useAndroidDownload`.
49
+ <a
50
+ href={androidDownload.url}
51
+ target="_blank"
52
+ rel="noopener noreferrer"
53
+ title={`Download Kabmo ${androidDownload.latest} for Android`}
54
+ aria-label={`Download Kabmo ${androidDownload.latest} for Android`}
55
+ className="p-1 rounded hover:text-ink hover:bg-ink/5 cursor-pointer transition-colors"
56
+ >
57
+ <Download size={16} />
58
+ </a>
59
+ )}
42
60
  </div>
43
61
  {updateAvailable && (
44
62
  <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={`w-7 h-7 inline-flex items-center justify-center rounded border transition-colors cursor-pointer ${
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="absolute right-0 top-full mt-2 w-52 bg-paper border border-ink/15 rounded-lg shadow-lg z-50 py-1 text-xs"
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 px-3 py-1.5 flex items-center gap-2 transition-colors ${
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="desktop" sortOption={sortOption} onSortChange={onSortChange} />
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 } = useRowWidth();
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,9 @@ 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 download offer: null in a browser and null when this APK is
61
+ // already the newest published one, so the footer draws nothing.
62
+ const androidDownload = useAndroidDownload(window.__OMP_BOOTSTRAP__?.shellVersion);
59
63
  const { toasts, pushToast, dismissToast } = useToasts();
60
64
 
61
65
  // Sidebar inline states
@@ -212,6 +216,7 @@ export function SessionSidebar({ className = '', onClose, appSettings = {}, stre
212
216
  <SessionSidebarFooter
213
217
  onSettings={() => setSettingsOpen(true)}
214
218
  onInfo={() => setInfoOpen(true)}
219
+ androidDownload={androidDownload}
215
220
  updateAvailable={updates.hasUpdate}
216
221
  onUpdateClick={() => {
217
222
  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;