@kabmo-labs/kabmo 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kabmo-labs/kabmo",
3
- "version": "0.2.0",
3
+ "version": "0.3.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>",
@@ -26,10 +26,19 @@ export interface KabmoBootstrap {
26
26
  * The Android shell's version, when this page was requested by the APK
27
27
  * (`KabmoAndroid/<version>` in the User-Agent). Null in every browser.
28
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.
29
+ * The sidebar uses it to decide whether an UPDATE applies — this install is
30
+ * behind the published APK.
31
31
  */
32
32
  shellVersion?: string | null;
33
+ /**
34
+ * Whether this request came from an Android device at all — the app, or a
35
+ * browser running on it.
36
+ *
37
+ * The wider fact, and it exists for the INSTALL offer: a reader on Chrome or
38
+ * Brave for Android carries no shell token, so `shellVersion` alone cannot
39
+ * tell them from a desktop where an APK is useless.
40
+ */
41
+ isAndroid?: boolean;
33
42
  }
34
43
 
35
44
  declare global {
@@ -1,12 +1,16 @@
1
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;
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;
12
+ /** The APK to offer, or null. Set inside the app (an update) or on an Android browser (an install). */
13
+ androidDownload?: AndroidDownload | null;
10
14
  }
11
15
 
12
16
  /**
@@ -42,16 +46,16 @@ export function SessionSidebarFooter({ onSettings, onInfo, updateAvailable, onUp
42
46
  <Info size={16} />
43
47
  </button>
44
48
  {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
+ // 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`.
49
53
  <a
50
54
  href={androidDownload.url}
51
55
  target="_blank"
52
56
  rel="noopener noreferrer"
53
- title={`Download Kabmo ${androidDownload.latest} for Android`}
54
- aria-label={`Download Kabmo ${androidDownload.latest} for Android`}
57
+ title={androidDownloadLabel(androidDownload)}
58
+ aria-label={androidDownloadLabel(androidDownload)}
55
59
  className="p-1 rounded hover:text-ink hover:bg-ink/5 cursor-pointer transition-colors"
56
60
  >
57
61
  <Download size={16} />
@@ -57,9 +57,12 @@ export function SessionSidebar({ className = '', onClose, appSettings = {}, stre
57
57
  const [schedulerOpen, setSchedulerOpen] = useState(false);
58
58
  const scheduledCount = useScheduledTaskCount();
59
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);
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
+ );
63
66
  const { toasts, pushToast, dismissToast } = useToasts();
64
67
 
65
68
  // Sidebar inline states
@@ -73,9 +73,12 @@ export function MobileSessionSidebar({
73
73
  const scheduledCount = useScheduledTaskCount();
74
74
  const [aboutOpen, setAboutOpen] = useState(false);
75
75
  const updates = useUpdates();
76
- // Null in a browser, and null when this APK is already current — see
77
- // `useAndroidDownload`.
78
- const androidDownload = useAndroidDownload(window.__OMP_BOOTSTRAP__?.shellVersion);
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
+ );
79
82
  const { toasts, pushToast, dismissToast } = useToasts();
80
83
 
81
84
  // The popup's "Update now" hands the run over rather than starting its own —
@@ -1,13 +1,17 @@
1
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;
9
- /** The newer APK to offer, or null. Only ever set inside the Android app. */
10
- androidDownload?: { url: string; latest: string; releaseUrl: string | null } | null;
13
+ /** The APK to offer, or null. Set inside the app (an update) or on an Android browser (an install). */
14
+ androidDownload?: AndroidDownload | null;
11
15
  /** Switch the app to the desktop layout for this page session only. The
12
16
  * choice is not persisted, so the next load re-detects the device. */
13
17
  onDesktopToggle?: () => void;
@@ -48,16 +52,20 @@ export function MobileSessionFooter({
48
52
  // button: the download is the browser's (or the WebView's) job, and
49
53
  // `target="_blank"` hands it to the system browser so the console is
50
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.
51
59
  <a
52
60
  href={androidDownload.url}
53
61
  target="_blank"
54
62
  rel="noopener noreferrer"
55
- title={`Download Kabmo ${androidDownload.latest} for Android`}
56
- aria-label={`Download Kabmo ${androidDownload.latest} for Android`}
63
+ title={androidDownloadLabel(androidDownload)}
64
+ aria-label={androidDownloadLabel(androidDownload)}
57
65
  className="flex items-center space-x-1 hover:text-ink transition-colors"
58
66
  >
59
67
  <Download size={14} />
60
- <span>APK</span>
68
+ <span>{androidDownload.kind === 'update' ? 'Update APK' : 'Get APK'}</span>
61
69
  </a>
62
70
  )}
63
71
  {onDesktopToggle && (
@@ -1,5 +1,5 @@
1
1
  {
2
- "source": "@oh-my-pi/pi-coding-agent 10 tabs / 554 settings",
2
+ "source": "@oh-my-pi/pi-coding-agent 10 tabs / 559 settings",
3
3
  "tabs": [
4
4
  {
5
5
  "id": "appearance",
@@ -507,6 +507,16 @@
507
507
  ]
508
508
  }
509
509
  },
510
+ "tui.completeScrollback": {
511
+ "type": "boolean",
512
+ "default": false,
513
+ "ui": {
514
+ "tab": "appearance",
515
+ "group": "Display",
516
+ "label": "Complete Scrollback",
517
+ "description": "Send transcript rows pushed off screen by a growing HUD or dialog straight to scrollback; when it shrinks, blank rows sit between the transcript and the input until new output fills them"
518
+ }
519
+ },
510
520
  "terminal.showProgress": {
511
521
  "type": "boolean",
512
522
  "default": false,
@@ -3865,6 +3875,39 @@
3865
3875
  "condition": "advisorEnabled"
3866
3876
  }
3867
3877
  },
3878
+ "advisor.continueSeverity": {
3879
+ "type": "enum",
3880
+ "default": "concern",
3881
+ "values": [
3882
+ "nit",
3883
+ "concern",
3884
+ "blocker"
3885
+ ],
3886
+ "ui": {
3887
+ "tab": "model",
3888
+ "group": "Advisor",
3889
+ "label": "Advisor Continue Severity",
3890
+ "description": "Minimum severity of an advisor note that wakes the agent after it finished with a final answer. Lower-severity notes stay as a card at the end of the transcript and reach the agent with your next message.",
3891
+ "options": [
3892
+ {
3893
+ "value": "nit",
3894
+ "label": "Nit",
3895
+ "description": "Every note continues the run."
3896
+ },
3897
+ {
3898
+ "value": "concern",
3899
+ "label": "Concern",
3900
+ "description": "Concerns and blockers continue the run. Default."
3901
+ },
3902
+ {
3903
+ "value": "blocker",
3904
+ "label": "Blocker",
3905
+ "description": "Only blockers continue the run."
3906
+ }
3907
+ ],
3908
+ "condition": "advisorEnabled"
3909
+ }
3910
+ },
3868
3911
  "advisor.reviewMode": {
3869
3912
  "type": "enum",
3870
3913
  "default": "turn",
@@ -4062,7 +4105,7 @@
4062
4105
  {
4063
4106
  "value": "remote",
4064
4107
  "label": "Server compaction",
4065
- "description": "Use provider-native server compaction (OpenAI Responses compact, Anthropic compaction beta) when the active route supports it"
4108
+ "description": "Use provider-native server compaction (OpenAI Responses compact, Anthropic compaction beta, including on Bedrock) when the active route supports it"
4066
4109
  },
4067
4110
  {
4068
4111
  "value": "snapcompact",
@@ -4312,7 +4355,7 @@
4312
4355
  "tab": "context",
4313
4356
  "group": "Compaction",
4314
4357
  "label": "Idle Compaction Delay",
4315
- "description": "Seconds to wait while idle before compacting",
4358
+ "description": "Seconds to wait while idle before compacting; with a 1-hour prompt cache, compaction instead runs just before the cache expires",
4316
4359
  "options": [
4317
4360
  {
4318
4361
  "value": "60",
@@ -5811,6 +5854,7 @@
5811
5854
  "default": "yolo",
5812
5855
  "values": [
5813
5856
  "always-ask",
5857
+ "always-ask-cached",
5814
5858
  "write",
5815
5859
  "yolo"
5816
5860
  ],
@@ -5818,13 +5862,18 @@
5818
5862
  "tab": "interaction",
5819
5863
  "group": "Approvals",
5820
5864
  "label": "Tool Approval",
5821
- "description": "Default approval behavior for tool calls. 'Always ask' auto-approves read-only tools only. 'Write' auto-approves read and workspace-write tools. 'Yolo' auto-approves all tiers; user policy may still prompt or block.",
5865
+ "description": "Default approval behavior for tool calls. 'Always ask' auto-approves read-only tools only. 'Ask and remember' also asks before file reads, and can remember a file or folder. 'Write' auto-approves read and workspace-write tools. 'Yolo' auto-approves all tiers; user policy may still prompt or block.",
5822
5866
  "options": [
5823
5867
  {
5824
5868
  "value": "always-ask",
5825
5869
  "label": "Always ask",
5826
5870
  "description": "Auto-approve read-only tools; require confirmation for write and exec tools."
5827
5871
  },
5872
+ {
5873
+ "value": "always-ask-cached",
5874
+ "label": "Ask and remember",
5875
+ "description": "Ask before reading or writing a file, with answers that remember the file or its folder for this session or every session; require confirmation for exec tools."
5876
+ },
5828
5877
  {
5829
5878
  "value": "write",
5830
5879
  "label": "Write",
@@ -6199,6 +6248,32 @@
6199
6248
  "description": "Maximum screenshot height in pixels"
6200
6249
  }
6201
6250
  },
6251
+ "computer.backgroundFallback": {
6252
+ "type": "enum",
6253
+ "default": "takeover",
6254
+ "values": [
6255
+ "takeover",
6256
+ "refuse"
6257
+ ],
6258
+ "ui": {
6259
+ "tab": "tools",
6260
+ "group": "Computer",
6261
+ "label": "Computer Background Fallback",
6262
+ "description": "What window input does when its background route is unavailable: rerun it in takeover and return focus to your app (macOS; elsewhere plain takeover), or refuse, which also keeps the agent from taking the foreground or opening macOS menus itself",
6263
+ "options": [
6264
+ {
6265
+ "value": "takeover",
6266
+ "label": "Takeover",
6267
+ "description": "Briefly bring the target forward for that one call, then return focus to your app (on macOS even if you kept typing)"
6268
+ },
6269
+ {
6270
+ "value": "refuse",
6271
+ "label": "Refuse",
6272
+ "description": "Fail the call; nothing takes over, and the agent's own takeover, raise, activating open, desktop input and macOS menus (context menus, popup and menu buttons; they take your keyboard while open) are refused too"
6273
+ }
6274
+ ]
6275
+ }
6276
+ },
6202
6277
  "images.questionTimeoutMs": {
6203
6278
  "type": "number",
6204
6279
  "default": 300000,
@@ -6435,6 +6510,16 @@
6435
6510
  "description": "Enable async bash commands and background task execution"
6436
6511
  }
6437
6512
  },
6513
+ "async.widgets": {
6514
+ "type": "boolean",
6515
+ "default": true,
6516
+ "ui": {
6517
+ "tab": "tools",
6518
+ "group": "Execution",
6519
+ "label": "Live Job Widgets",
6520
+ "description": "Draw a live widget for each running background job (charts, bars, status grids derived from its output) in the wait card, the jobs pill and the jobs sheet. The `widget` model role (default: `smol`) writes one spec per job; updates need no further model calls"
6521
+ }
6522
+ },
6438
6523
  "tools.xdev": {
6439
6524
  "type": "boolean",
6440
6525
  "default": true,
@@ -6,21 +6,22 @@
6
6
  import { describe, expect, test } from 'bun:test';
7
7
 
8
8
  import {
9
+ androidDownloadLabel,
9
10
  parseAndroidRelease,
10
11
  shouldOfferAndroidDownload,
11
12
  type AndroidDownload,
12
13
  } from '@/client/hooks/ui/updates/android-download';
13
14
 
14
- /**
15
- * The download button is a promise that a newer APK exists. These are the four
16
- * ways that promise can be wrong, and each must render nothing.
17
- */
18
- const RELEASE: AndroidDownload = {
15
+ /** The published release every case compares against. */
16
+ const RELEASE = {
19
17
  url: 'https://github.com/kabmo-labs/kabmo/releases/download/v1.2.3/kabmo-android-v1.2.3.apk',
20
18
  latest: '1.2.3',
21
19
  releaseUrl: 'https://github.com/kabmo-labs/kabmo/releases/tag/v1.2.3',
22
20
  };
23
21
 
22
+ const UPDATE: AndroidDownload = { ...RELEASE, kind: 'update' };
23
+ const INSTALL: AndroidDownload = { ...RELEASE, kind: 'install' };
24
+
24
25
  describe('parseAndroidRelease', () => {
25
26
  test('reads a complete payload', () => {
26
27
  expect(parseAndroidRelease(RELEASE)).toEqual(RELEASE);
@@ -44,30 +45,68 @@ describe('parseAndroidRelease', () => {
44
45
  });
45
46
  });
46
47
 
47
- describe('shouldOfferAndroidDownload', () => {
48
- test('offers a newer release to the app', () => {
49
- expect(shouldOfferAndroidDownload('1.2.2', RELEASE)).toEqual(RELEASE);
50
- });
51
-
52
- test('offers nothing to a browser', () => {
53
- // No shell version means the page is not the APK; a browser has no app to
54
- // update, and the app's own update badge already covers the server.
55
- expect(shouldOfferAndroidDownload(null, RELEASE)).toBeNull();
56
- expect(shouldOfferAndroidDownload(undefined, RELEASE)).toBeNull();
57
- expect(shouldOfferAndroidDownload('', RELEASE)).toBeNull();
48
+ describe('the offer inside the installed app', () => {
49
+ test('offers an UPDATE when a newer APK is published', () => {
50
+ expect(shouldOfferAndroidDownload('1.2.2', true, RELEASE)).toEqual(UPDATE);
58
51
  });
59
52
 
60
53
  test('offers nothing when the app is already current', () => {
61
- expect(shouldOfferAndroidDownload('1.2.3', RELEASE)).toBeNull();
54
+ expect(shouldOfferAndroidDownload('1.2.3', true, RELEASE)).toBeNull();
62
55
  });
63
56
 
64
57
  test('offers nothing when the app is ahead', () => {
65
58
  // A sideloaded build newer than the published asset: downloading it would be
66
59
  // a downgrade.
67
- expect(shouldOfferAndroidDownload('1.3.0', RELEASE)).toBeNull();
60
+ expect(shouldOfferAndroidDownload('1.3.0', true, RELEASE)).toBeNull();
68
61
  });
69
62
 
70
63
  test('offers nothing when the probe failed', () => {
71
- expect(shouldOfferAndroidDownload('1.0.0', null)).toBeNull();
64
+ expect(shouldOfferAndroidDownload('1.0.0', true, null)).toBeNull();
65
+ });
66
+ });
67
+
68
+ describe('the offer to an Android browser', () => {
69
+ test('offers an INSTALL, because there is no installed version to compare', () => {
70
+ // The reported gap: a reader on Chrome or Brave for Android was told
71
+ // nothing, and the shell is exactly what they do not know exists.
72
+ expect(shouldOfferAndroidDownload(null, true, RELEASE)).toEqual(INSTALL);
73
+ });
74
+
75
+ test('offers an install even when the published APK is older than some build', () => {
76
+ // Nothing is installed here, so "newer than" has no left-hand side — the
77
+ // published asset is simply what the button names.
78
+ expect(shouldOfferAndroidDownload(undefined, true, { ...RELEASE, latest: '0.0.1' })).toEqual({
79
+ ...INSTALL,
80
+ latest: '0.0.1',
81
+ });
82
+ });
83
+
84
+ test('offers nothing when no APK is published', () => {
85
+ expect(shouldOfferAndroidDownload(null, true, null)).toBeNull();
86
+ });
87
+ });
88
+
89
+ describe('everywhere else', () => {
90
+ test('a desktop browser is offered nothing', () => {
91
+ // An APK cannot be installed there, and the app's own update badge already
92
+ // covers the server.
93
+ expect(shouldOfferAndroidDownload(null, false, RELEASE)).toBeNull();
94
+ expect(shouldOfferAndroidDownload(null, null, RELEASE)).toBeNull();
95
+ expect(shouldOfferAndroidDownload(null, undefined, RELEASE)).toBeNull();
96
+ expect(shouldOfferAndroidDownload('', false, RELEASE)).toBeNull();
97
+ });
98
+
99
+ test('the shell token alone is enough, even if the wider flag is missing', () => {
100
+ // `isAndroid` is derived from the same UA, so a stale bootstrap that predates
101
+ // the field must not silently drop the update offer.
102
+ expect(shouldOfferAndroidDownload('1.2.2', false, RELEASE)).toEqual(UPDATE);
103
+ expect(shouldOfferAndroidDownload('1.2.2', undefined, RELEASE)).toEqual(UPDATE);
104
+ });
105
+ });
106
+
107
+ describe('androidDownloadLabel', () => {
108
+ test('names the verb the reader needs', () => {
109
+ expect(androidDownloadLabel(UPDATE)).toBe('Update Kabmo 1.2.3 for Android');
110
+ expect(androidDownloadLabel(INSTALL)).toBe('Install Kabmo 1.2.3 for Android');
72
111
  });
73
112
  });
@@ -1,31 +1,38 @@
1
1
  /**
2
- * Whether the sidebar should offer an APK download.
2
+ * What the sidebar should offer for the Android shell, and why.
3
3
  *
4
- * Two independent facts, and the button needs both:
4
+ * Two DIFFERENT offers, from two different facts:
5
5
  *
6
- * - **Running inside the Android app** (`shellVersion`, read from the bootstrap).
7
- * In a browser there is no app to update, and a "Download APK" button there
8
- * would be a second, unrelated offer sitting next to the app's own update
9
- * badge.
10
- * - **A newer APK than the one running it** (`latest` > `shellVersion`). The
11
- * published asset is attached to the app's release, so it moves on its own
12
- * schedule; an up-to-date app must show nothing at all rather than a button
13
- * that downloads the build already installed.
6
+ * - **`update`** — this page is running inside the APK (`shellVersion` from the
7
+ * bootstrap) and a newer one is published. An up-to-date app offers nothing:
8
+ * a button that downloads the build already installed is worse than no button.
9
+ * - **`install`** — this page is an Android BROWSER (Chrome, Brave, Firefox) and
10
+ * an APK is published. The reader may not know a native shell exists, and
11
+ * telling them is the whole point of the offer; there is no installed version
12
+ * to compare against, so the newest published one is what it names.
14
13
  *
15
- * The probe is lazy: it is fetched once when a shell is detected, never in a
16
- * browser, so a desktop console makes no request it cannot use.
14
+ * A desktop browser gets neither: `isAndroid` is false, an APK cannot be
15
+ * installed there, and the app's own update badge already covers the server.
16
+ *
17
+ * The probe is lazy: it runs only on an Android device, never on a desktop, so a
18
+ * console that can never show the button makes no request for it.
17
19
  */
18
20
 
19
21
  import { useEffect, useState } from 'preact/hooks';
20
22
  import { isNewer } from '@/shared/lib/updates/semver';
21
23
 
24
+ /** Which offer this is — the two carry different wording. */
25
+ export type AndroidOfferKind = 'update' | 'install';
26
+
22
27
  export interface AndroidDownload {
23
- /** The asset to download, or null when there is nothing to offer. */
28
+ /** The asset to download. */
24
29
  url: string;
25
30
  /** The version that asset ships. */
26
31
  latest: string;
27
32
  /** The release page, for a "what changed" link. */
28
33
  releaseUrl: string | null;
34
+ /** An update to an installed APK, or a first install from a browser. */
35
+ kind: AndroidOfferKind;
29
36
  }
30
37
 
31
38
  interface ReleasePayload {
@@ -41,7 +48,7 @@ interface ReleasePayload {
41
48
  * probe, so a shape check that only looks at the version would offer a button
42
49
  * with no link.
43
50
  */
44
- export function parseAndroidRelease(payload: unknown): AndroidDownload | null {
51
+ export function parseAndroidRelease(payload: unknown): Omit<AndroidDownload, 'kind'> | null {
45
52
  if (typeof payload !== 'object' || payload === null) return null;
46
53
  const body = payload as ReleasePayload;
47
54
  if (typeof body.url !== 'string' || !body.url) return null;
@@ -54,23 +61,51 @@ export function parseAndroidRelease(payload: unknown): AndroidDownload | null {
54
61
  }
55
62
 
56
63
  /**
57
- * The download to offer, or null.
64
+ * The offer, or null.
65
+ *
66
+ * `shellVersion` is null in every browser — that is what makes the two branches
67
+ * mutually exclusive — and `isAndroid` is the wider fact that admits a browser
68
+ * ON ANDROID. Both come from the bootstrap rather than a client-side UA sniff:
69
+ * the server already parsed them for the very request that served the page.
58
70
  *
59
- * `shellVersion` is null in a browser — that is the whole gate, and it is why
60
- * this takes the bootstrap value rather than sniffing the UA again on the
61
- * client: the server already parsed it for the very request that served the
62
- * page.
71
+ * An `install` needs no version comparison, because nothing is installed. It
72
+ * does require a published APK: `release` being null is a failed probe, and a
73
+ * button the console cannot back with a link must not appear.
63
74
  */
64
75
  export function shouldOfferAndroidDownload(
65
76
  shellVersion: string | null | undefined,
66
- release: AndroidDownload | null,
77
+ isAndroid: boolean | null | undefined,
78
+ release: Omit<AndroidDownload, 'kind'> | null,
67
79
  ): AndroidDownload | null {
68
- if (!shellVersion || !release) return null;
69
- return isNewer(release.latest, shellVersion) ? release : null;
80
+ if (!release) return null;
81
+
82
+ // Inside the APK: an update, and only when one exists.
83
+ if (shellVersion) {
84
+ return isNewer(release.latest, shellVersion) ? { ...release, kind: 'update' } : null;
85
+ }
86
+
87
+ // A browser on an Android device: the app is something to install.
88
+ if (isAndroid) return { ...release, kind: 'install' };
89
+
90
+ return null;
91
+ }
92
+
93
+ /**
94
+ * The wording of the offer, in one place.
95
+ *
96
+ * Both layouts draw this control and they must say the same thing: an installed
97
+ * app that is behind is an UPDATE, and a browser on Android is an INSTALL —
98
+ * "Download" for both would leave a first-time reader unsure whether they
99
+ * already have the app. The version is in the label because the asset is a
100
+ * specific build, not "the app".
101
+ */
102
+ export function androidDownloadLabel(download: AndroidDownload): string {
103
+ const verb = download.kind === 'update' ? 'Update' : 'Install';
104
+ return `${verb} Kabmo ${download.latest} for Android`;
70
105
  }
71
106
 
72
107
  /**
73
- * Fetch the published APK and compare it with the running shell.
108
+ * Fetch the published APK and decide what to offer.
74
109
  *
75
110
  * `fetchImpl` is a seam for the tests: the cases that matter (no release, a
76
111
  * failed probe, a version equal to the installed one) cannot be produced by a
@@ -78,12 +113,14 @@ export function shouldOfferAndroidDownload(
78
113
  */
79
114
  export function useAndroidDownload(
80
115
  shellVersion: string | null | undefined,
116
+ isAndroid: boolean | null | undefined,
81
117
  fetchImpl: typeof fetch = fetch,
82
118
  ): AndroidDownload | null {
83
- const [release, setRelease] = useState<AndroidDownload | null>(null);
119
+ const [release, setRelease] = useState<Omit<AndroidDownload, 'kind'> | null>(null);
120
+ const wanted = Boolean(shellVersion) || Boolean(isAndroid);
84
121
 
85
122
  useEffect(() => {
86
- if (!shellVersion) return;
123
+ if (!wanted) return;
87
124
 
88
125
  let cancelled = false;
89
126
  void (async () => {
@@ -100,7 +137,7 @@ export function useAndroidDownload(
100
137
  return () => {
101
138
  cancelled = true;
102
139
  };
103
- }, [shellVersion, fetchImpl]);
140
+ }, [wanted, fetchImpl]);
104
141
 
105
- return shouldOfferAndroidDownload(shellVersion, release);
142
+ return shouldOfferAndroidDownload(shellVersion, isAndroid, release);
106
143
  }