diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index db7c2032cb..143c0c06a3 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -958,11 +958,16 @@ export interface ChannelsSpaceViewedProperties { // Subscription / billing events -export type UpgradePromptShownSurface = "usage_limit_modal" | "upgrade_dialog"; +export type UpgradePromptShownSurface = + | "usage_limit_modal" + | "upgrade_dialog" + | "titlebar_card"; export type UpgradePromptClickedSurface = | "usage_limit_modal" | "sidebar" + | "titlebar" + | "titlebar_card" | "plan_page_card" | "upgrade_dialog"; diff --git a/packages/shared/src/backoff.ts b/packages/shared/src/backoff.ts index 8552773ae2..0dfa8cb239 100644 --- a/packages/shared/src/backoff.ts +++ b/packages/shared/src/backoff.ts @@ -20,12 +20,31 @@ export function getBackoffDelay( } /** - * Sleep with exponential backoff delay + * Sleep with exponential backoff delay. + * + * Pass an AbortSignal to make the sleep cancelable: on abort the timer is + * cleared and the promise resolves immediately (it never rejects), so a + * retry loop can bail out on its own `signal.aborted` check. */ export function sleepWithBackoff( attempt: number, options: BackoffOptions, + signal?: AbortSignal, ): Promise { const delay = getBackoffDelay(attempt, options); - return new Promise((resolve) => setTimeout(resolve, delay)); + return new Promise((resolve) => { + if (signal?.aborted) { + resolve(); + return; + } + const onAbort = () => { + clearTimeout(timer); + resolve(); + }; + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, delay); + signal?.addEventListener("abort", onAbort, { once: true }); + }); } diff --git a/packages/ui/src/features/billing/SidebarUsageBar.tsx b/packages/ui/src/features/billing/SidebarUsageBar.tsx deleted file mode 100644 index 53ddad8bf3..0000000000 --- a/packages/ui/src/features/billing/SidebarUsageBar.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { Circle } from "@phosphor-icons/react"; -import { - formatResetTime, - isUsageExceeded, -} from "@posthog/core/billing/usageDisplay"; -import { BILLING_FLAG } from "@posthog/shared"; -import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; -import { track } from "../../shell/analytics"; -import { useFeatureFlag } from "../feature-flags/useFeatureFlag"; -import { openSettings } from "../settings/hooks/useOpenSettings"; -import { useFreeUsage } from "./useFreeUsage"; - -export function SidebarUsageBar() { - const billingEnabled = useFeatureFlag(BILLING_FLAG); - const { usage, isLoading } = useFreeUsage(billingEnabled); - - if (!billingEnabled) return null; - - const handleUpgrade = () => { - track(ANALYTICS_EVENTS.UPGRADE_PROMPT_CLICKED, { surface: "sidebar" }); - openSettings("plan-usage"); - }; - - if (!usage) { - if (!isLoading) return null; - return ( -
-
- Free plan -
-
-
- ); - } - - const exceeded = isUsageExceeded(usage); - const dominant = - usage.sustained.used_percent >= usage.burst.used_percent - ? usage.sustained - : usage.burst; - const usagePercent = Math.min(Math.round(dominant.used_percent), 100); - const resetLabel = formatResetTime(dominant.reset_at); - - return ( -
-
- - Free plan - - - {exceeded ? "Limit reached" : `${usagePercent}% used`} - - - -
-
-
-
-
- {resetLabel} -
-
- ); -} diff --git a/packages/ui/src/features/billing/UsageButton.tsx b/packages/ui/src/features/billing/UsageButton.tsx new file mode 100644 index 0000000000..95b42d3001 --- /dev/null +++ b/packages/ui/src/features/billing/UsageButton.tsx @@ -0,0 +1,153 @@ +import { Circle } from "@phosphor-icons/react"; +import { + formatResetTime, + isUsageExceeded, +} from "@posthog/core/billing/usageDisplay"; +import { + Button, + Popover, + PopoverContent, + PopoverTrigger, + Progress, +} from "@posthog/quill"; +import { BILLING_FLAG } from "@posthog/shared"; +import { + ANALYTICS_EVENTS, + type UpgradePromptClickedSurface, +} from "@posthog/shared/analytics-events"; +import { Link } from "@tanstack/react-router"; +import { useState } from "react"; +import { track } from "../../shell/analytics"; +import { useFeatureFlag } from "../feature-flags/useFeatureFlag"; +import { + openSettings, + prepareSettingsPage, +} from "../settings/hooks/useOpenSettings"; +import { useFreeUsage } from "./useFreeUsage"; + +// Title-bar usage entry point (replaces the old sidebar usage bar): a compact +// "Usage: N%" button whose hover card carries the full plan card — plan name, +// progress bar, reset time, and the Upgrade action. Built on quill's Popover +// with `openOnHover` on the trigger, so it behaves as a hover card (Base UI +// keeps it open while the pointer travels into the card to click Upgrade). +// The card body styles with quill tokens (foreground/muted-foreground/primary, +// quill Progress) — radix scale classes don't resolve inside the data-quill +// popover portal. +export function UsageButton() { + const billingEnabled = useFeatureFlag(BILLING_FLAG); + const { usage, isLoading } = useFreeUsage(billingEnabled); + // Controlled so the trigger click can close the card before navigating to + // settings — uncontrolled, the same click would also toggle the popover open + // over the settings view. Hover open/close still flows through onOpenChange. + const [open, setOpen] = useState(false); + + if (!billingEnabled) return null; + + // Same-size placeholder while usage loads, so the button doesn't pop in and + // shift the PostHog Web button after boot. + if (!usage) { + if (!isLoading) return null; + return ( + + ); + } + + const exceeded = isUsageExceeded(usage); + const dominant = + usage.sustained.used_percent >= usage.burst.used_percent + ? usage.sustained + : usage.burst; + const usagePercent = Math.min(Math.round(dominant.used_percent), 100); + const resetLabel = formatResetTime(dominant.reset_at); + + const handleOpenChange = (nextOpen: boolean) => { + // The hover card has real impressions now (the old sidebar bar was + // always-visible); count each open as a shown upgrade prompt. + if (nextOpen && !open) { + track(ANALYTICS_EVENTS.UPGRADE_PROMPT_SHOWN, { + surface: "titlebar_card", + }); + } + setOpen(nextOpen); + }; + + const handleOpenPlan = (surface: UpgradePromptClickedSurface) => { + track(ANALYTICS_EVENTS.UPGRADE_PROMPT_CLICKED, { surface }); + setOpen(false); + openSettings("plan-usage"); + }; + + // The trigger is a real (render={} per convention), so the + // router owns the navigation; this click handler carries the side effects + // openSettings would have done — tracking, closing the card, and resetting + // the settings-page store so no stale context/one-shot action leaks in. + const handleTriggerClick = () => { + track(ANALYTICS_EVENTS.UPGRADE_PROMPT_CLICKED, { surface: "titlebar" }); + setOpen(false); + prepareSettingsPage(); + }; + + return ( + + + } + > + {exceeded ? "Usage: limit reached" : `Usage: ${usagePercent}%`} + + } + /> + {/* no-drag: the popover opens under the title bar's drag region; without + the opt-out a click near its top edge is swallowed as a window drag. */} + +
+ + Free plan + + + {exceeded ? "Limit reached" : `${usagePercent}% used`} + + + +
+ +
+ {resetLabel} +
+
+
+ ); +} diff --git a/packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx b/packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx index 072256de4a..8b42382a4c 100644 --- a/packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx +++ b/packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx @@ -7,6 +7,8 @@ import { SquaresFourIcon, TrayIcon, } from "@phosphor-icons/react"; +import { ROOT_LOGGER, type RootLogger } from "@posthog/di/logger"; +import { useService } from "@posthog/di/react"; import { useHostTRPC } from "@posthog/host-router/react"; import { closeTab as closeTabLocal, @@ -59,7 +61,12 @@ import { usePinnedTabsStore } from "./pinnedTabsStore"; import { TabStrip, type TabView } from "./TabStrip"; import { TaskTabIcon } from "./TaskTabIcon"; import { useTabReorderStore } from "./tabReorderStore"; -import { applyLocalTransform, persistWrite, readMirror } from "./tabsSync"; +import { + applyLocalTransform, + persistWrite, + readMirror, + reseedMirror, +} from "./tabsSync"; import { useTabsSnapshot } from "./useBrowserTabs"; /** The active tab id is carried in router history state so back/forward replay @@ -153,6 +160,7 @@ function isAppView(value: string): value is AppView { } export function BrowserTabStrip() { + const logger = useService(ROOT_LOGGER); const snapshot = useTabsSnapshot(); const navigate = useNavigate(); const router = useRouter(); @@ -750,16 +758,50 @@ export function BrowserTabStrip() { // mirror and navigate in the same tick (no IPC wait), then persist with the // same id so the durable state matches. The service is idempotent on the // minted id, so a replay can't append a duplicate. - const handleNewTab = () => { - if (!windowId) return; + const createBlankTab = (targetWindowId: string) => { const tabId = crypto.randomUUID(); applyLocalTransform( (s) => - newBlankTabLocal(s, { windowId, makeId: () => tabId, now: Date.now }) - .snapshot, + newBlankTabLocal(s, { + windowId: targetWindowId, + makeId: () => tabId, + now: Date.now, + }).snapshot, ); landOnDefault(tabId); - void persistWrite(() => newBlankTab.mutateAsync({ windowId, tabId })); + void persistWrite(() => + newBlankTab.mutateAsync({ windowId: targetWindowId, tabId }), + ); + }; + + const handleNewTab = () => { + if (windowId) { + createBlankTab(windowId); + return; + } + // No window means the mirror never seeded (the boot fetch raced or + // failed) — the click must not die. Re-pull the authoritative snapshot + // (the server always has a primary window) and append into it. Resolve + // the window from the FETCHED snapshot, not the mirror: reseedMirror + // skips the store apply when a local write or newer remote push raced + // the fetch, and the mirror could still be windowless then. + void reseedMirror() + .then((server) => { + const win = server + ? primaryWindow(server) + : primaryWindow(readMirror()); + if (win) { + createBlankTab(win.id); + return; + } + // Should be unreachable (the server always mints a primary window), + // but a silent skip here reproduces the dead-"+" this path exists to + // fix — make it loud instead. + logger.error("browser-tabs: new-tab found no window after reseed"); + }) + .catch((error) => { + logger.error("browser-tabs: new-tab reseed failed", { error }); + }); }; // Cmd/Ctrl+T opens a new browser tab. Bound here (not globally) so it only diff --git a/packages/ui/src/features/browser-tabs/TabStrip.tsx b/packages/ui/src/features/browser-tabs/TabStrip.tsx index e21b1315c0..0d389d8d6b 100644 --- a/packages/ui/src/features/browser-tabs/TabStrip.tsx +++ b/packages/ui/src/features/browser-tabs/TabStrip.tsx @@ -85,11 +85,14 @@ export function TabStrip({ return ( {/* overflow-hidden: incompressible pinned pills must clip within the - strip rather than overlap the title bar's right-side controls. */} + strip rather than overlap the title bar's right-side controls. + The container inherits the title bar's `drag` region so the empty + space right of the pills moves the window; each interactive child + opts out with `no-drag` individually. */} {tabs.map((tab, index) => ( @@ -113,7 +116,7 @@ export function TabStrip({ + {/* Gated so an empty right-side group can't claim a no-drag rect + in the title bar for nothing — every pixel without controls + should drag the window. */} + {(billingEnabled || channelsEnabled) && ( + + + {channelsEnabled && ( + + )} )} diff --git a/packages/workspace-server/src/db/migrate.test.ts b/packages/workspace-server/src/db/migrate.test.ts index bb975fa840..279aa23140 100644 --- a/packages/workspace-server/src/db/migrate.test.ts +++ b/packages/workspace-server/src/db/migrate.test.ts @@ -1,4 +1,10 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import Database from "better-sqlite3"; @@ -113,6 +119,83 @@ describe("runMigrations", () => { rmSync(dir, { recursive: true, force: true }); } }); + + // 0013 was amended in place on the bluebird branch, so dogfood DBs have it + // recorded as applied while carrying an earlier variant of the browser-tabs + // schema. 0020 must heal every observed variant without ever failing boot. + describe("0020 browser-tabs repair", () => { + const REPAIR_TIMESTAMP = 1783685997328; + + function markHistoryApplied(db: InstanceType) { + // Record 0000..0019 as applied without running them, mimicking a DB + // whose ledger is ahead of its real schema. + db.exec( + "CREATE TABLE IF NOT EXISTS __drizzle_migrations (id SERIAL PRIMARY KEY, hash text NOT NULL, created_at numeric)", + ); + const journal = JSON.parse( + readFileSync( + path.join(MIGRATIONS_FOLDER, "meta", "_journal.json"), + "utf8", + ), + ) as { entries: { idx: number; when: number }[] }; + const insert = db.prepare( + "INSERT INTO __drizzle_migrations (hash, created_at) VALUES ('x', ?)", + ); + for (const entry of journal.entries) { + if (entry.when !== REPAIR_TIMESTAMP) insert.run(entry.when); + } + } + + it("heals the panes-era variant (tables exist, active_tab_id missing)", () => { + markHistoryApplied(sqlite); + sqlite.exec(` + CREATE TABLE browser_windows (id text PRIMARY KEY NOT NULL, + is_primary integer DEFAULT false NOT NULL, bounds text, + position integer DEFAULT 0 NOT NULL, created_at integer NOT NULL, + updated_at integer NOT NULL, layout text, focused_pane_id text); + CREATE TABLE browser_tabs (id text PRIMARY KEY NOT NULL, + window_id text NOT NULL, dashboard_id text, channel_id text, + position integer NOT NULL, scroll_state text, + created_at integer NOT NULL, last_active_at integer NOT NULL, + pane_id text); + `); + + expect(() => runMigrations(sqlite, MIGRATIONS_FOLDER)).not.toThrow(); + expect(hasColumn(sqlite, "browser_windows", "active_tab_id")).toBe(true); + expect(hasColumn(sqlite, "browser_tabs", "task_id")).toBe(true); + expect(hasColumn(sqlite, "browser_tabs", "channel_section")).toBe(true); + expect(hasColumn(sqlite, "browser_tabs", "app_view")).toBe(true); + }); + + it("heals a variant missing the browser tables entirely", () => { + markHistoryApplied(sqlite); + + expect(() => runMigrations(sqlite, MIGRATIONS_FOLDER)).not.toThrow(); + expect(hasColumn(sqlite, "browser_windows", "active_tab_id")).toBe(true); + expect(hasColumn(sqlite, "browser_tabs", "app_view")).toBe(true); + }); + + it("tolerates arbitrary statement failures in a best-effort migration", () => { + // An unforeseen divergence must degrade to "still broken", never a + // failed migration batch that kills boot. + markHistoryApplied(sqlite); + // A browser_windows table that can't accept the ALTERs cleanly: the + // column exists with a different type — ALTER throws duplicate column + // (tolerated), and the CREATEs no-op. Simulate a nastier case by making + // browser_tabs a VIEW, which CREATE TABLE IF NOT EXISTS *and* ALTER + // both reject with non-duplicate-column errors. + sqlite.exec(` + CREATE TABLE browser_windows (id text PRIMARY KEY NOT NULL, + is_primary integer DEFAULT false NOT NULL, bounds text, + active_tab_id text, position integer DEFAULT 0 NOT NULL, + created_at integer NOT NULL, updated_at integer NOT NULL); + CREATE VIEW browser_tabs AS SELECT 1 AS id; + `); + + expect(() => runMigrations(sqlite, MIGRATIONS_FOLDER)).not.toThrow(); + expect(ledgerHas(sqlite, REPAIR_TIMESTAMP)).toBe(true); + }); + }); }); function writeTempMigration(sql: string): string { diff --git a/packages/workspace-server/src/db/migrate.ts b/packages/workspace-server/src/db/migrate.ts index 98075806b2..ba6acc1b9d 100644 --- a/packages/workspace-server/src/db/migrate.ts +++ b/packages/workspace-server/src/db/migrate.ts @@ -7,6 +7,17 @@ function isDuplicateColumnError(error: unknown): boolean { return error instanceof Error && /duplicate column name/i.test(error.message); } +// Repair migrations heal DBs whose recorded history diverged from their real +// schema (a migration amended in place on a branch). Their statements are +// opportunistic: one that cannot apply must not roll back the batch and kill +// boot — for these migrations the worst acceptable outcome is the pre-repair +// status quo, never a database that fails to open. Statements in the listed +// migrations tolerate ANY SQLite error; everything else keeps the strict +// duplicate-column-only tolerance. +const BEST_EFFORT_MIGRATIONS = new Set([ + 1783685997328, // 0020_repair_browser_tabs_schema +]); + export function runMigrations( sqlite: SqliteDatabase, migrationsFolder: string, @@ -33,11 +44,12 @@ export function runMigrations( if (appliedTimestamps.has(migration.folderMillis)) { continue; } + const bestEffort = BEST_EFFORT_MIGRATIONS.has(migration.folderMillis); for (const statement of migration.sql) { try { sqlite.exec(statement); } catch (error) { - if (isDuplicateColumnError(error)) { + if (bestEffort || isDuplicateColumnError(error)) { continue; } throw error; diff --git a/packages/workspace-server/src/db/migrations/0020_repair_browser_tabs_schema.sql b/packages/workspace-server/src/db/migrations/0020_repair_browser_tabs_schema.sql new file mode 100644 index 0000000000..9dfb2a0659 --- /dev/null +++ b/packages/workspace-server/src/db/migrations/0020_repair_browser_tabs_schema.sql @@ -0,0 +1,46 @@ +-- Repair migration for profiles that dogfooded a pre-merge bluebird build. +-- Migration 0013 was amended in place on the branch (same folderMillis), so a +-- DB that ran an early version has it recorded as applied without the final +-- tab-strip schema: `browser_windows` lacks `active_tab_id` (and other +-- variants may lack columns or whole tables), which makes every browser-tabs +-- query throw and leaves the tab strip dead. +-- +-- Two-step repair, both idempotent on healthy DBs: +-- 1. CREATE TABLE IF NOT EXISTS with the FULL current schema — heals variants +-- where an amendment never created the table at all (a plain ALTER would +-- throw "no such table" and fail the whole migration transaction). +-- 2. ALTER TABLE ADD COLUMN for each column a stale variant may be missing — +-- the runner tolerates "duplicate column name" (see migrate.ts), so these +-- no-op wherever the column already exists. +-- +-- Accepted limitation: ALTER can't heal type or NOT NULL divergence on +-- existing columns. Every observed 0013 variant agrees on the shared column +-- shapes (all nullable text), so only missing columns/tables need repair. +CREATE TABLE IF NOT EXISTS `browser_windows` ( + `id` text PRIMARY KEY NOT NULL, + `is_primary` integer DEFAULT false NOT NULL, + `bounds` text, + `active_tab_id` text, + `position` integer DEFAULT 0 NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `browser_tabs` ( + `id` text PRIMARY KEY NOT NULL, + `window_id` text NOT NULL, + `dashboard_id` text, + `channel_id` text, + `position` integer NOT NULL, + `scroll_state` text, + `created_at` integer NOT NULL, + `last_active_at` integer NOT NULL, + `task_id` text, + `channel_section` text, + `app_view` text, + FOREIGN KEY (`window_id`) REFERENCES `browser_windows`(`id`) ON UPDATE no action ON DELETE cascade +);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `browser_tabs_window_idx` ON `browser_tabs` (`window_id`);--> statement-breakpoint +ALTER TABLE `browser_windows` ADD COLUMN `active_tab_id` text;--> statement-breakpoint +ALTER TABLE `browser_tabs` ADD COLUMN `task_id` text;--> statement-breakpoint +ALTER TABLE `browser_tabs` ADD COLUMN `channel_section` text;--> statement-breakpoint +ALTER TABLE `browser_tabs` ADD COLUMN `app_view` text; diff --git a/packages/workspace-server/src/db/migrations/meta/0020_snapshot.json b/packages/workspace-server/src/db/migrations/meta/0020_snapshot.json new file mode 100644 index 0000000000..3e2053d085 --- /dev/null +++ b/packages/workspace-server/src/db/migrations/meta/0020_snapshot.json @@ -0,0 +1,1023 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "e99d8d2c-d74b-4dea-9261-b5923c869d7a", + "prevId": "47b5564d-959b-4528-97fc-849a579d05ae", + "tables": { + "archives": { + "name": "archives", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "archives_workspaceId_unique": { + "name": "archives_workspaceId_unique", + "columns": ["workspace_id"], + "isUnique": true + } + }, + "foreignKeys": { + "archives_workspace_id_workspaces_id_fk": { + "name": "archives_workspace_id_workspaces_id_fk", + "tableFrom": "archives", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_org_project_preferences": { + "name": "auth_org_project_preferences", + "columns": { + "account_key": { + "name": "account_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cloud_region": { + "name": "cloud_region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_selected_project_id": { + "name": "last_selected_project_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "auth_org_project_account_region_org_idx": { + "name": "auth_org_project_account_region_org_idx", + "columns": ["account_key", "cloud_region", "org_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_preferences": { + "name": "auth_preferences", + "columns": { + "account_key": { + "name": "account_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cloud_region": { + "name": "cloud_region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_selected_project_id": { + "name": "last_selected_project_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_selected_org_id": { + "name": "last_selected_org_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "auth_preferences_account_region_idx": { + "name": "auth_preferences_account_region_idx", + "columns": ["account_key", "cloud_region"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_sessions": { + "name": "auth_sessions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "refresh_token_encrypted": { + "name": "refresh_token_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cloud_region": { + "name": "cloud_region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "selected_project_id": { + "name": "selected_project_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope_version": { + "name": "scope_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "autoresearch_runs": { + "name": "autoresearch_runs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "autoresearch_runs_task_id_idx": { + "name": "autoresearch_runs_task_id_idx", + "columns": ["task_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "browser_tabs": { + "name": "browser_tabs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "window_id": { + "name": "window_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dashboard_id": { + "name": "dashboard_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel_section": { + "name": "channel_section", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scroll_state": { + "name": "scroll_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "app_view": { + "name": "app_view", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "browser_tabs_window_idx": { + "name": "browser_tabs_window_idx", + "columns": ["window_id"], + "isUnique": false + } + }, + "foreignKeys": { + "browser_tabs_window_id_browser_windows_id_fk": { + "name": "browser_tabs_window_id_browser_windows_id_fk", + "tableFrom": "browser_tabs", + "tableTo": "browser_windows", + "columnsFrom": ["window_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "browser_windows": { + "name": "browser_windows", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "is_primary": { + "name": "is_primary", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "bounds": { + "name": "bounds", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_tab_id": { + "name": "active_tab_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "claude_session_imports": { + "name": "claude_session_imports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "source_session_id": { + "name": "source_session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "imported_session_id": { + "name": "imported_session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_path": { + "name": "repo_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_mtime_ms": { + "name": "source_mtime_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_size_bytes": { + "name": "source_size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_last_entry_uuid": { + "name": "source_last_entry_uuid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "claude_session_imports_importedSessionId_unique": { + "name": "claude_session_imports_importedSessionId_unique", + "columns": ["imported_session_id"], + "isUnique": true + }, + "claude_session_imports_source_idx": { + "name": "claude_session_imports_source_idx", + "columns": ["source_session_id"], + "isUnique": false + }, + "claude_session_imports_task_idx": { + "name": "claude_session_imports_task_idx", + "columns": ["task_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "default_additional_directories": { + "name": "default_additional_directories", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "repositories": { + "name": "repositories", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remote_url": { + "name": "remote_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "repositories_path_unique": { + "name": "repositories_path_unique", + "columns": ["path"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "suspensions": { + "name": "suspensions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "suspensions_workspaceId_unique": { + "name": "suspensions_workspaceId_unique", + "columns": ["workspace_id"], + "isUnique": true + } + }, + "foreignKeys": { + "suspensions_workspace_id_workspaces_id_fk": { + "name": "suspensions_workspace_id_workspaces_id_fk", + "tableFrom": "suspensions", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "task_metadata": { + "name": "task_metadata", + "columns": { + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_viewed_at": { + "name": "last_viewed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "linked_branch": { + "name": "linked_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_viewed_at": { + "name": "last_viewed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "additional_directories": { + "name": "additional_directories", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pr_state": { + "name": "pr_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pr_urls": { + "name": "pr_urls", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "workspaces_taskId_unique": { + "name": "workspaces_taskId_unique", + "columns": ["task_id"], + "isUnique": true + }, + "workspaces_repository_id_idx": { + "name": "workspaces_repository_id_idx", + "columns": ["repository_id"], + "isUnique": false + } + }, + "foreignKeys": { + "workspaces_repository_id_repositories_id_fk": { + "name": "workspaces_repository_id_repositories_id_fk", + "tableFrom": "workspaces", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "worktrees": { + "name": "worktrees", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "worktrees_workspaceId_unique": { + "name": "worktrees_workspaceId_unique", + "columns": ["workspace_id"], + "isUnique": true + } + }, + "foreignKeys": { + "worktrees_workspace_id_workspaces_id_fk": { + "name": "worktrees_workspace_id_workspaces_id_fk", + "tableFrom": "worktrees", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/packages/workspace-server/src/db/migrations/meta/_journal.json b/packages/workspace-server/src/db/migrations/meta/_journal.json index 695642eb69..25b5e27f0f 100644 --- a/packages/workspace-server/src/db/migrations/meta/_journal.json +++ b/packages/workspace-server/src/db/migrations/meta/_journal.json @@ -141,6 +141,13 @@ "when": 1783430845938, "tag": "0019_add_app_view", "breakpoints": true + }, + { + "idx": 20, + "version": "6", + "when": 1783685997328, + "tag": "0020_repair_browser_tabs_schema", + "breakpoints": true } ] } diff --git a/packages/workspace-server/src/services/browser-tabs/service.test.ts b/packages/workspace-server/src/services/browser-tabs/service.test.ts new file mode 100644 index 0000000000..b050510152 --- /dev/null +++ b/packages/workspace-server/src/services/browser-tabs/service.test.ts @@ -0,0 +1,108 @@ +import type { BrowserTab, TabsSnapshot } from "@posthog/shared"; +import { describe, expect, it } from "vitest"; +import type { IBrowserTabsRepository } from "../../db/repositories/browser-tabs-repository"; +import { BrowserTabsService } from "./service"; + +const blankTab = (overrides: Partial = {}): BrowserTab => ({ + id: "tab-1", + windowId: "win-1", + dashboardId: null, + taskId: null, + channelId: null, + channelSection: null, + appView: null, + position: 100, + scrollState: null, + createdAt: 1, + lastActiveAt: 1, + ...overrides, +}); + +class FakeRepository implements IBrowserTabsRepository { + saved: TabsSnapshot | null = null; + constructor(private readonly initial: TabsSnapshot) {} + load(): TabsSnapshot { + return this.initial; + } + save(snapshot: TabsSnapshot): void { + this.saved = snapshot; + } +} + +describe("BrowserTabsService boot invariants", () => { + const bootCases: [string, TabsSnapshot][] = [ + ["empty store", { windows: [], tabs: [] }], + [ + "window persisted with zero tabs", + { + windows: [ + { id: "win-1", isPrimary: true, bounds: null, activeTabId: null }, + ], + tabs: [], + }, + ], + ]; + + it.each(bootCases)( + "seeds a primary window with at least one tab (%s)", + (_name, initial) => { + const repo = new FakeRepository(initial); + const service = new BrowserTabsService(repo); + + const snapshot = service.getSnapshot(); + const primary = snapshot.windows.find((w) => w.isPrimary); + expect(primary).toBeDefined(); + expect(snapshot.tabs.length).toBeGreaterThanOrEqual(1); + expect(snapshot.tabs[0]?.windowId).toBe(primary?.id); + // The healed snapshot is persisted so the invariant survives a restart. + expect(repo.saved).toEqual(snapshot); + }, + ); + + it("leaves an already-populated snapshot untouched", () => { + const initial: TabsSnapshot = { + windows: [ + { id: "win-1", isPrimary: true, bounds: null, activeTabId: "tab-1" }, + ], + tabs: [blankTab()], + }; + const repo = new FakeRepository(initial); + const service = new BrowserTabsService(repo); + + expect(service.getSnapshot()).toBe(initial); + expect(repo.saved).toBeNull(); + }); +}); + +describe("BrowserTabsService window-id healing", () => { + it("newBlankTab lands in the primary window when the given id is unknown", () => { + const repo = new FakeRepository({ windows: [], tabs: [] }); + const service = new BrowserTabsService(repo); + const primaryId = service.getPrimaryWindowId(); + + const snapshot = service.newBlankTab({ + windowId: "stale-window-id", + tabId: "tab-new", + }); + + const created = snapshot.tabs.find((t) => t.id === "tab-new"); + expect(created?.windowId).toBe(primaryId); + }); + + it("openOrFocus lands in the primary window when the given id is unknown", () => { + const repo = new FakeRepository({ windows: [], tabs: [] }); + const service = new BrowserTabsService(repo); + const primaryId = service.getPrimaryWindowId(); + + const snapshot = service.openOrFocus({ + windowId: "stale-window-id", + dashboardId: "dash-1", + taskId: null, + channelId: null, + tabId: "tab-open", + }); + + const created = snapshot.tabs.find((t) => t.id === "tab-open"); + expect(created?.windowId).toBe(primaryId); + }); +}); diff --git a/packages/workspace-server/src/services/browser-tabs/service.ts b/packages/workspace-server/src/services/browser-tabs/service.ts index e658412b7b..3b3e33e813 100644 --- a/packages/workspace-server/src/services/browser-tabs/service.ts +++ b/packages/workspace-server/src/services/browser-tabs/service.ts @@ -70,7 +70,7 @@ export class BrowserTabsService super(); this.setMaxListeners(0); const loaded = this.repo.load(); - const seeded = this.ensurePrimaryWindow(loaded); + const seeded = this.ensureAtLeastOneTab(this.ensurePrimaryWindow(loaded)); if (seeded !== loaded) this.repo.save(seeded); this.snapshot = seeded; } @@ -87,6 +87,28 @@ export class BrowserTabsService return { ...snapshot, windows: [primary, ...snapshot.windows] }; } + /** The strip must never boot empty: seed a blank tab when none survived. */ + private ensureAtLeastOneTab(snapshot: TabsSnapshot): TabsSnapshot { + if (snapshot.tabs.length > 0) return snapshot; + const primary = snapshot.windows.find((w) => w.isPrimary); + if (!primary) return snapshot; + return newBlankTab(snapshot, { windowId: primary.id, makeId, now }) + .snapshot; + } + + /** Creation targets heal a stale window id (a mirror seeded before a schema + * repair, or another window's since-closed id) to the primary window rather + * than appending into a window that doesn't exist. Deliberately creation-only: + * a desynced mirror's reorder (`setOrder`) or focus (`setActiveTab`) carries + * stale TAB ids too, so retargeting those at the primary window would apply + * wrong state — the shared transforms no-op safely instead, and the snapshot + * reconcile heals the mirror. Creating a tab is window-independent intent. */ + private resolveWindowId(windowId: string): string { + return this.snapshot.windows.some((w) => w.id === windowId) + ? windowId + : this.getPrimaryWindowId(); + } + getSnapshot(): TabsSnapshot { return this.snapshot; } @@ -113,6 +135,7 @@ export class BrowserTabsService const providedId = input.tabId; const { snapshot } = openOrFocusTab(this.snapshot, { ...input, + windowId: this.resolveWindowId(input.windowId), makeId: providedId ? () => providedId : makeId, now, }); @@ -127,7 +150,7 @@ export class BrowserTabsService return this.snapshot; } const { snapshot } = newBlankTab(this.snapshot, { - windowId: input.windowId, + windowId: this.resolveWindowId(input.windowId), makeId: providedId ? () => providedId : makeId, now, });