Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -1416,10 +1416,13 @@ Alert rows of the table above from the `--droplet-*` roles in `src/styles.css`.
- Rows use only shared theme tokens in dark and light. Touch keeps row menus and folds visible,
with `--touch-target` controls, and draws no hover fill; reduced motion removes row transitions
and keeps the working dot's breath (opacity only).
- Footer holds the contextual **Install app** action and Settings, its gear alone (named
**Settings** to screen readers and in its tooltip), with the plan meters filling the rest of its row,
as rows on the same grid with no rule above.
It carries no product name or version: the running versions are read in Settings.
- Footer holds Settings, its gear alone (named **Settings** to screen readers and in its tooltip),
with the plan meters filling the rest of its row, as a row on the same grid with no rule above.
It carries no product name or version: the running versions are read in Settings. Nor does it
offer **Install app**: the page can only tell it is installed from inside the app's own window,
so a footer row kept showing in every browser tab and in browsers that never prompt. Settings →
Phone & devices → Install offers it when the browser can install, and the steps otherwise,
as the Claude web app keeps getting its apps in the account menu rather than a sidebar row.

### Plan meters (`.usage*`)
- Right of the Settings gear, one button holding as many chips as the row has room for (judged
Expand Down
3 changes: 3 additions & 0 deletions changelog.d/sidebar-install-app-removed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Removed
- The sidebar no longer shows Install app; install from Settings → Phone & devices → Install,
which offers it when the browser can install and says how otherwise.
2 changes: 1 addition & 1 deletion docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ bun scripts/background-agent-detail-demo-regression.ts # a running Claude subage
bun scripts/font-swap-demo-regression.ts # the app's faces arriving late on a slow link: a reader at the end of a chat stays there, a tab strip the user scrolled stays put
bun scripts/narrow-pane-demo-regression.ts # a split pane under 80 columns: the smaller font, a zoom, and a pane no font can bring to 80
bun scripts/agent-skill-menu-demo-regression.ts [agent …] # each agent's `/` (and `$`) skill menu, its origin groups and its skill chips at 1280 and 390px, one case file per agent in scripts/skill-menu-cases/, screenshots in evidence/agent-skill-menu/<agent>/
bun scripts/install-state-demo-regression.ts # Install app against a real CDP install on a throwaway profile and HOME: the help or the prompt before, gone in the app window and a tab of its origin, back after uninstall
bun scripts/install-state-demo-regression.ts # Install app against a real CDP install on a throwaway profile and HOME: never in the sidebar; Settings > Install shows the help or the prompt before, Installed in the app window and a tab of its origin, the help again after uninstall
bun scripts/terminal-paste-insert-demo-regression.ts # a phone's Paste key into the terminal's input line and Insert without Enter on the demo shell: the caret, a held or refused clipboard read, the acknowledged clear, an older bridge; with UI_EVIDENCE_DIR, every state at 390 and 1440, light and dark, and Korean
bun scripts/link-menu-demo-regression.ts # a long press on a chat link in the Android app: Copy link, Open link and, where the WebView shares, Share link; a tap, other words, a mouse's menu, a swipe and a browser keep their own; with UI_EVIDENCE_DIR, the sheet at 390 in English and Korean
```
Expand Down
4 changes: 2 additions & 2 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ You need herdr 0.9.0+ and Bun 1.4+, and for attached terminals either the Rust P

</details>

**2. Open it** at **http://127.0.0.1:7317**, the address the installer opened. On a slow link, such as a phone on mobile data, a spinner shows while the app downloads. The installed app belongs to that exact address: `localhost:7317` is another address to the browser, so a tab there still offers **Install**. Every workspace of your herdr session is a row in the sidebar, as in herdr's own, led by its project's icon when its folder keeps one (a favicon in the folder, `public/` or `static/`; else an app icon such as Next.js's `app/icon.*` or Electron's `build/icon.png`; else a logo such as `logo.svg` or `assets/logo.png`; the folder is the worktree checkout, or else the pane's working folder, so a pane that has `cd`'d into `src/` looks there) and a folder glyph otherwise (a repository row with worktrees under it is also its fold button: the folder opens and closes with it, and an icon turns into that folder under the pointer; the worktree rows nested under it have no mark); its tabs are always shown in a strip, and the active tab shows all its split panes together. Pick one, start a new agent with **New workspace**, or add a tab to a workspace with **New tab** (the strip's `+`, a tab's right-click menu, or the command palette on a wider screen or with a keyboard). Right-click a tab to rename or close it; on a phone, tap the selected tab’s **⋯** for a trimmed menu with pane actions, Rename tab and Close tab. A focused tab also opens its menu with Shift+F10, and double-click still renames it.
**2. Open it** at **http://127.0.0.1:7317**, the address the installer opened. On a slow link, such as a phone on mobile data, a spinner shows while the app downloads. The installed app belongs to that exact address: `localhost:7317` is another address to the browser, so a tab there is not the installed app, and Settings → Phone & devices → Install there still offers to install it (an **Install app** button when the browser can, its steps otherwise). Every workspace of your herdr session is a row in the sidebar, as in herdr's own, led by its project's icon when its folder keeps one (a favicon in the folder, `public/` or `static/`; else an app icon such as Next.js's `app/icon.*` or Electron's `build/icon.png`; else a logo such as `logo.svg` or `assets/logo.png`; the folder is the worktree checkout, or else the pane's working folder, so a pane that has `cd`'d into `src/` looks there) and a folder glyph otherwise (a repository row with worktrees under it is also its fold button: the folder opens and closes with it, and an icon turns into that folder under the pointer; the worktree rows nested under it have no mark); its tabs are always shown in a strip, and the active tab shows all its split panes together. Pick one, start a new agent with **New workspace**, or add a tab to a workspace with **New tab** (the strip's `+`, a tab's right-click menu, or the command palette on a wider screen or with a keyboard). Right-click a tab to rename or close it; on a phone, tap the selected tab’s **⋯** for a trimmed menu with pane actions, Rename tab and Close tab. A focused tab also opens its menu with Shift+F10, and double-click still renames it.

Drag a tab to change its order within the workspace, or choose **Move tab left / right** from
its menu (also on a phone). Alt+Shift+Left/Right moves a focused tab. Reordering keeps the
Expand Down Expand Up @@ -399,7 +399,7 @@ Only devices in your tailnet can open that address. Your own devices get in with
**Settings → Phone & devices** in the app does this step for you as far as it can: it shows the address Tailscale already serves for this PC as a QR code, or the exact command still to run, and the address it will give.

1. Open the address.
2. Install the app: in Safari, choose **Share → Add to Home Screen**; in Chrome, choose **Install app**. Each PC installs as an app of its own: set `OVIS_APP_NAME` on each to tell them apart (see [Configuration](#configuration)).
2. Install the app: in Safari, choose **Share → Add to Home Screen**; in Chrome, open **Settings → Phone & devices** and choose **Install app**; when it shows steps instead of the button, follow them (Chrome's menu has **Install app** or **Add to Home screen**). Each PC installs as an app of its own: set `OVIS_APP_NAME` on each to tell them apart (see [Configuration](#configuration)).
3. Open the **⋯** menu at the top right and tap **Alerts** to turn on alerts for that device. iPhone needs iOS 16.4+ and the home-screen app.

To check alerts later, choose **Settings → Alerts → Send test**. The result tells you
Expand Down
76 changes: 45 additions & 31 deletions scripts/install-state-demo-regression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,19 @@ import assert from "node:assert/strict";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { join } from "node:path";
import { chromium, type BrowserContext, type CDPSession, type Page } from "playwright-core";
import { chromium, type BrowserContext, type CDPSession, type Locator, type Page } from "playwright-core";
import { manifestBody } from "../server/static.ts";
import { buildDemoApp } from "./demo-build.ts";
import { openSettingsPage } from "./settings-page.ts";

// The sidebar's "Install app" against a real install: the unmodified app over the demo's fixture
// Install app against a real install: the unmodified app over the demo's fixture
// transport, served at the ROOT of a loopback origin so the manifest's id, start_url and scope "/"
// are this app, installed with the browser's own PWA domain (CDP). The browser runs on a profile,
// HOME and XDG data dir of its own, so the install's OS integration (desktop entries, icons) lands
// in this run's directory and never in the user's; no herdr session is opened.
// The sidebar offers no Install app at any point: it kept showing in every tab of a browser that had
// the app installed and in browsers that never prompt. Settings → Phone & devices → Install offers it:
// the button while the browser holds a prompt, the steps while it holds none, Installed once it is.
// HEADED=1 (under xvfb-run where there is no display) runs the same steps in a window.
const root = mkdtempSync(join(tmpdir(), "herdr-install-state-demo-"));
const app = join(root, "app");
Expand All @@ -25,11 +28,25 @@ const newRealDesktopEntries = (): string[] => existsSync(realApplications)
? readdirSync(realApplications).filter((name) => /^chrome-.*\.desktop$/.test(name) && statSync(join(realApplications, name)).mtimeMs >= started)
: [];

const installButton = (page: Page) => page.locator(".sidebar-footer-action").filter({ hasText: "Install app" });
const ready = async (page: Page): Promise<void> => {
await page.locator(".conn-live").waitFor({ state: "attached" });
await page.locator(".sidebar-footer").waitFor();
await assertNoSidebarInstall(page);
};
// by its words or by a name alone (an icon-only button labelled Install app counts too)
const assertNoSidebarInstall = async (page: Page): Promise<void> => {
const sidebar = page.locator(".sidebar-shell");
assert.equal(await sidebar.getByText(/install app/i).count() + await sidebar.getByRole("button", { name: /install/i }).count(), 0, "the sidebar offers no Install app");
};
// installHelp() for desktop Chromium over loopback (src/lib/install.ts)
const CHROMIUM_HELP = "Use the install icon in the address bar, or the browser menu's Install option.";
/** Settings → Phone & devices → Install, opened in this page. */
const installRow = async (page: Page): Promise<Locator> => {
await page.keyboard.press("ControlOrMeta+Shift+Comma");
await openSettingsPage(page, "Phone & devices");
return page.locator(".settings-row").filter({ has: page.locator(".settings-label", { hasText: /^Install$/ }) });
};
const installButton = (row: Locator): Locator => row.getByRole("button", { name: "Install app", exact: true });
const relatedApps = (page: Page): Promise<number> =>
page.evaluate(async () => (await (navigator as Navigator & { getInstalledRelatedApps(): Promise<unknown[]> }).getInstalledRelatedApps()).length);
const nextPage = async (context: BrowserContext, open: () => Promise<unknown>): Promise<Page> => {
Expand Down Expand Up @@ -93,25 +110,18 @@ try {
await send(cdp, "PWA.uninstall", { manifestId });
console.log(`PASS spike: PWA.install and PWA.uninstall accepted in ${headless ? "headless" : "headed"} Chromium ${browser.version()}`);

// (a) before the install, a tab offers Install app
// (a) before the install, with no prompt held: no sidebar Install app; Settings says how to install
const tab = watch(context.pages()[0] ?? await context.newPage());
await tab.goto(manifestId);
await ready(tab);
await installButton(tab).waitFor();
assert.equal(await relatedApps(tab), 0, "nothing is installed yet");
console.log("PASS (a) before install, a tab shows Install app");

// (a1) no prompt: the click shows the help instead
await installButton(tab).click();
const help = tab.locator(".sidebar-install-help");
await help.waitFor();
assert.equal(await help.getAttribute("role"), "status");
assert.notEqual((await help.textContent())?.trim() ?? "", "", "the help says how to install");
assert.equal(await installButton(tab).getAttribute("aria-expanded"), "true");
assert.equal(await tab.evaluate(() => (window as unknown as { __prompts?: number }).__prompts), undefined, "no prompt was attempted");
console.log("PASS (a1) without a prompt, Install app shows the help text");
const tabRow = await installRow(tab);
assert.equal((await tabRow.locator(".settings-description").textContent())?.trim(), CHROMIUM_HELP, "Settings > Install says how to install");
assert.equal(await installButton(tabRow).count(), 0, "no Install app button without a prompt");
await shot(tab, "settings-help");
console.log("PASS (a) before install, without a prompt: no sidebar Install app, Settings > Install shows the steps");

// (a2) a prompt: the click shows it, once, and no help
// (a2) a prompt: Settings' Install app shows it, once, and no help
const prompted = watch(await context.newPage());
await prompted.goto(manifestId);
await ready(prompted);
Expand All @@ -122,10 +132,12 @@ try {
});
window.dispatchEvent(event);
});
await installButton(prompted).click();
await assertNoSidebarInstall(prompted);
const promptedRow = await installRow(prompted);
assert.equal(await promptedRow.locator(".settings-description").count(), 0, "a prompt shows no help text");
await installButton(promptedRow).click();
await prompted.waitForFunction(() => (window as unknown as { __prompts?: number }).__prompts === 1);
assert.equal(await prompted.locator(".sidebar-install-help").count(), 0, "a prompt shows no help text");
console.log("PASS (a2) with a prompt, Install app calls prompt() exactly once and shows no help");
console.log("PASS (a2) with a prompt: no sidebar Install app, Settings' Install app calls prompt() exactly once and shows no help");
await prompted.close();

// (b) installed and launched in its own window: no Install app, Settings says Installed.
Expand All @@ -136,11 +148,9 @@ try {
assert.equal(new URL(appWindow.url()).origin, origin);
await ready(appWindow);
assert.equal(await appWindow.evaluate(() => matchMedia("(display-mode: standalone)").matches), true, "the app runs in a standalone window");
assert.equal(await installButton(appWindow).count(), 0, "the app window shows no Install app");
await appWindow.keyboard.press("ControlOrMeta+Shift+Comma");
await openSettingsPage(appWindow, "Phone & devices");
const installRow = appWindow.locator(".settings-row").filter({ has: appWindow.locator(".settings-label", { hasText: /^Install$/ }) });
assert.equal(await installRow.locator(".settings-description").textContent(), "Installed");
const appRow = await installRow(appWindow);
assert.equal(await appRow.locator(".settings-description").textContent(), "Installed");
assert.equal(await installButton(appRow).count(), 0, "the app window offers no Install app");
await shot(appWindow, "app-window");
console.log("PASS (b) the installed app window: display-mode standalone, no Install app, Settings > Install says Installed");

Expand All @@ -150,19 +160,23 @@ try {
assert.equal(await related.evaluate(() => matchMedia("(display-mode: browser)").matches), true, "this is a browser tab, not the app window");
const linked = await related.evaluate(async () => (await (await fetch(document.querySelector<HTMLLinkElement>("link[rel=manifest]")!.href)).json()) as { related_applications?: unknown });
assert.equal(await relatedApps(related), 1, `(c) getInstalledRelatedApps() finds the installed app; the served related_applications: ${JSON.stringify(linked.related_applications)}`);
try { await installButton(related).waitFor({ state: "detached", timeout: 10_000 }); }
catch (error) { throw new Error("(c) the tab of the installed app still shows Install app", { cause: error }); }
const relatedRow = await installRow(related);
try { await relatedRow.locator(".settings-description", { hasText: /^Installed$/ }).waitFor({ timeout: 10_000 }); }
catch (error) { throw new Error("(c) Settings in the tab of the installed app does not say Installed", { cause: error }); }
assert.equal(await installButton(relatedRow).count(), 0, "the tab of the installed app offers no Install app");
await shot(related, "tab");
console.log("PASS (c) a tab of the installed origin: getInstalledRelatedApps() has 1 entry, no Install app");
console.log("PASS (c) a tab of the installed origin: getInstalledRelatedApps() has 1 entry, no sidebar Install app, Settings > Install says Installed");

// (d) after the uninstall a NEW tab offers Install app again (installedByEvent is per page)
// (d) after the uninstall a NEW tab is told how to install again (installedByEvent is per page)
await send(cdp, "PWA.uninstall", { manifestId });
const after = watch(await context.newPage());
await after.goto(manifestId);
await ready(after);
assert.equal(await relatedApps(after), 0, "nothing is installed after the uninstall");
await installButton(after).waitFor();
console.log("PASS (d) after uninstall, a new tab shows Install app and getInstalledRelatedApps() is empty");
const afterRow = await installRow(after);
assert.equal((await afterRow.locator(".settings-description").textContent())?.trim(), CHROMIUM_HELP, "Settings > Install shows the steps again");
assert.equal(await installButton(afterRow).count(), 0, "no Install app button without a prompt after the uninstall");
console.log("PASS (d) after uninstall, a new tab: no sidebar Install app, Settings > Install shows the steps, getInstalledRelatedApps() is empty");
assert.deepEqual(errors, [], "no page error");
} finally {
await context.close();
Expand Down
Loading
Loading