From b775809f4438cdc0a895947c9195edd9f85ba0db Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Fri, 10 Jul 2026 13:32:30 +0100 Subject: [PATCH 1/7] fix(tabs): repair stale browser-tabs schema, never-empty strip; move usage to title bar Browser tabs (bluebird follow-ups): - Add repair migration 0020: profiles that dogfooded a pre-merge bluebird build have migration 0013 recorded as applied but the old panes-era browser_windows schema (no active_tab_id), which killed the whole browser-tabs service at boot and left a dead "+" strip. Tolerated ALTERs heal stale DBs and no-op on healthy ones. - BrowserTabsService now seeds a blank tab at boot when none survived (the strip should never be empty) and heals an unknown windowId on newBlankTab/openOrFocus to the primary window. - The renderer snapshot seed retries with backoff and logs terminal failure instead of swallowing it; the "+" handler re-pulls the authoritative snapshot instead of silently no-oping when the mirror has no window. - Make the empty title-bar space around the tab strip a native window drag region (container inherits drag; pills and "+" opt out). Usage CTA: - Move the sidebar usage bar into the title bar as a "Usage: N%" button (left of PostHog Web); the full plan card (bar, reset time, Upgrade) shows in a quill Popover on hover via openOnHover. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/shared/src/analytics-events.ts | 2 + .../src/features/billing/SidebarUsageBar.tsx | 77 -- .../ui/src/features/billing/UsageButton.tsx | 95 ++ .../features/browser-tabs/BrowserTabStrip.tsx | 37 +- .../ui/src/features/browser-tabs/TabStrip.tsx | 13 +- .../browser-tabs-events.contribution.ts | 36 +- .../ui/src/features/browser-tabs/tabsSync.ts | 23 +- .../canvas/components/ChannelsSidebar.tsx | 2 - packages/ui/src/router/routes/__root.tsx | 10 +- .../0020_repair_browser_tabs_schema.sql | 11 + .../src/db/migrations/meta/0020_snapshot.json | 1023 +++++++++++++++++ .../src/db/migrations/meta/_journal.json | 7 + .../src/services/browser-tabs/service.test.ts | 108 ++ .../src/services/browser-tabs/service.ts | 23 +- 14 files changed, 1359 insertions(+), 108 deletions(-) delete mode 100644 packages/ui/src/features/billing/SidebarUsageBar.tsx create mode 100644 packages/ui/src/features/billing/UsageButton.tsx create mode 100644 packages/workspace-server/src/db/migrations/0020_repair_browser_tabs_schema.sql create mode 100644 packages/workspace-server/src/db/migrations/meta/0020_snapshot.json create mode 100644 packages/workspace-server/src/services/browser-tabs/service.test.ts diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index db7c2032cb..976b2d9e7a 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -963,6 +963,8 @@ export type UpgradePromptShownSurface = "usage_limit_modal" | "upgrade_dialog"; export type UpgradePromptClickedSurface = | "usage_limit_modal" | "sidebar" + | "titlebar" + | "titlebar_card" | "plan_page_card" | "upgrade_dialog"; 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..fbb5442b07 --- /dev/null +++ b/packages/ui/src/features/billing/UsageButton.tsx @@ -0,0 +1,95 @@ +import { Circle } from "@phosphor-icons/react"; +import { + formatResetTime, + isUsageExceeded, +} from "@posthog/core/billing/usageDisplay"; +import { + Button, + Popover, + PopoverContent, + PopoverTrigger, +} from "@posthog/quill"; +import { BILLING_FLAG } from "@posthog/shared"; +import { + ANALYTICS_EVENTS, + type UpgradePromptClickedSurface, +} 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"; + +// 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). +export function UsageButton() { + const billingEnabled = useFeatureFlag(BILLING_FLAG); + const { usage } = useFreeUsage(billingEnabled); + + if (!billingEnabled || !usage) return null; + + 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 handleOpenPlan = (surface: UpgradePromptClickedSurface) => { + track(ANALYTICS_EVENTS.UPGRADE_PROMPT_CLICKED, { surface }); + openSettings("plan-usage"); + }; + + return ( + + handleOpenPlan("titlebar")} + > + {exceeded ? "Usage: limit reached" : `Usage: ${usagePercent}%`} + + } + /> + +
+ + 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..1ec4e1b4ed 100644 --- a/packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx +++ b/packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx @@ -59,7 +59,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 @@ -750,16 +755,36 @@ 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. + void reseedMirror() + .then(() => { + const win = primaryWindow(readMirror()); + if (win) createBlankTab(win.id); + }) + .catch(() => undefined); }; // 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({ - - )} + )} + 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..735ec42dfd --- /dev/null +++ b/packages/workspace-server/src/db/migrations/0020_repair_browser_tabs_schema.sql @@ -0,0 +1,11 @@ +-- 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 the early panes-era version has it recorded as applied and never +-- got the rewritten tab-strip schema: `browser_windows` lacks `active_tab_id`, +-- which makes every browser-tabs query throw and leaves the tab strip dead. +-- Each ALTER below is a no-op on healthy DBs: the runner tolerates +-- "duplicate column name" (see migrate.ts) and only heals divergent tables. +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..d2356e33df 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,24 @@ 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. */ + private resolveWindowId(windowId: string): string { + return this.snapshot.windows.some((w) => w.id === windowId) + ? windowId + : this.getPrimaryWindowId(); + } + getSnapshot(): TabsSnapshot { return this.snapshot; } @@ -113,6 +131,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 +146,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, }); From 2bc2b5ae5e0cdbf8bb83449be1b8829790b8ed36 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Fri, 10 Jul 2026 13:45:21 +0100 Subject: [PATCH 2/7] fix(billing): style usage hover card with quill tokens Radix scale classes (text-gray-11, bg-accent-9) don't resolve inside the data-quill popover portal, so the card rendered colorless. Use quill Progress and foreground/muted-foreground/primary tokens instead. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ui/src/features/billing/UsageButton.tsx | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/packages/ui/src/features/billing/UsageButton.tsx b/packages/ui/src/features/billing/UsageButton.tsx index fbb5442b07..81fe4f9f5d 100644 --- a/packages/ui/src/features/billing/UsageButton.tsx +++ b/packages/ui/src/features/billing/UsageButton.tsx @@ -8,6 +8,7 @@ import { Popover, PopoverContent, PopoverTrigger, + Progress, } from "@posthog/quill"; import { BILLING_FLAG } from "@posthog/shared"; import { @@ -24,6 +25,9 @@ import { useFreeUsage } from "./useFreeUsage"; // 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 } = useFreeUsage(billingEnabled); @@ -61,32 +65,31 @@ export function UsageButton() { />
- + Free plan - + {exceeded ? "Limit reached" : `${usagePercent}% used`}
-
-
-
-
+ +
{resetLabel}
From 1bf506da6cd0ac31414fe3639af0e9d27279d88a Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Fri, 10 Jul 2026 13:50:32 +0100 Subject: [PATCH 3/7] polish(billing): usage card spacing + upgrade underline Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/ui/src/features/billing/UsageButton.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/features/billing/UsageButton.tsx b/packages/ui/src/features/billing/UsageButton.tsx index 81fe4f9f5d..f7e27e3c38 100644 --- a/packages/ui/src/features/billing/UsageButton.tsx +++ b/packages/ui/src/features/billing/UsageButton.tsx @@ -63,7 +63,12 @@ export function UsageButton() { } /> - +
Free plan @@ -78,18 +83,17 @@ export function UsageButton() {
-
+
{resetLabel}
From 692f31f4cf517ee81fbbc70b335c9ba11ae4f770 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Fri, 10 Jul 2026 13:57:47 +0100 Subject: [PATCH 4/7] fix: address review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sync-quill.sh: portable hashing (shasum) and sed rewrite (temp file + mv) so the script works on Linux, not just BSD/macOS - UsageButton: mark the portalled PopoverContent no-drag — it opens under the title bar's drag region and clicks near its top edge were draggable - new-tab recovery: resolve the primary window from the snapshot returned by reseedMirror, not the mirror — the store apply is skipped when a local write or newer remote push races the fetch Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/ui/src/features/billing/UsageButton.tsx | 4 +++- .../ui/src/features/browser-tabs/BrowserTabStrip.tsx | 11 ++++++++--- packages/ui/src/features/browser-tabs/tabsSync.ts | 11 +++++++---- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/features/billing/UsageButton.tsx b/packages/ui/src/features/billing/UsageButton.tsx index f7e27e3c38..84e2db2062 100644 --- a/packages/ui/src/features/billing/UsageButton.tsx +++ b/packages/ui/src/features/billing/UsageButton.tsx @@ -63,11 +63,13 @@ export function UsageButton() { } /> + {/* 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. */}
diff --git a/packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx b/packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx index 1ec4e1b4ed..03134e5a06 100644 --- a/packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx +++ b/packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx @@ -778,10 +778,15 @@ export function BrowserTabStrip() { } // 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. + // (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(() => { - const win = primaryWindow(readMirror()); + .then((server) => { + const win = server + ? primaryWindow(server) + : primaryWindow(readMirror()); if (win) createBlankTab(win.id); }) .catch(() => undefined); diff --git a/packages/ui/src/features/browser-tabs/tabsSync.ts b/packages/ui/src/features/browser-tabs/tabsSync.ts index 508775842b..fd2d65cbbf 100644 --- a/packages/ui/src/features/browser-tabs/tabsSync.ts +++ b/packages/ui/src/features/browser-tabs/tabsSync.ts @@ -44,16 +44,19 @@ function reconcileAuthoritative(): void { * Pull the authoritative snapshot and apply it to the mirror. Used to heal a * mirror that never seeded (the boot fetch raced or failed) — e.g. from the * new-tab handler when it finds no window to append into. Applies only if no - * local write or newer remote push landed meanwhile. Rejects when the fetch - * fails so callers can chain on success. + * local write or newer remote push landed meanwhile, but always RETURNS the + * fetched snapshot (null if no fetcher is registered) so a caller can act on + * the server state even when the store apply was skipped. Rejects when the + * fetch fails so callers can chain on success. */ -export async function reseedMirror(): Promise { - if (!fetchAuthoritative) return; +export async function reseedMirror(): Promise { + if (!fetchAuthoritative) return null; const versionAtRequest = remoteSnapshotVersion; const server = await fetchAuthoritative(); if (inFlight === 0 && remoteSnapshotVersion === versionAtRequest) { browserTabsStore.getState().setSnapshot(server); } + return server; } /** Read the mirror's current snapshot (non-reactive; for event handlers and From edd771b13c595da591222b3318cb40bff8044bd9 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Fri, 10 Jul 2026 14:25:06 +0100 Subject: [PATCH 5/7] fix: harden migration 0020, seed abort, usage button UX - Migration 0020: CREATE TABLE IF NOT EXISTS (full current schema) before the tolerated ALTERs, so a stale variant missing the browser tables entirely heals instead of failing the migration transaction on "no such table"; document the type/NOT NULL divergence limitation. Verified against healthy, panes-era, and tables-missing lineages. - New-tab reseed failure now logs instead of a silent catch. - Contribution seed loop is abort-aware: stop() cancels the backoff and a late snapshot can't apply after teardown; repeated start() can't stack loops. - UsageButton: loading skeleton reserves the button's spot (no title-bar layout shift), the popover is controlled so the trigger click closes the card before opening settings, and hover-card opens track UPGRADE_PROMPT_SHOWN (new titlebar_card shown-surface). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/shared/src/analytics-events.ts | 5 ++- .../ui/src/features/billing/UsageButton.tsx | 34 ++++++++++++-- .../features/browser-tabs/BrowserTabStrip.tsx | 7 ++- .../browser-tabs-events.contribution.ts | 36 ++++++++++++--- .../0020_repair_browser_tabs_schema.sql | 45 ++++++++++++++++--- 5 files changed, 111 insertions(+), 16 deletions(-) diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index 976b2d9e7a..143c0c06a3 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -958,7 +958,10 @@ 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" diff --git a/packages/ui/src/features/billing/UsageButton.tsx b/packages/ui/src/features/billing/UsageButton.tsx index 84e2db2062..f9cd74c729 100644 --- a/packages/ui/src/features/billing/UsageButton.tsx +++ b/packages/ui/src/features/billing/UsageButton.tsx @@ -15,6 +15,7 @@ import { ANALYTICS_EVENTS, type UpgradePromptClickedSurface, } from "@posthog/shared/analytics-events"; +import { useState } from "react"; import { track } from "../../shell/analytics"; import { useFeatureFlag } from "../feature-flags/useFeatureFlag"; import { openSettings } from "../settings/hooks/useOpenSettings"; @@ -30,9 +31,24 @@ import { useFreeUsage } from "./useFreeUsage"; // popover portal. export function UsageButton() { const billingEnabled = useFeatureFlag(BILLING_FLAG); - const { usage } = useFreeUsage(billingEnabled); + 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 || !usage) return null; + 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 = @@ -42,13 +58,25 @@ export function UsageButton() { 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"); }; return ( - + (ROOT_LOGGER); const snapshot = useTabsSnapshot(); const navigate = useNavigate(); const router = useRouter(); @@ -789,7 +792,9 @@ export function BrowserTabStrip() { : primaryWindow(readMirror()); if (win) createBlankTab(win.id); }) - .catch(() => undefined); + .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/browser-tabs-events.contribution.ts b/packages/ui/src/features/browser-tabs/browser-tabs-events.contribution.ts index 81a2f286c6..ef27f3629c 100644 --- a/packages/ui/src/features/browser-tabs/browser-tabs-events.contribution.ts +++ b/packages/ui/src/features/browser-tabs/browser-tabs-events.contribution.ts @@ -20,6 +20,7 @@ const SEED_RETRY_BASE_MS = 1_000; @injectable() export class BrowserTabsEventsContribution implements Contribution { private subscription: { unsubscribe: () => void } | null = null; + private seedAbort: AbortController | null = null; private readonly logger; constructor( @@ -36,7 +37,11 @@ export class BrowserTabsEventsContribution implements Contribution { // (a failed mutation emits no snapshotChange, so nothing else reconciles). registerSnapshotFetcher(() => this.client.getSnapshot()); - void this.seedWithRetry(); + // Abort any prior loop so a repeated start() can't stack a second one + // (mirrors the subscription replacement below). + this.seedAbort?.abort(); + this.seedAbort = new AbortController(); + void this.seedWithRetry(this.seedAbort.signal); // Replace any prior handle so a repeated start() can't leak a subscription. this.subscription?.unsubscribe(); @@ -48,24 +53,43 @@ export class BrowserTabsEventsContribution implements Contribution { // A failed seed used to be swallowed, leaving the mirror windowless forever — // the strip renders only a dead "+" in that state. Retry with backoff, and // make the terminal failure loud so a broken service can't fail silently. - private async seedWithRetry(): Promise { + // Abort-aware: stop() must cancel the backoff and prevent a late snapshot + // from applying after teardown. + private async seedWithRetry(signal: AbortSignal): Promise { for (let attempt = 1; attempt <= SEED_ATTEMPTS; attempt++) { + if (signal.aborted) return; try { - applyRemoteSnapshot(await this.client.getSnapshot()); + const snapshot = await this.client.getSnapshot(); + if (signal.aborted) return; + applyRemoteSnapshot(snapshot); return; } catch (error) { + if (signal.aborted) return; if (attempt === SEED_ATTEMPTS) { this.logger.error("browser-tabs snapshot seed failed", { error }); return; } - await new Promise((resolve) => - setTimeout(resolve, SEED_RETRY_BASE_MS * 2 ** (attempt - 1)), - ); + await new Promise((resolve) => { + const timer = setTimeout( + resolve, + SEED_RETRY_BASE_MS * 2 ** (attempt - 1), + ); + signal.addEventListener( + "abort", + () => { + clearTimeout(timer); + resolve(); + }, + { once: true }, + ); + }); } } } stop(): void { + this.seedAbort?.abort(); + this.seedAbort = null; registerSnapshotFetcher(null); this.subscription?.unsubscribe(); this.subscription = null; 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 index 735ec42dfd..9dfb2a0659 100644 --- 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 @@ -1,10 +1,45 @@ -- 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 the early panes-era version has it recorded as applied and never --- got the rewritten tab-strip schema: `browser_windows` lacks `active_tab_id`, --- which makes every browser-tabs query throw and leaves the tab strip dead. --- Each ALTER below is a no-op on healthy DBs: the runner tolerates --- "duplicate column name" (see migrate.ts) and only heals divergent tables. +-- 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 From 0e104ba75c0e38747bf1f289d0c5f9bb71a7ff13 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Fri, 10 Jul 2026 21:33:24 +0100 Subject: [PATCH 6/7] fix: address second review round MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - migrate.ts: repair migrations (0020) are best-effort — statements tolerate any SQLite error, so an unforeseen schema divergence degrades to the pre-repair status quo instead of rolling back the batch and killing boot. Covered by migrate.test.ts including a pathological browser_tabs-as-VIEW variant, plus lineage tests for the panes-era and tables-missing variants. - service.ts: document why windowId healing is creation-only (reorder and focus mutations from a desynced mirror carry stale tab ids; the shared transforms no-op safely). - BrowserTabStrip: log when no window is resolvable after a reseed instead of silently skipping. - __root.tsx: gate the title-bar right-side group on billingEnabled || channelsEnabled so an empty Flex can't claim a no-drag rect in the drag region. - backoff.ts: sleepWithBackoff takes an optional AbortSignal; the seed retry loop uses the shared helper instead of a local reimplementation. - UsageButton: Upgrade CTA is a quill Button variant=link per CLAUDE.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/shared/src/backoff.ts | 23 ++++- .../ui/src/features/billing/UsageButton.tsx | 9 +- .../features/browser-tabs/BrowserTabStrip.tsx | 9 +- .../browser-tabs-events.contribution.ts | 20 ++--- packages/ui/src/router/routes/__root.tsx | 33 ++++--- .../workspace-server/src/db/migrate.test.ts | 85 ++++++++++++++++++- packages/workspace-server/src/db/migrate.ts | 14 ++- .../src/services/browser-tabs/service.ts | 6 +- 8 files changed, 161 insertions(+), 38 deletions(-) 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/UsageButton.tsx b/packages/ui/src/features/billing/UsageButton.tsx index f9cd74c729..75e80d89b5 100644 --- a/packages/ui/src/features/billing/UsageButton.tsx +++ b/packages/ui/src/features/billing/UsageButton.tsx @@ -111,13 +111,14 @@ export function UsageButton() { {exceeded ? "Limit reached" : `${usagePercent}% used`} - +
{ logger.error("browser-tabs: new-tab reseed failed", { error }); diff --git a/packages/ui/src/features/browser-tabs/browser-tabs-events.contribution.ts b/packages/ui/src/features/browser-tabs/browser-tabs-events.contribution.ts index ef27f3629c..85216c5b74 100644 --- a/packages/ui/src/features/browser-tabs/browser-tabs-events.contribution.ts +++ b/packages/ui/src/features/browser-tabs/browser-tabs-events.contribution.ts @@ -1,5 +1,6 @@ import type { Contribution } from "@posthog/di/contribution"; import { ROOT_LOGGER, type RootLogger } from "@posthog/di/logger"; +import { sleepWithBackoff } from "@posthog/shared"; import { inject, injectable } from "inversify"; import { BROWSER_TABS_CLIENT, @@ -69,20 +70,11 @@ export class BrowserTabsEventsContribution implements Contribution { this.logger.error("browser-tabs snapshot seed failed", { error }); return; } - await new Promise((resolve) => { - const timer = setTimeout( - resolve, - SEED_RETRY_BASE_MS * 2 ** (attempt - 1), - ); - signal.addEventListener( - "abort", - () => { - clearTimeout(timer); - resolve(); - }, - { once: true }, - ); - }); + await sleepWithBackoff( + attempt - 1, + { initialDelayMs: SEED_RETRY_BASE_MS }, + signal, + ); } } } diff --git a/packages/ui/src/router/routes/__root.tsx b/packages/ui/src/router/routes/__root.tsx index 18387f3b96..b3c0b6eac9 100644 --- a/packages/ui/src/router/routes/__root.tsx +++ b/packages/ui/src/router/routes/__root.tsx @@ -374,20 +374,25 @@ function RootLayout() { noops on param-less routes (inbox, agents, new-task), so it's safe to mount everywhere. */} - - - {channelsEnabled && ( - - )} - + {/* 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/services/browser-tabs/service.ts b/packages/workspace-server/src/services/browser-tabs/service.ts index d2356e33df..3b3e33e813 100644 --- a/packages/workspace-server/src/services/browser-tabs/service.ts +++ b/packages/workspace-server/src/services/browser-tabs/service.ts @@ -98,7 +98,11 @@ export class BrowserTabsService /** 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. */ + * 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 From 63f692177f4deb7cc555521b9d972c2e0bedebd3 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Fri, 10 Jul 2026 21:57:06 +0100 Subject: [PATCH 7/7] upgrade now link --- .../ui/src/features/billing/UsageButton.tsx | 24 +++++++++++++++++-- .../settings/hooks/useOpenSettings.ts | 13 +++++++++- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/features/billing/UsageButton.tsx b/packages/ui/src/features/billing/UsageButton.tsx index 75e80d89b5..95b42d3001 100644 --- a/packages/ui/src/features/billing/UsageButton.tsx +++ b/packages/ui/src/features/billing/UsageButton.tsx @@ -15,10 +15,14 @@ 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 } from "../settings/hooks/useOpenSettings"; +import { + openSettings, + prepareSettingsPage, +} from "../settings/hooks/useOpenSettings"; import { useFreeUsage } from "./useFreeUsage"; // Title-bar usage entry point (replaces the old sidebar usage bar): a compact @@ -75,6 +79,16 @@ export function UsageButton() { 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 ( handleOpenPlan("titlebar")} + render={ + + } > {exceeded ? "Usage: limit reached" : `Usage: ${usagePercent}%`} diff --git a/packages/ui/src/features/settings/hooks/useOpenSettings.ts b/packages/ui/src/features/settings/hooks/useOpenSettings.ts index a1ecc51fcd..4eebc2cfe4 100644 --- a/packages/ui/src/features/settings/hooks/useOpenSettings.ts +++ b/packages/ui/src/features/settings/hooks/useOpenSettings.ts @@ -17,6 +17,18 @@ interface SettingsContext { export function openSettings( category: SettingsCategory = "general", contextOrAction?: SettingsContext | string, +): void { + prepareSettingsPage(contextOrAction); + nav.navigateToSettings(category); +} + +/** + * Reset/pin the settings page store without navigating — for `` CTAs + * that own the navigation themselves (the render={} convention) but + * must not carry stale context or a one-shot action into the page. + */ +export function prepareSettingsPage( + contextOrAction?: SettingsContext | string, ): void { const store = useSettingsPageStore.getState(); if (typeof contextOrAction === "string") { @@ -27,7 +39,6 @@ export function openSettings( store.setInitialAction(null); } store.setFormMode(false); - nav.navigateToSettings(category); } /**