diff --git a/.gitignore b/.gitignore
index 7ab17fe..da178fd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,7 @@ release
# Web build runtime state (default paths write to repo root; override via env)
web-settings.json
web-notifications.json
+web-history.json
# Baked build identity (Docker builder writes it; never committed)
.gitsha
diff --git a/CLAUDE.md b/CLAUDE.md
index 144104a..f33ba6f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -50,6 +50,7 @@ A lightweight Electron app that connects to a remote Chromium-based browser via
- **Adaptive Viewport**: An optional mode that eliminates letterbox bars by resizing the remote page to match the canvas via `Emulation.setDeviceMetricsOverride`. The main process caches the last override and re-applies it before `Page.startScreencast` on every (re)connect. See `docs/adr/0002-adaptive-viewport.md`.
- **Packaging allowlist**: `build.files` in `package.json` is an explicit allowlist, not a denylist. Any new file `main.js` requires/reads at runtime (e.g. anything under `core/` or `inject/`) must be added there, or it gets stripped from the asar and the packaged app throws `Cannot find module` on launch. `core/**/*.js` is already in the allowlist; test files (`*.test.ts`) are excluded. Renderer code is safe — it's bundled into `dist/` by Vite.
- **Settings persistence**: Host, port, theme, pins, sidebar width, sidebar-collapsed state, pinned-open state, `adaptiveViewport`, `forceOnClient`, `switchEffect`, `notificationsEnabled`, and `syncTheme` are stored in `userData/settings.json`. Saving a new CDP address immediately reconnects to the first available tab. Legacy `switchBlur` boolean is migrated to `switchEffect`, and legacy `bookmarks` to `pins`, on first load. The three web-only client prefs — `qualityTier`, `inputTransport`, `latencyHud` — persist **per device** in server ui-state under `_` (t100, `src/lib/device-prefs.ts`, `core/settings-store.js` `DEVICE_KEY_PREFIXES`), so they survive an iPad-PWA localStorage wipe; `qualityTier` also mirrors into a plain global shadow the screencast connector reads (the connecting device's tier applies to the shared stream).
+- **Cross-device sync (t103, ADR-0017)**: The web server is the source of truth for **pins + browsing history**, shared across every web/PWA device that talks to it. Electron opts in via two settings — `syncEnabled` + `syncServerUrl` (a reachable **plaintext tailnet** web build, no auth/E2E; Electron-only Settings "Sync" card): when set, `main.js`'s pin IPC handlers + history proxy CRUD to `${syncServerUrl}/api/pins*` / `/api/history` instead of the local `settings.json` / `userData/history.json`; a network failure falls back to local (never a crash). First enable is **server-wins** (`app.tsx` re-loads pins from the server). Browsing history is captured with **no CDP History domain** — `visitsFromTabs` (`core/history-store.js`) diffs the existing `/json` tab poll (`type === "page"` only) into `{url,title,ts}` visits; the server records in `reconcileCycle`, Electron in the `cdp:list-tabs` handler. Suggestions ride `window.cdp.getHistory()` into the New Tab omnibox (`src/lib/tab-suggest.ts`). Web is inherently synced, so its Sync card is hidden. **Pins update near-real-time**: `app.tsx` re-fetches the pin store on the ~3s tab-poll cadence, re-resolves links, and applies only on an actual change (a grace window after a local edit prevents a stale read reverting an in-flight write), so a pin made on another device appears within seconds without reload. History is fresh on every omnibox open.
- **Pins (live-tab holders)**: A pin holds a remote tab (`targetId`), hidden from the Tabs list while linked. Click activates the linked tab or opens+links a fresh one; cmd/middle-click opens an unlinked throwaway tab. Created from a live tab only (toolbar star, right-click tab → Pin, or drag a tab into the Pinned section). A linked pin mirrors its tab's live title/favicon (restoring the saved title when the tab closes); the active pin shows an Arc-style URL-drift cue (a `/` separator and a favicon "Back to Pinned URL" button) when its tab navigates off the saved URL. Closing a pin's tab reverts it to unlinked; un-pinning (confirm dialog) returns the tab to the Tabs list. Cmd+1..9 indexes all pins then visible tabs; Ctrl+Tab cycles open pins + tabs. Link resolution is pure (`src/lib/pins.ts`); persistence/effects live in main + `app.tsx`. See `docs/adr/0004-pin-live-tab-model.md`.
- **Unread badges by group**: Sidebar unread counts are computed by `aggregateUnread` (`src/lib/unread-aggregator.ts`) and keyed by `groupKey` (from the notification entry) falling back to `groupKeyForUrl(url)` — Slack's per-workspace `slack:{teamId}`, else URL origin. Every tab/pin of the same app shares one count whether or not it captured the notification, and a dormant pin still badges by resolving its saved URL through the same key derivation.
- **Local tabs**: Real local web pages rendered as in-DOM Electron ``s on a shared `persist:local` session (`src/components/local-webviews.tsx`) — full device access (OS notifications, speaker/mic, camera, screen-share) that CDP screencast tabs can't have. Because a `` is an in-page OOPIF, React overlays (dialogs, menus, tooltips, the settings sheet) stack **above the live page via CSS z-index** — no native z-order, no freeze. `activeKind: 'cdp' | 'local'` chooses the surface and routes the toolbar/nav hotkeys (`RemotePage` vs the active webview's methods). The renderer holds `LocalTab` metadata and maps webview DOM events to it; only the active webview is shown (others `display:none`, kept alive in the background). All open local tabs persist + restore on launch; pinned ones (a `pinned` flag, distinct from CDP PINNED pins) sort atop the LOCAL TABS section. Unpacked MV3 extensions load into the local session only (`localExtensionPaths`) and their content scripts inject into webview guests; the toolbar shows a Chrome-like action icon per extension (opens its popup in a popover), and popup/options also open as a local tab via the `chrome-extension://` URL. Permissions auto-granted behind the `autoGrantLocalMedia` setting (a `media` request triggers `askForMediaAccess`); packaging ships mic/cam/audio-capture Info.plist keys + entitlements (`build/entitlements.mac.plist`, hardened runtime). See `docs/adr/0005-local-tabs-base-window.md`.
@@ -91,6 +92,7 @@ cdp-browser/
│ ├── notif-mutes.js # Pure mute logic: muteKey(entry) (slack→groupKey, else adapter) + isMuted + unreadExcluding(list,mutes,masterOn) (unread, 0 when master off). Mirrored in src/lib/notif-mutes.ts; gates per-device push in web/server.mjs (t093) AND the Electron OS-notify (shouldNotifyOs) + dock badge via a global notifMutes (t101)
│ ├── push-subscriptions.js # Pure E0: endpoint-reconciled deviceId (t095, ADR-0014). reconcileDeviceId(existingSubs, {endpoint}) → {deviceId, isNew} — server-authoritative identity surviving storage wipe
│ ├── push-send-options.js # Pure E2: push notification send options (t095). pushSendOptions() → {urgency:"high", TTL:1800} for timely, non-stale delivery
+│ ├── history-store.js # Pure browsing-history read-model (t103, ADR-0017): recordVisit (dedup-by-url, frecency, cap), rankHistory (New Tab omnibox source), visitsFromTabs (tab-poll → visits diff). Shared by web/server.mjs + main.js; matcher mirrored in src/lib/tab-suggest.ts
│ (Channel Exclude logic lives in src/lib/slack-excludes.ts — renderer edits ui-state, server sweep reads it, t072)
│ └── crypto-envelope.js # Server-side AES-256-GCM seal/open (E2E mode); mirrors src/lib/crypto-envelope.ts
├── web/
@@ -140,6 +142,8 @@ cdp-browser/
│ ├── key-routing.ts # isOsReservedKey — gates Input Forwarding for macOS-reserved combos
│ ├── typing-surface.ts # isTypingSurface(activeKind) — bare-char shortcut guard: true when canvas/webview is active (t065)
│ ├── pins.ts # Pin link resolution: resolvePinLink, pinForTarget, dropDeadLinks
+ │ ├── tab-suggest.ts # Pure New Tab omnibox matcher (t103): merges history + open tabs → ranked switch/history suggestions, diacritic-safe. Mirrors core/history-store.js ranking
+ │ ├── fold-text.ts # Pure Vietnamese-aware diacritic/case fold for substring matching (t103); mirrors core/history-store.js fold
│ ├── local-tabs.ts # Local tabs: LocalTab shape, sortPinnedFirst, to/fromPersisted
│ ├── closed-tabs.ts # Unified close-ordered reopen stack ({kind,url}) across CDP + local
│ ├── active-order.ts # MRU activation history across CDP + local: touchActive, dropActive, mostRecent
@@ -187,7 +191,7 @@ cdp-browser/
├── status-bar.tsx # Bottom status bar for loading/error states (replaces mid-viewport overlay)
├── settings-dialog.tsx # Non-modal right Sheet drawer (showOverlay=false); grouped cards; hybrid mouse-leave + keyboard-commit close (mouse-leave gated to fine pointer via shouldArmLeaveTimer; coarse pointer dismisses via header close button + scrim tap); Cmd+, toggles
├── notification-bell.tsx # Bell icon + badge + popover; grouped by conversation
- ├── new-tab-dialog.tsx # URL input + pin quick-launch (reused for CDP and local)
+ ├── new-tab-dialog.tsx # Omnibox: URL input + pin quick-launch + history suggestions & "Switch to tab" for open tabs (t103, via tab-suggest.ts + window.cdp.getHistory)
├── edit-pin-dialog.tsx # Edit pin title/URL, with "Use current tab URL" when the linked tab has drifted
├── local-webviews.tsx # Local tabs as in-DOM s; imperative nav API + DOM-event → LocalTab mapping
├── install-banner.tsx # iOS Safari install nudge (Add to Home Screen); 1-week localStorage dismiss; PWA-only
diff --git a/CONTEXT.md b/CONTEXT.md
index 45fbcef..daa7990 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -100,6 +100,14 @@ _Avoid_: preview, mini-client, mobile Slack.
A locally-rendered web page displayed as an in-DOM Electron `` element inside the chrome view. Unlike a **Tab** (which renders a remote page as a JPEG screencast), a Local Tab has full device access: real OS notifications, audio, mic, camera, screen-share, and loadable MV3 extensions. The renderer holds `LocalTab` metadata (`{ id, url, title, favicon?, pinned, loading, canGoBack, canGoForward, audible, muted }`); the main process owns the `persist:local` session and extension loading. Local Tabs occupy the LOCAL TABS sidebar section; a `pinned` flag (distinct from CDP Pins) keeps them atop that section and restores them on relaunch. See `docs/adr/0005-local-tabs-base-window.md`.
_Avoid_: native tab, page view, webview tab.
+**Visit**:
+One recorded page load, `{ url, title, visitCount, lastVisit }`, folded into the browsing-history store by `core/history-store.js`'s `recordVisit` (dedup by url) from a `/json` tab-poll diff (`visitsFromTabs`) — CDP/Edge exposes no History domain, so a Visit is only ever inferred from a Tab's url changing, not read back from the Remote Browser. Ranked by frecency (`rankHistory`) for the New Tab omnibox. See `docs/adr/0017-shared-sync-backend-for-pins-and-history.md`.
+_Avoid_: history entry, page visit, navigation event.
+
+**Sync Backend**:
+The web server acting as the shared source of truth for **Pin**s and **Visit**s across a user's devices (ADR-0017). Web is a Sync Backend client by construction (`/api/pins*`, `/api/history*`); Electron opts in per device via `syncEnabled` + `syncServerUrl` (plaintext tailnet URL, no auth/E2E), proxying its Pin + Visit reads/writes there instead of its local `settings.json` / `history.json` and falling back to local on any network failure. First enable is server-wins.
+_Avoid_: sync server, sync service, cloud sync.
+
## Relationships
- A **Remote Browser** hosts many **Tabs**; exactly one is the **Active Tab**.
@@ -110,6 +118,7 @@ _Avoid_: native tab, page view, webview tab.
- A **Notification Side-Channel** attaches to a background **Tab**'s target and uses a **Notification Adapter** to run **Notification Capture** — independent of the **Active Tab**'s screencast socket. Clicking the result activates the owning Tab and, if the entry carries an `activate` intent, the activation registry maps it to a **Remote Page** deep-open intention.
- For Slack, the **Slack Content Sweep** is the authoritative **Notification Capture** writer; the in-page hijack provides only an instant foreground toast. The sweep reads creds from a live **Tab**, persists workspaces in the **Workspace Registry**, uses the **Parked Tab** keeper (pin-deferred since t098 — a pinned workspace is owned by its Pin), and respects the **Channel Exclude** list.
- A **Local Tab** renders a real local web page (in-DOM ``) alongside CDP Tabs — it does not use **Screencast Frames** or **Input Forwarding**; it gets direct device access instead.
+- The **Sync Backend** is the shared store for **Pin**s and **Visit**s; the New Tab omnibox ranks **Visit**s (`rankHistory`/`suggest`) alongside currently-open Tabs to offer a "switch to tab" result ahead of opening a duplicate.
## Example dialogue
diff --git a/core/history-store.js b/core/history-store.js
new file mode 100644
index 0000000..7b18121
--- /dev/null
+++ b/core/history-store.js
@@ -0,0 +1,99 @@
+// Pure browsing-history read-model (t103, ADR-0017). Shared by web/server.mjs and
+// main.js — neither can import the renderer's ESM, so the matcher is mirrored in
+// src/lib/tab-suggest.ts (same duplication pattern as core/notif-mutes.js).
+//
+// CDP/Edge exposes no History domain, so history is *recorded* from the tab poll:
+// visitsFromTabs diffs a /json snapshot into visits, the caller stamps `ts` and
+// folds them in with recordVisit. rankHistory serves the New Tab omnibox.
+//
+// A Visit is { url, title, visitCount, lastVisit }. Ranking is frecency
+// (frequency × recency) so a page you open often + recently floats to the top.
+
+const DAY = 24 * 3600_000
+const DEFAULT_CAP = 1000
+
+// Diacritic + case fold for Vietnamese-aware matching. NFD strips tone/diacritic
+// combining marks; đ/Đ don't decompose so they're replaced explicitly.
+function fold(s) {
+ return (s || "")
+ .normalize("NFD")
+ .replace(/[̀-ͯ]/g, "")
+ .replace(/đ/g, "d")
+ .replace(/Đ/g, "D")
+ .toLowerCase()
+}
+
+// Only real web pages get remembered — no blank/internal/non-http schemes.
+function isHistoryableUrl(url) {
+ return typeof url === "string" && /^https?:\/\//i.test(url)
+}
+
+// Frecency: visits weighted by recency. A fresh page (age 0) keeps full weight; an
+// old one decays toward zero. Simple 1/(1+ageDays) decay — enough to order suggestions.
+function frecencyScore(visit, now) {
+ const ageDays = Math.max(0, now - visit.lastVisit) / DAY
+ return visit.visitCount * (1 / (1 + ageDays))
+}
+
+// Fold a visit into the store: dedup by url (bump count + recency, keep newest
+// non-empty title), else prepend. Caps to the lowest-frecency entries. Immutable.
+function recordVisit(visits, { url, title, ts }, opts = {}) {
+ const cap = opts.cap || DEFAULT_CAP
+ const now = opts.now != null ? opts.now : ts
+ const idx = visits.findIndex((v) => v.url === url)
+ let next
+ if (idx >= 0) {
+ const existing = visits[idx]
+ const updated = {
+ url,
+ title: title || existing.title,
+ visitCount: existing.visitCount + 1,
+ lastVisit: ts,
+ }
+ next = visits.slice()
+ next[idx] = updated
+ } else {
+ next = [{ url, title: title || url, visitCount: 1, lastVisit: ts }, ...visits]
+ }
+ if (next.length > cap) {
+ next = next
+ .slice()
+ .sort((a, b) => frecencyScore(b, now) - frecencyScore(a, now))
+ .slice(0, cap)
+ }
+ return next
+}
+
+// The New Tab omnibox source: filter by folded query over title + url, order by
+// frecency, cap to `limit`. Empty query returns the top-frecency pages.
+function rankHistory(visits, { query, now, limit }) {
+ const q = fold(query)
+ const matched = q
+ ? visits.filter((v) => fold(v.title).includes(q) || fold(v.url).includes(q))
+ : visits.slice()
+ matched.sort((a, b) => frecencyScore(b, now) - frecencyScore(a, now))
+ return matched.slice(0, limit)
+}
+
+// Diff a /json tab snapshot against the last-seen url per tab. A tab whose url is
+// new (or changed) yields one visit; unchanged and non-historyable tabs are skipped.
+// Returns the changed {url,title} pairs (caller stamps ts) + the next per-tab map.
+function visitsFromTabs(prevByTab, tabs) {
+ const changed = []
+ const next = {}
+ for (const t of tabs) {
+ if (!isHistoryableUrl(t.url)) continue
+ next[t.id] = t.url
+ if (prevByTab[t.id] !== t.url) changed.push({ url: t.url, title: t.title || "" })
+ }
+ return { changed, next }
+}
+
+module.exports = {
+ fold,
+ isHistoryableUrl,
+ frecencyScore,
+ recordVisit,
+ rankHistory,
+ visitsFromTabs,
+}
diff --git a/core/history-store.test.ts b/core/history-store.test.ts
new file mode 100644
index 0000000..32b4bc0
--- /dev/null
+++ b/core/history-store.test.ts
@@ -0,0 +1,154 @@
+import { describe, expect, it } from "vitest"
+// CommonJS module shared with web/server.mjs + main.js (which can't import src/lib ESM).
+import {
+ frecencyScore,
+ isHistoryableUrl,
+ rankHistory,
+ recordVisit,
+ visitsFromTabs,
+} from "./history-store"
+
+const HOUR = 3600_000
+const DAY = 24 * HOUR
+
+describe("recordVisit", () => {
+ it("adds a new visit with visitCount 1", () => {
+ const out = recordVisit([], { url: "https://a.com/", title: "A", ts: 1000 })
+ expect(out).toEqual([{ url: "https://a.com/", title: "A", visitCount: 1, lastVisit: 1000 }])
+ })
+
+ it("dedups by url — bumps visitCount and lastVisit, keeps latest title", () => {
+ let v = recordVisit([], { url: "https://a.com/", title: "Old", ts: 1000 })
+ v = recordVisit(v, { url: "https://a.com/", title: "New", ts: 2000 })
+ expect(v).toEqual([{ url: "https://a.com/", title: "New", visitCount: 2, lastVisit: 2000 }])
+ })
+
+ it("keeps the existing title when the new visit has none", () => {
+ let v = recordVisit([], { url: "https://a.com/", title: "Kept", ts: 1000 })
+ v = recordVisit(v, { url: "https://a.com/", title: "", ts: 2000 })
+ expect(v[0].title).toBe("Kept")
+ })
+
+ it("falls back to the url as title for a titleless new url", () => {
+ const v = recordVisit([], { url: "https://a.com/", title: "", ts: 1000 })
+ expect(v[0].title).toBe("https://a.com/")
+ })
+
+ it("does not mutate the input array", () => {
+ const orig = [{ url: "https://a.com/", title: "A", visitCount: 1, lastVisit: 1000 }]
+ const copy = JSON.parse(JSON.stringify(orig))
+ recordVisit(orig, { url: "https://b.com/", title: "B", ts: 2000 })
+ expect(orig).toEqual(copy)
+ })
+
+ it("caps the store, dropping the lowest-frecency entries", () => {
+ let v: unknown[] = []
+ // three low-count old visits + one fresh — cap to 2 keeps the fresh + highest.
+ v = recordVisit(v, { url: "https://old1/", title: "", ts: 0 })
+ v = recordVisit(v, { url: "https://old2/", title: "", ts: 0 })
+ v = recordVisit(
+ v,
+ { url: "https://fresh/", title: "", ts: 10 * DAY },
+ { cap: 2, now: 10 * DAY },
+ )
+ expect(v).toHaveLength(2)
+ expect(v.some((e: any) => e.url === "https://fresh/")).toBe(true)
+ })
+})
+
+describe("frecencyScore", () => {
+ it("rewards more visits", () => {
+ const now = 10 * DAY
+ const a = frecencyScore({ url: "a", title: "", visitCount: 5, lastVisit: now }, now)
+ const b = frecencyScore({ url: "b", title: "", visitCount: 1, lastVisit: now }, now)
+ expect(a).toBeGreaterThan(b)
+ })
+
+ it("rewards recency — a fresh single visit beats an ancient one", () => {
+ const now = 100 * DAY
+ const fresh = frecencyScore({ url: "a", title: "", visitCount: 1, lastVisit: now }, now)
+ const old = frecencyScore({ url: "b", title: "", visitCount: 1, lastVisit: 0 }, now)
+ expect(fresh).toBeGreaterThan(old)
+ })
+})
+
+describe("rankHistory", () => {
+ const now = 10 * DAY
+ const visits = [
+ { url: "https://github.com/", title: "GitHub", visitCount: 10, lastVisit: now },
+ { url: "https://google.com/", title: "Google", visitCount: 1, lastVisit: now - 5 * DAY },
+ { url: "https://gitlab.com/", title: "GitLab", visitCount: 3, lastVisit: now - DAY },
+ ]
+
+ it("empty query ranks by frecency desc", () => {
+ const out = rankHistory(visits, { query: "", now, limit: 10 })
+ expect(out[0].url).toBe("https://github.com/")
+ })
+
+ it("matches on url", () => {
+ const out = rankHistory(visits, { query: "gitlab", now, limit: 10 })
+ expect(out.map((v) => v.url)).toEqual(["https://gitlab.com/"])
+ })
+
+ it("matches on title", () => {
+ const out = rankHistory(visits, { query: "google", now, limit: 10 })
+ expect(out.map((v) => v.url)).toEqual(["https://google.com/"])
+ })
+
+ it("is diacritic-insensitive", () => {
+ const v = [{ url: "https://x/", title: "Đà Nẵng", visitCount: 1, lastVisit: now }]
+ expect(rankHistory(v, { query: "da nang", now, limit: 10 })).toHaveLength(1)
+ })
+
+ it("respects the limit", () => {
+ const out = rankHistory(visits, { query: "git", now, limit: 1 })
+ expect(out).toHaveLength(1)
+ })
+})
+
+describe("visitsFromTabs", () => {
+ it("emits a visit for each newly-seen tab url", () => {
+ const { changed, next } = visitsFromTabs({}, [
+ { id: "1", url: "https://a.com/", title: "A" },
+ { id: "2", url: "https://b.com/", title: "B" },
+ ])
+ expect(changed).toEqual([
+ { url: "https://a.com/", title: "A" },
+ { url: "https://b.com/", title: "B" },
+ ])
+ expect(next).toEqual({ "1": "https://a.com/", "2": "https://b.com/" })
+ })
+
+ it("ignores tabs whose url is unchanged", () => {
+ const prev = { "1": "https://a.com/" }
+ const { changed } = visitsFromTabs(prev, [{ id: "1", url: "https://a.com/", title: "A" }])
+ expect(changed).toEqual([])
+ })
+
+ it("emits when a tab navigates to a new url", () => {
+ const prev = { "1": "https://a.com/" }
+ const { changed } = visitsFromTabs(prev, [{ id: "1", url: "https://a.com/next", title: "A2" }])
+ expect(changed).toEqual([{ url: "https://a.com/next", title: "A2" }])
+ })
+
+ it("skips non-historyable urls", () => {
+ const { changed } = visitsFromTabs({}, [
+ { id: "1", url: "about:blank", title: "" },
+ { id: "2", url: "https://ok.com/", title: "OK" },
+ ])
+ expect(changed).toEqual([{ url: "https://ok.com/", title: "OK" }])
+ })
+})
+
+describe("isHistoryableUrl", () => {
+ it("accepts http and https", () => {
+ expect(isHistoryableUrl("https://a.com/")).toBe(true)
+ expect(isHistoryableUrl("http://a.com/")).toBe(true)
+ })
+
+ it("rejects blank / internal / non-http schemes", () => {
+ for (const u of ["", "about:blank", "chrome://newtab", "edge://settings", "devtools://x"]) {
+ expect(isHistoryableUrl(u)).toBe(false)
+ }
+ })
+})
diff --git a/core/settings-store.js b/core/settings-store.js
index 95b11da..9c3dc2c 100644
--- a/core/settings-store.js
+++ b/core/settings-store.js
@@ -39,6 +39,12 @@ const UI_DEFAULTS = {
// channels/DMs as { team, channelId, label }. The server sweep reads it; the renderer
// edits it (a "Mute this channel" action + the Settings list). See ADR-0011.
slackExcludes: [],
+ // Electron-only cross-device sync (t103, ADR-0017). When `syncEnabled` and a
+ // `syncServerUrl` are set, main.js routes pin + history CRUD to that web server
+ // (plaintext tailnet) so the Mac app shares one set with the PWA. The web build
+ // is inherently server-backed, so it ignores both.
+ syncEnabled: false,
+ syncServerUrl: "",
}
// Keys settable via setUiState (localExtensionPaths is owned by extension flows).
const UI_SETTABLE = Object.keys(UI_DEFAULTS).filter((k) => k !== "localExtensionPaths")
diff --git a/docs/adr/0017-shared-sync-backend-for-pins-and-history.md b/docs/adr/0017-shared-sync-backend-for-pins-and-history.md
new file mode 100644
index 0000000..9f9341d
--- /dev/null
+++ b/docs/adr/0017-shared-sync-backend-for-pins-and-history.md
@@ -0,0 +1,83 @@
+# ADR-0017: Shared sync backend for pins and history
+
+- **Status:** Accepted
+- **Date:** 2026-07-09
+
+## Context
+
+Pins persist through the same `core/settings-store.js` API in both builds, but to
+**different files**: the web server writes `web-settings.json`, and every web/PWA
+device that talks to one server already shares those pins. The Electron app writes
+`userData/settings.json` on the local Mac, so its pins never leave the machine.
+Result: a pin made on the Mac never shows on the iPad PWA and vice-versa.
+
+Separately, the New Tab modal only suggests pins. There is no browsing history to
+suggest from — and CDP/Edge exposes no history API (there is no `History` domain),
+so history has to be *recorded* as the user browses rather than read from the
+remote browser.
+
+We want: (1) one pin set shared across the user's devices, opt-in per device;
+(2) a browsing-history store that follows the user across devices; (3) New Tab
+suggestions from that history (matched on address **and** title) plus a
+"Switch to tab" action for tabs already open.
+
+## Decision
+
+The **web server is the source of truth** for synced pins and history. Both are
+served over its plain HTTP API.
+
+- **Pins.** Web is already server-backed (`/api/pins*`) and therefore already
+ synced across web devices. Electron gains two settings — `syncEnabled` (bool)
+ and `syncServerUrl` (string). When both are set, Electron's pin IPC handlers
+ proxy CRUD to `${syncServerUrl}/api/pins*` over **plaintext HTTP** instead of
+ the local settings file; when off, it keeps today's local behavior. First
+ enable is **server-wins**: the device adopts the server's pin set.
+
+- **History.** A new pure `core/history-store.js` owns the read-model
+ (dedup-by-url, frecency rank, cap) and the tab-snapshot diff that turns the
+ existing `/json` tab poll into visit records `{ url, title, ts, visitCount }` —
+ no extra CDP subscription. The server persists `history.json` and serves
+ `/api/history`. Electron records to the same server when sync is on, else to a
+ local file.
+
+- **Transport for Electron.** Plaintext tailnet URL, no auth and no E2E — the
+ sync endpoint is expected to be a reachable tailnet address (like the previews),
+ not the Authentik/E2E-fronted prod portal. Keeping Electron plaintext avoids
+ porting the crypto-envelope handshake into the main process.
+
+- **Propagation.** Pins update in **near-real-time**: every renderer re-fetches the
+ pin store on the same ~3s cadence as the tab-list poll, re-resolves each pin's
+ link against its live tabs, and applies only when the set actually changed (a
+ short grace window after a local edit prevents a stale read reverting an in-flight
+ write; an unchanged fetch never churns the list, so a drag in progress is safe).
+ History is fetched fresh each time the New Tab omnibox opens, so it is current on
+ every open. No live SSE push is needed — the poll is simple and uniform across
+ both builds.
+
+## Consequences
+
+- Easier: one pin set and one history across the Mac app and the iPad PWA; New
+ Tab becomes a real omnibox (history + open-tab switch), matching the daily-
+ driver bar.
+- Harder: Electron gains an **optional dependency on the web server** — a second
+ network target beyond the CDP host. It is opt-in and degrades to local when the
+ URL is unset or unreachable.
+- Plaintext-only Electron sync will **not** work against the Authentik/E2E prod
+ portal; a tailnet endpoint is required. Revisit if that becomes limiting.
+- History is captured from the tab poll, so it records the pages that appear in
+ the tab list — not in-tab SPA route changes that never change the `/json` URL.
+ Acceptable for suggestion quality; revisit if SPA-heavy history matters.
+
+## Alternatives
+
+- **Electron talks CDP only, no server.** Rejected: there is no shared store, so
+ no cross-device sync — the whole point.
+- **A separate dedicated sync service.** Rejected: the web server already holds
+ the authoritative pins and is already deployed; a new service is more moving
+ parts for no gain.
+- **Capture history via `Page.frameNavigated` side-channels on every tab.**
+ Rejected for v1: more CDP sockets and lifecycle to manage; the tab-poll diff is
+ simpler and covers the real use (pages you actually land on). Can be added later
+ if SPA route history is wanted.
+- **E2E/Authentik-aware Electron sync.** Deferred: needs the crypto handshake in
+ main; plaintext tailnet is enough for now.
diff --git a/docs/tasks/done/103-sync-pins-and-history-across-devices-with-new-tab.md b/docs/tasks/done/103-sync-pins-and-history-across-devices-with-new-tab.md
new file mode 100644
index 0000000..b7b6e85
--- /dev/null
+++ b/docs/tasks/done/103-sync-pins-and-history-across-devices-with-new-tab.md
@@ -0,0 +1,91 @@
+# 103 — Sync pins and history across devices with New Tab suggestions
+
+- **Status:** done
+- **Mode:** HITL
+- **Estimate:** 1d
+- **Depends on:** none
+- **Blocks:** none
+
+## Goal
+
+Pins and browsing history become shared across the user's devices via the web
+server, and the New Tab modal turns into an omnibox: it suggests from history
+(matched on address **and** title) and offers "Switch to tab" for tabs already
+open. The Electron app opts into the shared backend with a per-device toggle +
+sync-server URL; when off it behaves as today (local pins, no remote history).
+
+## Why now
+
+The daily driver is the iPad PWA plus the Mac Electron app. Today a pin made on
+one never appears on the other, and New Tab only knows about pins. This closes
+both gaps and makes New Tab a real address bar. See ADR-0017.
+
+## Acceptance criteria
+
+- [~] A pin created/removed/reordered in Electron (sync on) appears on the PWA and vice-versa. — code complete; final two-device confirmation deferred to the preview-deploy test.
+- [x] Turning sync ON in Electron adopts the server's pin set (server-wins). — `handleSyncEnabledChange` re-loads pins from the server.
+- [x] Browsing pages records history on the server; it persists across restarts. — verified: `web-history.json` populated from the tab poll, page-only.
+- [x] New Tab suggests history matched by both URL and page title, frecency-ranked.
+- [x] A query matching an open tab (CDP or local) shows "Switch to tab" and activates it instead of opening a duplicate. — verified via /cdp (switched to the general Slack tab, no dup).
+- [x] The pinned quick-launch row still shows when the query is empty.
+- [x] Sync toggle + (Electron-only) server-URL field live in Settings; web is inherently synced (card hidden on web).
+- [x] With sync off / URL unset / server unreachable, Electron falls back to local pins and doesn't crash (`syncOrLocal`).
+
+## Test plan
+
+### Layer 1 — Pure logic (TDD)
+
+- [x] `core/history-store.js` `recordVisit` — dedup by URL, bump visitCount, update title/lastVisit, cap size
+- [x] `core/history-store.js` `rankHistory` — frecency ordering (recency × frequency)
+- [x] `core/history-store.js` `visitsFromTabs` — diffs a tab snapshot into new visits, ignores unchanged URLs
+- [x] `src/lib/tab-suggest.ts` — matches history on url + title, diacritic-safe; open-tab matches marked as switch; ranking/limit
+
+### Layer 2 — Manual smoke (CDP/IPC)
+
+- [~] Electron sync on → pin appears on PWA (server file updated); sync off → local file only — server pin/history endpoints proven live; two-device Electron↔PWA confirmation in the preview test.
+- [x] Electron unreachable sync URL → pins fall back to local, no crash (`syncOrLocal` try/catch → local store).
+- [x] Browsing in web writes history; Electron main boots clean with the capture wired in `cdp:list-tabs`.
+
+### Layer 3 — Visual review
+
+- [x] Screenshots via Chrome MCP against the web build (omnibox + tab-item polish).
+- [x] New Tab: empty (pins), typing (Open URL + switch rows) verified; no-match falls through to pins.
+- [~] Settings sync card renders — Electron-only (`!caps.web`), hidden on web as designed; live Electron GUI render not screenshotted.
+
+## Design notes
+
+- **Contracts changed:** `CdpBridge` (`window.cdp`) — add `getHistory()` and `recordVisit(visit)`; `NewTabDialogProps` — add `history`, `openTabs`, `onSwitchTab`. New Electron settings `syncEnabled: boolean`, `syncServerUrl: string`.
+- **New modules:** `core/history-store.js` (pure history read-model + tab-diff), `src/lib/tab-suggest.ts` (pure suggestion matcher). One Electron sync helper in `main.js` (effectful HTTP proxy).
+- **New ADR needed?** yes — ADR-0017 (written).
+
+```ts
+type Visit = { url: string; title: string; ts: number; visitCount: number }
+type Suggestion =
+ | { kind: "switch"; tabKind: "cdp" | "local"; id: string; title: string; url: string }
+ | { kind: "history"; title: string; url: string }
+```
+
+## Out of scope
+
+- E2E/Authentik-aware Electron sync (plaintext tailnet only — ADR-0017).
+- Real-time push of pin/history edits — sync is eventual (on launch/reconnect + omnibox open + the on-toggle re-load); no live SSE channel.
+- SPA in-tab route history that never changes the `/json` URL.
+- Syncing anything beyond pins + history (settings, notifications stay as-is).
+
+## Definition of Done
+
+- [x] Layer 1 tests written and green (25 new tests: history-store + tab-suggest)
+- [~] Layer 2 smoke — web path proven against a live Remote Browser; Electron main boots clean; two-device sync confirmation in the preview test
+- [x] Layer 3 screenshots captured (omnibox + tab-item polish)
+- [x] `pnpm typecheck` clean
+- [x] `pnpm test` (1049) + `pnpm test:e2e` (49) green
+- [x] CLAUDE.md + src/lib/CLAUDE.md + CONTEXT.md updated for modified modules
+- [x] ADR-0017 written
+- [x] Task closed: status → done, moved to `docs/tasks/done/`, t103 in commit
+
+## Notes
+
+Grilled decisions (2026-07-09): Electron joins server; server-wins on enable;
+history stored on shared backend; New Tab = history(url+title) + switch-to-tab +
+keep pins; Electron link is plaintext tailnet URL (no auth/E2E); all three built
+together.
diff --git a/main.js b/main.js
index 6e827d1..dd3fc6b 100644
--- a/main.js
+++ b/main.js
@@ -19,6 +19,7 @@ const { emulatedMediaParams } = require("./core/theme-emulation")
const { createSettingsStore } = require("./core/settings-store")
const endpoints = require("./core/cdp-endpoints")
const { tierToParams, DEFAULT_TIER } = require("./core/quality-tier")
+const { recordVisit: historyRecord, visitsFromTabs } = require("./core/history-store")
// The window is a BaseWindow composed of a chrome view (the React UI, full
// window) layered over zero-or-more local-tab page views. `chromeView` hosts
@@ -93,6 +94,85 @@ const saveSettings = () => persist(settings)
// Re-persist the migrated shape once on first load when a legacy key was present.
if (hadLegacyKeys) saveSettings()
+// ---- cross-device sync (t103, ADR-0017) -----------------------------------
+// When sync is on and a server URL is set, pins + history read/write the shared
+// web-server backend (plaintext tailnet, no auth/E2E) instead of the local file,
+// so the Mac app and the iPad PWA share one set. Server-wins: reads come from the
+// server, so enabling sync simply shows the server's pins. Any network failure
+// falls back to the local store — sync is best-effort, never a crash.
+const syncBase = () =>
+ settings.syncEnabled && settings.syncServerUrl
+ ? String(settings.syncServerUrl).replace(/\/+$/, "")
+ : null
+
+async function syncGetJson(pathname) {
+ const res = await fetch(syncBase() + pathname)
+ return res.json()
+}
+// `parseJson` is only true for endpoints that reply with a body (the pin CRUD
+// endpoints echo the resulting pins array); history/record replies 204.
+async function syncPost(pathname, payload, { parseJson = false } = {}) {
+ const res = await fetch(syncBase() + pathname, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(payload),
+ })
+ return parseJson ? res.json() : undefined
+}
+
+// Try the shared backend first, falling back to the local store on any network
+// failure — sync is best-effort, never a crash.
+async function syncOrLocal(syncCall, localCall) {
+ if (syncBase()) {
+ try {
+ return await syncCall()
+ } catch {
+ /* fall back to local */
+ }
+ }
+ return localCall()
+}
+
+// Local history fallback (userData/history.json) — used only when sync is off.
+const historyPath = path.join(app.getPath("userData"), "history.json")
+let localHistory = (() => {
+ try {
+ return JSON.parse(fs.readFileSync(historyPath, "utf8"))
+ } catch {
+ return []
+ }
+})()
+const saveLocalHistory = () => {
+ try {
+ fs.writeFileSync(historyPath, JSON.stringify(localHistory))
+ } catch (e) {
+ console.error("[main] history persist failed:", e.message)
+ }
+}
+let lastTabUrls = {} // tabId → last-seen url, for the navigation diff
+
+// Fold a /json snapshot into history — to the server when syncing, else the local
+// file. Non-fatal: a sync POST failure just drops that visit.
+async function ingestHistoryFromTabs(tabs) {
+ const pages = tabs.filter((t) => t.type === "page")
+ const { changed, next } = visitsFromTabs(lastTabUrls, pages)
+ lastTabUrls = next
+ if (changed.length === 0) return
+ const now = Date.now()
+ if (syncBase()) {
+ for (const v of changed) {
+ try {
+ await syncPost("/api/history/record", { ...v, ts: now })
+ } catch {
+ /* best-effort */
+ }
+ }
+ } else {
+ for (const v of changed) localHistory = historyRecord(localHistory, { ...v, ts: now })
+ saveLocalHistory()
+ }
+}
+
let cdpHost = settings.host
let cdpPort = settings.port
@@ -216,7 +296,9 @@ ipcMain.handle("cdp:list-tabs", async () => {
try {
const { url, method } = endpoints.list(cdpHost, cdpPort)
const res = await fetch(url, { method })
- return await res.json()
+ const tabs = await res.json()
+ if (Array.isArray(tabs)) ingestHistoryFromTabs(tabs)
+ return tabs
} catch (e) {
return { error: e.message }
}
@@ -465,11 +547,44 @@ ipcMain.handle("cdp:set-ui-state", (_, partial) => {
// Pins (live-tab holders). The renderer owns link state (`targetId`); main is the
// persistent store. `reorder-pins` replaces the whole array, so it also persists
// link/unlink changes. Dedup-by-url and persistence live in the shared store.
-ipcMain.handle("cdp:get-pins", () => settingsStore.getPins())
-ipcMain.handle("cdp:add-pin", (_, pin) => settingsStore.addPin(pin))
-ipcMain.handle("cdp:update-pin", (_, id, patch) => settingsStore.updatePin(id, patch))
-ipcMain.handle("cdp:remove-pin", (_, id) => settingsStore.removePin(id))
-ipcMain.handle("cdp:reorder-pins", (_, pins) => settingsStore.reorderPins(pins))
+// Sync-aware: when a sync server is set, pin CRUD rides the shared backend so the
+// pin set matches the PWA; a network failure falls back to the local store.
+ipcMain.handle("cdp:get-pins", () =>
+ syncOrLocal(
+ () => syncGetJson("/api/pins"),
+ () => settingsStore.getPins(),
+ ),
+)
+ipcMain.handle("cdp:add-pin", (_, pin) =>
+ syncOrLocal(
+ () => syncPost("/api/pins/add", pin, { parseJson: true }),
+ () => settingsStore.addPin(pin),
+ ),
+)
+ipcMain.handle("cdp:update-pin", (_, id, patch) =>
+ syncOrLocal(
+ () => syncPost("/api/pins/update", { id, patch }, { parseJson: true }),
+ () => settingsStore.updatePin(id, patch),
+ ),
+)
+ipcMain.handle("cdp:remove-pin", (_, id) =>
+ syncOrLocal(
+ () => syncPost("/api/pins/remove", { id }, { parseJson: true }),
+ () => settingsStore.removePin(id),
+ ),
+)
+ipcMain.handle("cdp:reorder-pins", (_, pins) =>
+ syncOrLocal(
+ () => syncPost("/api/pins/reorder", { pins }, { parseJson: true }),
+ () => settingsStore.reorderPins(pins),
+ ),
+)
+ipcMain.handle("cdp:get-history", () =>
+ syncOrLocal(
+ () => syncGetJson("/api/history"),
+ () => localHistory,
+ ),
+)
ipcMain.handle("cdp:set-theme-source", (_, source) => {
nativeTheme.themeSource = source
diff --git a/preload.js b/preload.js
index 275291b..d32568e 100644
--- a/preload.js
+++ b/preload.js
@@ -34,6 +34,8 @@ contextBridge.exposeInMainWorld("cdp", {
updatePin: (id, patch) => ipcRenderer.invoke("cdp:update-pin", id, patch),
removePin: (id) => ipcRenderer.invoke("cdp:remove-pin", id),
reorderPins: (pins) => ipcRenderer.invoke("cdp:reorder-pins", pins),
+ // Browsing history (t103) — New Tab omnibox source
+ getHistory: () => ipcRenderer.invoke("cdp:get-history"),
// Notifications
getNotifications: () => ipcRenderer.invoke("cdp:get-notifications"),
markNotificationRead: (id) => ipcRenderer.invoke("cdp:mark-notification-read", id),
diff --git a/src/app.tsx b/src/app.tsx
index fd9bb45..70197f3 100644
--- a/src/app.tsx
+++ b/src/app.tsx
@@ -87,6 +87,20 @@ type NavRow =
| { kind: "tab"; id: string }
| { kind: "local"; id: string }
+// Re-links each pin's targetId against the current tab list (a saved pin can point at a
+// target that closed, or match a fresh tab opened at the same URL). Shared by the initial
+// load, the realtime poll (t103), and manual sync-enable — all three re-resolve a pin list
+// fetched from the store the same way.
+function resolvePinsAgainstTabs(loaded: Pin[], tabs: TabInfo[]): Pin[] {
+ return loaded.map((p) => {
+ const targetId = resolvePinLink(p, tabs)
+ if (targetId === p.targetId) return p
+ if (targetId) return { ...p, targetId }
+ const { targetId: _gone, ...rest } = p
+ return rest
+ })
+}
+
function applyThemeClass(theme: ThemeSource, systemDark: boolean) {
const isDark = theme === "dark" || (theme === "system" && systemDark)
document.documentElement.classList.toggle("dark", isDark)
@@ -159,6 +173,8 @@ export default function App() {
const [notificationsEnabled, setNotificationsEnabled] = useState(true)
const [notifMutes, setNotifMutes] = useState([])
const [syncTheme, setSyncTheme] = useState(true)
+ const [syncEnabled, setSyncEnabled] = useState(false)
+ const [syncServerUrl, setSyncServerUrl] = useState("")
const [bellOpen, setBellOpen] = useState(false)
const [newTabOpen, setNewTabOpen] = useState(false)
const [newTabKind, setNewTabKind] = useState("cdp")
@@ -187,6 +203,10 @@ export default function App() {
const notificationsRef = useRef([])
const activeTabIdRef = useRef(null)
const pinsRef = useRef([])
+ // Timestamp of the last local pin edit — the realtime pin-sync poll skips a short
+ // window after it so an in-flight fire-and-forget write can't be reverted by a
+ // stale server read (t103).
+ const pinEditAtRef = useRef(0)
const page = useRemotePage()
const caps = getCaps()
// Phone Shell (t076, ADR-0012): below the width breakpoint the Inbox is the root
@@ -405,6 +425,15 @@ export default function App() {
switchTab: (id) => switchTabRef.current?.(id),
})
+ // Open tabs (CDP + local) for the New Tab "Switch to tab" suggestion (t103).
+ const openTabsForSuggest = useMemo(
+ () => [
+ ...tabs.map((t) => ({ kind: "cdp" as const, id: t.id, title: t.title, url: t.url })),
+ ...localTabs.map((t) => ({ kind: "local" as const, id: t.id, title: t.title, url: t.url })),
+ ],
+ [tabs, localTabs],
+ )
+
// Theme initialization
useEffect(() => {
// Reflect the current DOM theme in the OS chrome immediately, before the async
@@ -446,6 +475,8 @@ export default function App() {
setCurrentTier(s.qualityTier)
}
setSyncTheme(s.syncTheme ?? true)
+ setSyncEnabled(s.syncEnabled ?? false)
+ setSyncServerUrl(s.syncServerUrl ?? "")
setAutoGrantLocalMedia(s.autoGrantLocalMedia ?? true)
restoreLocalPinsRef.current = s.restoreLocalPins ?? true
uiStateLoadedRef.current = true
@@ -558,6 +589,7 @@ export default function App() {
// Persist the whole pins array (covers link/unlink and reorder). The renderer
// owns link state; main is the store. Keeps pinsRef in sync for callbacks.
const persistPins = useCallback((next: Pin[]) => {
+ pinEditAtRef.current = Date.now()
pinsRef.current = next
setPins(next)
window.cdp.reorderPins(next)
@@ -566,12 +598,14 @@ export default function App() {
// Un-pin keeps any live tab alive — removing the pin un-hides its target, so it
// returns to the Tabs list.
const unpinPin = useCallback(async (id: string) => {
+ pinEditAtRef.current = Date.now()
const updated = await window.cdp.removePin(id)
pinsRef.current = updated
setPins(updated)
}, [])
const handleEditPinSave = useCallback(async (id: string, title: string, pinUrl: string) => {
+ pinEditAtRef.current = Date.now()
const updated = await window.cdp.updatePin(id, { title, url: pinUrl })
pinsRef.current = updated
setPins(updated)
@@ -613,6 +647,7 @@ export default function App() {
// A pin whose target vanished (closed externally or via close) goes dormant.
const pruned = dropDeadLinks(pinsRef.current, ordered)
if (pruned !== pinsRef.current) {
+ pinEditAtRef.current = Date.now()
pinsRef.current = pruned
setPins(pruned)
window.cdp.reorderPins(pruned)
@@ -620,6 +655,25 @@ export default function App() {
return ordered
}, [])
+ // Realtime pin sync (t103): pins are server-authoritative (shared across web devices,
+ // and synced from Electron when enabled) but only load once at startup — a pin made on
+ // another device wouldn't appear until reload. Re-resolves each pin's link against the
+ // live tabs and applies only when the set actually changed, so an unchanged fetch never
+ // churns the list (a drag in progress is safe). The grace window (stamped by every local
+ // pin write, including the dead-link prune above) skips a poll for a few seconds after,
+ // so a stale server read can't revert an in-flight write.
+ const syncPins = useCallback(async () => {
+ if (Date.now() - pinEditAtRef.current < 5000) return
+ const loaded = await window.cdp.getPins().catch(() => null)
+ if (!loaded) return
+ const resolved = resolvePinsAgainstTabs(loaded, tabsRef.current)
+ const sig = (list: Pin[]) =>
+ list.map((p) => `${p.id}|${p.url}|${p.title}|${p.targetId ?? ""}`).join(";")
+ if (sig(resolved) === sig(pinsRef.current)) return
+ pinsRef.current = resolved
+ setPins(resolved)
+ }, [])
+
const switchTab = useCallback(
async (tabId: string) => {
setActiveKindCdp()
@@ -865,6 +919,23 @@ export default function App() {
window.cdp.setUiState({ syncTheme: enabled })
}, [])
+ // Cross-device sync (t103, Electron). Enabling adopts the server's pins (server-wins):
+ // main.js reads pins from the server while sync is on, so re-loading shows them,
+ // re-linking each to a live target by saved url.
+ const handleSyncEnabledChange = useCallback(async (enabled: boolean) => {
+ setSyncEnabled(enabled)
+ await window.cdp.setUiState({ syncEnabled: enabled })
+ const loaded = await window.cdp.getPins()
+ const resolved = resolvePinsAgainstTabs(loaded, tabsRef.current)
+ pinsRef.current = resolved
+ setPins(resolved)
+ }, [])
+
+ const handleSyncServerUrlChange = useCallback((url: string) => {
+ setSyncServerUrl(url)
+ window.cdp.setUiState({ syncServerUrl: url })
+ }, [])
+
const handleAutoGrantLocalMediaChange = useCallback((enabled: boolean) => {
setAutoGrantLocalMedia(enabled)
window.cdp.setUiState({ autoGrantLocalMedia: enabled })
@@ -1024,6 +1095,7 @@ export default function App() {
favicon: tab.faviconUrl,
targetId: tab.id,
}
+ pinEditAtRef.current = Date.now()
const updated = await window.cdp.addPin(newPin)
pinsRef.current = updated
setPins(updated)
@@ -1685,13 +1757,7 @@ export default function App() {
const init = async () => {
const [loadedPins, ordered] = await Promise.all([window.cdp.getPins(), refreshTabs()])
if (cancelled || !ordered) return
- const resolved = loadedPins.map((p) => {
- const targetId = resolvePinLink(p, ordered)
- if (targetId === p.targetId) return p
- if (targetId) return { ...p, targetId }
- const { targetId: _gone, ...rest } = p
- return rest
- })
+ const resolved = resolvePinsAgainstTabs(loadedPins, ordered)
pinsRef.current = resolved
setPins(resolved)
if (resolved.some((p, i) => p !== loadedPins[i])) window.cdp.reorderPins(resolved)
@@ -1699,12 +1765,17 @@ export default function App() {
if (first) switchTab(first.id)
}
init()
- const interval = setInterval(refreshTabs, 3000)
+ // Sequential, not two independent timers: refreshTabs' dead-link prune (and its
+ // grace-window stamp) always lands before syncPins reads the store back, so the two
+ // can't interleave and fight over the same pin list (t103).
+ const interval = setInterval(() => {
+ refreshTabs().then(syncPins)
+ }, 3000)
return () => {
cancelled = true
clearInterval(interval)
}
- }, [refreshTabs, switchTab])
+ }, [refreshTabs, switchTab, syncPins])
// Toolbar/URL bar reflect whichever surface is active.
const isLocal = activeKind === "local"
@@ -2073,6 +2144,8 @@ export default function App() {
onSettingsOpenChange={handleSettingsOpenChange}
onSettingsRequestOpenMouse={handleSettingsRequestOpenMouse}
onSwitchEffectChange={handleSwitchEffectChange}
+ onSyncEnabledChange={handleSyncEnabledChange}
+ onSyncServerUrlChange={handleSyncServerUrlChange}
onSyncThemeChange={handleSyncThemeChange}
onThemeChange={handleThemeChange}
onToggleMute={handleToggleMute}
@@ -2085,6 +2158,8 @@ export default function App() {
sidebarCollapsed={sidebarCollapsed}
status={status}
switchEffect={switchEffect}
+ syncEnabled={syncEnabled}
+ syncServerUrl={syncServerUrl}
syncTheme={syncTheme}
theme={theme}
url={effectiveUrl}
@@ -2144,7 +2219,9 @@ export default function App() {
onActivatePin={(kind, p) => (kind === "cdp" ? activatePin(p) : switchLocalTab(p.id))}
onOpenChange={setNewTabOpen}
onOpenUrl={(kind, u) => (kind === "cdp" ? newTab(u) : createLocalTab(u))}
+ onSwitchTab={(kind, id) => (kind === "cdp" ? switchTab(id) : switchLocalTab(id))}
open={newTabOpen}
+ openTabs={openTabsForSuggest}
/>
void
onActivatePin: (kind: NewTabKind, pin: Pin) => void
+ onSwitchTab: (kind: "cdp" | "local", id: string) => void
}
export function NewTabDialog({
@@ -46,20 +55,28 @@ export function NewTabDialog({
cdpPins,
localPins,
localEnabled,
+ openTabs,
onOpenUrl,
onActivatePin,
+ onSwitchTab,
}: NewTabDialogProps) {
const [kind, setKind] = useState(initialKind)
const [query, setQuery] = useState("")
const [selected, setSelected] = useState(0)
+ const [history, setHistory] = useState([])
const inputRef = useRef(null)
- // Seed mode + reset on each open. Web (no local) is always pinned to cdp.
+ // Seed mode + reset on each open. Web (no local) is always pinned to cdp. Load the
+ // browsing history once per open — it feeds the omnibox suggestions (t103).
useEffect(() => {
if (open) {
setKind(localEnabled ? initialKind : "cdp")
setQuery("")
setSelected(0)
+ window.cdp
+ .getHistory?.()
+ .then((h) => setHistory(h ?? []))
+ .catch(() => setHistory([]))
const t = setTimeout(() => inputRef.current?.focus(), 50)
return () => clearTimeout(t)
}
@@ -75,14 +92,28 @@ export function NewTabDialog({
return pins.filter((p) => p.title.toLowerCase().includes(q) || p.url.toLowerCase().includes(q))
}, [pins, trimmed])
- // Row model: an optional "open URL" row first (when typing), then matching pins.
- type Item = { kind: "url"; url: string } | { kind: "pin"; pin: Pin }
+ // History + open-tab suggestions for the typed query (t103). Empty query returns [].
+ const suggestions = useMemo(
+ () => suggest({ query: trimmed, history, openTabs, now: Date.now(), limit: 8 }),
+ [trimmed, history, openTabs],
+ )
+
+ // Row model. Empty query: pins only. Typing: the "open URL" row, then switch/history
+ // suggestions, then matching pins.
+ type Item =
+ | { kind: "url"; url: string }
+ | { kind: "pin"; pin: Pin }
+ | { kind: "switch"; tabKind: "cdp" | "local"; id: string; title: string; url: string }
+ | { kind: "history"; title: string; url: string }
const items = useMemo(() => {
const list: Item[] = []
- if (trimmed) list.push({ kind: "url", url: trimmed })
+ if (trimmed) {
+ list.push({ kind: "url", url: trimmed })
+ for (const s of suggestions) list.push(s)
+ }
for (const p of filteredPins) list.push({ kind: "pin", pin: p })
return list
- }, [trimmed, filteredPins])
+ }, [trimmed, suggestions, filteredPins])
// Keep the selection in range as items change.
useEffect(() => {
@@ -96,6 +127,8 @@ export function NewTabDialog({
return
}
if (item.kind === "url") onOpenUrl(kind, normalizeUrl(item.url))
+ else if (item.kind === "switch") onSwitchTab(item.tabKind, item.id)
+ else if (item.kind === "history") onOpenUrl(kind, item.url)
else onActivatePin(kind, item.pin)
onOpenChange(false)
}
@@ -173,12 +206,20 @@ export function NewTabDialog({
"flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-left transition-colors touch-target",
isSel ? "bg-foreground/[0.07]" : "hover:bg-foreground/[0.04]",
)}
- key={item.kind === "url" ? "__url" : item.pin.id}
+ key={
+ item.kind === "url"
+ ? "__url"
+ : item.kind === "pin"
+ ? `pin:${item.pin.id}`
+ : item.kind === "switch"
+ ? `switch:${item.tabKind}:${item.id}`
+ : `hist:${item.url}`
+ }
onClick={() => run(item)}
onMouseMove={() => setSelected(i)}
type="button"
>
- {item.kind === "url" ? (
+ {item.kind === "url" && (
<>
{item.url}
>
- ) : (
+ )}
+ {item.kind === "switch" && (
+ <>
+
+
+ {item.title || item.url}
+
+ Switch to tab · {stripScheme(item.url)}
+
+
+ >
+ )}
+ {item.kind === "history" && (
+ <>
+
+
+ {item.title || item.url}
+
+ {stripScheme(item.url)}
+
+
+ >
+ )}
+ {item.kind === "pin" && (
<>
@@ -256,3 +326,7 @@ function PinFavicon({ favicon }: { favicon?: string }) {
function normalizeUrl(input: string) {
return /^[a-z][a-z0-9+.-]*:\/\//i.test(input) ? input : `https://${input}`
}
+
+function stripScheme(url: string) {
+ return url.replace(/^[a-z]+:\/\//i, "")
+}
diff --git a/src/components/settings-dialog.tsx b/src/components/settings-dialog.tsx
index 8f4e723..2970cb4 100644
--- a/src/components/settings-dialog.tsx
+++ b/src/components/settings-dialog.tsx
@@ -119,6 +119,11 @@ interface SettingsDialogProps {
notifications: MuteEntry[]
syncTheme: boolean
onSyncThemeChange: (enabled: boolean) => void
+ /** Cross-device sync of pins + history (t103, Electron only — web is inherently server-backed). */
+ syncEnabled: boolean
+ onSyncEnabledChange: (enabled: boolean) => void
+ syncServerUrl: string
+ onSyncServerUrlChange: (url: string) => void
autoGrantLocalMedia: boolean
onAutoGrantLocalMediaChange: (enabled: boolean) => void
localExtensions: LocalExtensionInfo[]
@@ -214,6 +219,10 @@ export function SettingsDialog({
notifications,
syncTheme,
onSyncThemeChange,
+ syncEnabled,
+ onSyncEnabledChange,
+ syncServerUrl,
+ onSyncServerUrlChange,
autoGrantLocalMedia,
onAutoGrantLocalMediaChange,
localExtensions,
@@ -632,6 +641,44 @@ export function SettingsDialog({
+ {/* Cross-device sync (t103) — Electron only; the web build is inherently
+ server-backed, so its pins/history already sync with other web devices. */}
+ {!caps.web && (
+
+
+
+
+
+ Share pins and browsing history with your other devices via the web server
+ below.
+