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} />