Skip to content

feat: implement Tab Service v2 and replace old tab manager#276

Open
devin-ai-integration[bot] wants to merge 98 commits into
mainfrom
evan/tab-service-prototype-v2
Open

feat: implement Tab Service v2 and replace old tab manager#276
devin-ai-integration[bot] wants to merge 98 commits into
mainfrom
evan/tab-service-prototype-v2

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented May 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Complete OOP-based Tab Service v2 replacement for the old tabs-controller. This PR:

  1. Implements TabService v2 — A clean, future-proofed OOP architecture replacing the old tab manager
  2. Full tab sync across windows — Sync-aware IPC broadcasting; tabs from same space appear across windows when sync enabled
  3. Pinned tabs = always-sync — Clicking a pinned tab in any window moves the tab view there with placeholder in the old window, reusing sync logic
  4. Loading/navigation state for pinned tabs — All tabs (including pinned-owned) now have their state available to renderer for URL bar, loading indicators
  5. Complete feature parity with old system: auto-sleep/archive, fullscreen management, space-deletion cleanup, popup window reconciliation, background tab creation, error pages, web context menu, recently closed, save-image-as
  6. Review-blocker fixes — Ensures existing live tabs pinned into the grid propagate their layout node across same-profile layouts, hidden layouts cannot reveal web contents, and window.open() / web context-menu new tabs resolve the active target window/space at action time.

Architecture

  • TabService — central orchestrator (tabs, pinned tabs, layouts, positioner)
  • Tab — OOP entity with lifecycle (create → sleep → wake → destroy)
  • PinnedTab — persistent entity with per-space associations
  • TabLayout / TabLayoutNode — per-window layout management
  • TabIPC — batched structural/content IPC with sync-aware broadcasting
  • TabSync — handles tab view relocation between windows (focus-move, placeholder screenshots)
  • TabPersistenceService — dirty-tracking + periodic flush to SQLite
  • TabOwnerRef discriminated union (normal | pinned | bookmark) for future-proofing

Review & Testing Checklist for Human

  • Pinned tabs across windows: Pin an existing tab in window 1, click same pin in window 2 / another same-profile space → tab moves to the requested layout and placeholder remains in the old layout
  • Hidden space safety: Activate or sync a tab in a non-current space → web contents from the hidden space do not appear until switching to that space
  • Popup/new-tab routing: From a pinned/STAW tab that has moved windows, trigger window.open() for foreground/background/popup cases → new tabs open in the active window/space, not the tab's original creation space
  • Web context menu routing: Right-click page content in a moved pinned/STAW tab and open a link in a new tab/window → it targets the currently rendered window/space
  • Loading state in pinned tabs: Click a pinned tab → URL bar shows correct URL, loading spinner appears during navigation
  • Tab sync (when enabled): Enable "Sync Tabs Across Windows" → tabs from same space visible in all windows, focus moves tab view
  • Auto-sleep: Set "Sleep Tab After" to 5min, leave a background tab idle → it should sleep (no webContents)

Notes

  • Local validation run: bun run format, bun run typecheck, and bun run lint.
  • The build (ubuntu-24.04-arm) CI check may fail due to a known infrastructure issue with app-builder-bin (preexisting, not caused by this PR)
  • Tab sync placeholder screenshots use capturePage() which requires the tab to have been rendered at least once
  • Fullscreen management now properly exits HTML fullscreen and OS fullscreen when tabs are hidden/space changes

Link to Devin session: https://app.devin.ai/sessions/24bce8b710ba4e62b93215436e0b26a9
Requested by: @iamEvanYT

iamEvanYT and others added 2 commits May 24, 2026 18:42
- Core entities: Tab (OOP, WebContentsView lifecycle, TypedEventEmitter),
  TabLayoutNode (replaces old TabGroup for visual display grouping),
  PinnedTab (first-class citizen with per-space associations)
- Layout management: TabLayout (per-window active/focused state),
  TabPositioner (float-point ordering for efficient insertion)
- TabService orchestrator: unified tab creation, activation, movement,
  pinned tab operations, and event emission
- Persistence: TabPersistenceService with dirty tracking and batched
  flushing every 2s
- IPC: TabIPC with debounced structural/content updates, 20+ handlers
- Shared types: TabOwnerRef union (normal/pinned/bookmark) for
  future-proofing bookmarks feature
- Preload API factory: createTabServicePreloadAPI for renderer bridge
- React provider: TabServiceProvider with layout node computation,
  pinned tabs state, and focused tab tracking
- FlowTabServiceAPI interface for renderer consumption

Co-Authored-By: Evan <47493765+iamEvanYT@users.noreply.github.com>
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

@github-actions

github-actions Bot commented May 24, 2026

Copy link
Copy Markdown
Contributor

Build artifacts for all platforms are ready! 🚀

Download the artifacts for:

One-line installer (Unstable):
bunx flow-debug-build@1.2.1 --open 26471187611

(execution 26471187611 / attempt 1)

@iamEvanYT

Copy link
Copy Markdown
Member

@greptile review (aside)

devin-ai-integration[bot]

This comment was marked as resolved.

- TabLayoutNode: track destroy listeners per tab and clean them up on
  removeTab() and destroy() to prevent crashes from stale callbacks
- TabLayoutNode: guard onDestroyed callback with isDestroyed check
- TabService.unpinToTabList: collect affected window IDs before calling
  destroy() which clears associations, ensuring structural changes are
  properly emitted to the renderer

Co-Authored-By: Evan <47493765+iamEvanYT@users.noreply.github.com>
@greptile-apps

greptile-apps Bot commented May 24, 2026

Copy link
Copy Markdown

Greptile Summary

This PR replaces the old procedural tabs-controller (~4,800 lines deleted) with a clean OOP TabService v2 (~7,600 lines added), adding cross-window tab sync, pinned-tab grid with per-space associations, layout nodes (glance/split), a dirty-tracking persistence service, and a debounced IPC layer.

  • Core architecture: All previously-flagged issues (double layout-node-destroyed emission, stale layout closure in wireTabEvents, wasActive always-false, dirty-state cleared before transaction, unpinToTabList structural changes, persistence owner-transition gaps) appear addressed in the HEAD commit.
  • clickPinnedTab new-tab async gap: The existingTab branch guards if (existingTab.isDestroyed || window.destroyed) after its await sendPlaceholderForTab, but the new-tab creation branch (lines 703-724) has no equivalent guard after await createTab(…).
  • startTabLifecycleTimer interval not stored: The setInterval return value is discarded, making it impossible to clear the 10-second cycle.

Confidence Score: 4/5

Safe to merge after addressing the missing post-createTab destroy guard in clickPinnedTab; all other changes are well-structured and the previously-flagged regressions are resolved.

The rewrite is architecturally sound and previously-flagged regressions are addressed. One new defect remains: the new-tab creation branch of clickPinnedTab skips the post-await stale check that the existing-tab branch already has, which can leave a pinned tab permanently unresponsive if the window is destroyed mid-flight.

src/main/services/tab-service/tab-service.ts (new-tab path in clickPinnedTab, line 706) and src/main/services/tab-service/tab-lifecycle-timer.ts (discarded setInterval handle)

Important Files Changed

Filename Overview
src/main/services/tab-service/tab-service.ts Central orchestrator: pinned-tab async paths have a missing post-createTab stale guard; wasActive heuristic has an edge-case false-positive; previously-flagged P1s are addressed.
src/main/services/tab-service/tab-lifecycle-timer.ts Archive/sleep timer: setInterval return value discarded, preventing cleanup; pollPageState and quit guards are otherwise correct.
src/main/services/tab-service/persistence/tab-persistence-service.ts Dirty-tracking + flush: snapshot-before-clear pattern and retry-on-failure re-queue correctly address the previously-flagged data-loss issue.
src/main/services/tab-service/layout/tab-layout.ts Per-window-space layout: destroyNode no longer double-emits layout-node-destroyed; tabToNode cleanup and removeActiveAndSelectNext logic look sound.
src/main/services/tab-service/tab-sync.ts Cross-window sync and placeholder management: serialized runTabSyncMutation queue, stale checks, and post-await guards are present.
src/main/services/tab-service/core/tab-layout-node.ts Layout node entity: multi-layout membership, activeLayout tracking, auto-destroy on empty, and computeBounds for split/glance look correct.
src/main/services/tab-service/core/tab.ts scheduleUpdate now accumulates all pending property names in a Set before the microtask fires, fixing the previously-flagged silent discard of later batches.
src/main/saving/tabs/restore.ts Session restore: correctly groups tabs by window, pre-loads profiles, restores layout nodes, and uses beginBatch/endBatch to suppress mid-restore emissions.

Sequence Diagram

sequenceDiagram
    participant R as Renderer
    participant IPC as TabIPC
    participant TS as TabService
    participant TLN as TabLayoutNode
    participant Sync as TabSync
    participant Persist as TabPersistenceService

    R->>IPC: tab-service:pinned-tabs-click
    IPC->>TS: clickPinnedTab(pinnedTabId, window)
    alt existing associated tab
        TS->>Sync: sendPlaceholderForTab(tab, oldWindow)
        Note over Sync: capturePage() async
        Sync-->>TS: screenshot sent
        TS->>TLN: setActiveLayout(targetLayout)
        TS->>TS: activateTab(existingTab)
    else no associated tab
        TS->>TS: createTab() async profile load
        TS->>TLN: createSingleNode(tab)
        TS->>TS: propagatePinnedTabNode(node, profileId)
        TS->>TS: activateTab(tab)
    end
    TS->>IPC: emit structural-change windowId
    IPC->>R: tab-service on-data-changed debounced 32ms

    Note over Persist: Every 2 seconds
    Persist->>Persist: flush snapshot dirty maps run transaction
    alt transaction fails
        Persist->>Persist: re-queue unmasked entries
    end
Loading

Reviews (8): Last reviewed commit: "fixes" | Re-trigger Greptile

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

Test Results — Regression Testing

Ran Flow Browser locally on evan/tab-service-prototype-v2 to verify that adding ~3500 lines of new TabService v2 code does not break the existing tab system.

All 6 tests passed
# Test Result
1 App starts without crashes (build regression) ✅ PASSED
2 Tab creation via + button ✅ PASSED
3 Tab switching between multiple tabs ✅ PASSED
4 Tab closing via context menu ✅ PASSED
5 Navigation within a tab via address bar ✅ PASSED
6 Back button navigation history ✅ PASSED
Important: Integration Status

The new TabService v2 is architecturally complete but not yet integrated into the running application:

  • initializeTabService() is defined but NOT called from app startup
  • tabServiceAPI is NOT added to the preload context bridge
  • <TabServiceProvider> is NOT mounted in the React tree

The existing tabs-controller handles all runtime tab functionality. The new code exists as a parallel, dormant implementation that passes lint/typecheck/format.

Before the new TabService can be tested end-to-end, these integration steps are needed:

  1. Call initializeTabService() in main process after DB init
  2. Wire tabServiceAPI into preload bridge
  3. Mount <TabServiceProvider> in renderer
  4. Gradually migrate UI components to use the new provider
Evidence Screenshots
  • App launched with sidebar, new tab page, and navigation controls
  • Tab created with "Example Domain" title after navigating to example.com
  • Two tabs visible in sidebar (httpbin.org/html and Example Domain)
  • Tab closed via right-click context menu
  • In-tab navigation updated URL, title, and back button state
  • Back button returned to previous page correctly

🔗 Devin session

devin-ai-integration Bot and others added 2 commits May 24, 2026 18:34
…, flush data-loss)

- wireTabEvents: look up layout dynamically from this.layouts instead of
  capturing stale closure reference. Fixes broken cleanup after setWindow.
- wireTabEvents: detect wasActive even after the once('destroyed') listener
  from TabLayoutNode.addTab has already removed the tab and auto-destroyed
  the node. Checks activeNode.isDestroyed as fallback.
- TabPersistenceService.flush: clear dirty state only after successful
  transaction to prevent data loss if the DB write throws.

Co-Authored-By: Evan <47493765+iamEvanYT@users.noreply.github.com>
…nView

WebContentsView does not have a destroy() method in Electron.
The old tab controller correctly uses webContents.close() to tear down
views. Fixed the same pattern in Tab Service v2.

Co-Authored-By: Evan <47493765+iamEvanYT@users.noreply.github.com>
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

End-to-End Integration Test Results

Tested TabService v2 IPC integration by calling flow.tabService methods from the renderer DevTools console in the running app.

All 7 tests passed after fixing one bug discovered during testing.

Test Results
# Test Result
1 flow.tabService accessible from renderer ✅ PASS
2 getData() returns valid payload ✅ PASS
3 newTab() creates tab + loads URL ✅ PASS
4 switchToTab() changes focused state ✅ PASS
5 closeTab() removes tab (bug fix verified) ✅ PASS
6 getPinnedTabs() returns valid data ✅ PASS
7 No tab-service IPC errors ✅ PASS
Bug Found & Fixed

closeTab() threw TypeError: this.view.destroy is not a functionWebContentsView doesn't have a destroy() method in Electron. Fixed by using webContents.close() instead (matching old tab controller pattern). Commit: e756778.

Key Evidence

Sidebar UI renders TabService v2 tabs (proves full renderer integration):
Sidebar

closeTab works after fix (before: 2, after: 1):
closeTab

All operations complete without errors:
No errors

Notes
  • TabService v2 is a parallel implementation — the old tabs-controller still handles page bounds/positioning. The new system creates WebContentsViews, loads URLs, and manages all state correctly.
  • Tab content isn't visually positioned in the content area yet (requires page bounds integration when old system is replaced).
  • Sidebar UI correctly renders tabs from TabService v2 via TabServiceProvider.

Devin session

devin-ai-integration Bot and others added 2 commits May 24, 2026 19:58
- Migrate all main-process imports from tabsController to tabService
- Migrate all renderer flow.tabs.*/flow.pinnedTabs.* to flow.tabService.*
- Remove old preload tabs/pinnedTabs API
- Remove old IPC handlers (tabs.ts, pinned-tabs.ts)
- Remove old tabs-controller and pinned-tabs-controller directories
- Remove old TabPersistenceManager (saving/tabs/index.ts)
- Remove old shared flow interfaces (tabs.ts, pinned-tabs.ts)
- Update DB schema to use new TabLayoutNodeMode type
- Add setCurrentWindowSpace/handlePageBoundsChanged to TabService
- Add removeLayoutNode to TabPersistenceService
- Rewrite saving/tabs/restore.ts for new TabService API
- Update app-menu-controller to use tabService.recentlyClosed

Co-Authored-By: Evan <47493765+iamEvanYT@users.noreply.github.com>
Without calling updateTabVisibility() and handlePageBoundsChanged(),
switching tabs only changed internal state but didn't show/hide the
actual Electron WebContentsViews.

Co-Authored-By: Evan <47493765+iamEvanYT@users.noreply.github.com>
@devin-ai-integration devin-ai-integration Bot changed the title feat: implement Tab Service v2 architecture feat: implement Tab Service v2 and replace old tab manager May 24, 2026
devin-ai-integration Bot and others added 14 commits May 24, 2026 20:38
activateTab/activateNode must notify the renderer of active state
changes so the sidebar highlights the correct active tab.

Co-Authored-By: Evan <47493765+iamEvanYT@users.noreply.github.com>
…/update)

- Added loadPinnedTabs() that reads from pinned_tabs table on startup
- Added savePinnedTab() for upsert on create and favicon/position updates
- Added deletePinnedTabFromDb() for remove and unpin operations
- Added 'Pin Tab' context menu item for regular tabs
- Wire updated event to persist favicon/position changes automatically
- Called loadPinnedTabs() in initializeTabService()

Co-Authored-By: Evan <47493765+iamEvanYT@users.noreply.github.com>
- Add 'Sync Tabs Across Windows' feature (tab-sync.ts):
  - Moves active tab view to focused window when setting enabled
  - Shows screenshot placeholders in windows that lose the view
  - Relocates tabs from closing windows to surviving windows
  - Pinned tabs always sync across windows regardless of setting
  - Uses async queue with generation-based staleness detection
- Fix pinned tabs across multiple windows via moveTabToWindowHook
- Re-add web page right-click context menu (electron-context-menu):
  - Back/Forward/Reload, Open link in new tab/window
  - Copy/Paste/Cut/SelectAll, Search Google for selection
  - Save Image As, Copy Image/Image Address
  - View Page/Frame Source, Inspect Element
  - Extension context menu items
- Add did-fail-load handler with error page navigation
- Add devtools-open-url handler for opening URLs from DevTools
- Add update-target-url handler for hover link URL preview
- Clear placeholders when tabs are destroyed

Co-Authored-By: Evan <47493765+iamEvanYT@users.noreply.github.com>
…nd tabs

- IPC: include all tabs in payload (pinned-tab-owned tabs visible to renderer)
- IPC: pinned-tab-owned content updates broadcast to all windows (always-sync)
- Renderer: filter pinned/bookmark-owned tabs from sidebar groups
- Tab: add setFullScreen() with document.exitFullscreen() and event emission
- Tab: route enter/leave-html-full-screen through setFullScreen()
- Tab: call applyUrlBackground() after wakeUp()
- TabService: exit fullscreen when tab is hidden or space changes
- TabService: listen for fullscreen-changed to update bounds
- TabService: listen for BrowserWindow leave-full-screen to reset tab state
- TabService: add startBackgroundTasks() for space-deletion + auto-sleep/archive
- TabService: respect makeActive option (background tabs don't activate)
- TabService: add popup window reconciliation (auto-close empty popups)

Co-Authored-By: Evan <47493765+iamEvanYT@users.noreply.github.com>
Co-Authored-By: Evan <47493765+iamEvanYT@users.noreply.github.com>
Bug 1 - Tab sync clicking does nothing:
- switch-to-tab IPC now detects cross-window requests and moves
  the tab to the requesting window before activating it
- Added migrateTabBetweenLayouts() to move layout nodes between
  windows (previously only the view/layer moved, not the node)
- moveTabToWindowIfNeeded now calls layout migration before setWindow

Bug 2 - Archive Tab After too aggressive:
- Fixed unit mismatch: lastActiveAt is in seconds (getCurrentTimestamp)
  but timer compared against Date.now() (milliseconds). The difference
  was always ~1.7 trillion, exceeding any archive threshold immediately.
- Timer now uses seconds consistently (parseDurationToSeconds)
- activateTab() now updates lastActiveAt on each tab switch

Co-Authored-By: Evan <47493765+iamEvanYT@users.noreply.github.com>
The archive/sleep timer should measure from when the user stopped
viewing a tab. Previously lastActiveAt was only set on activation,
so a tab viewed for hours would appear 'inactive since activation'
when finally switched away from.

Co-Authored-By: Evan <47493765+iamEvanYT@users.noreply.github.com>
1. Focus loop: When a tab was migrated between windows,
   migrateTabBetweenLayouts destroyed the old node but left the
   focusedTabMap entry pointing at the migrated tab. The window-focus
   handler then found a 'focused tab' in the old window and moved it
   back, creating an infinite focus cycle. Fixed by clearing the
   focused tab reference when migrating.

2. Placeholder corners: Added overflow-hidden to BrowserContent
   container so the tab screenshot respects the parent's border-radius.

Co-Authored-By: Evan <47493765+iamEvanYT@users.noreply.github.com>
The focus handler previously used async moveTabToWindowIfNeeded which
calls captureTabScreenshot(). During the async gap, Electron can fire
a focus event for the other window (platform quirk when WebContentsViews
move), incrementing _focusMoveGeneration and making the current move
stale. Result: the move was cancelled and the tab never arrived.

Fix: The focus handler now uses moveTabToWindowImmediate() which moves
the tab synchronously without screenshot capture. The old window just
shows empty content (no placeholder image) instead of a screenshot —
acceptable trade-off for reliable tab movement.

Also reverted the focusedTabMap clearing in migrateTabBetweenLayouts —
the focused-tab reference is the window's 'memory' of what tab it was
viewing, needed by the focus handler to pull it back.

Additionally fixed placeholder rounded corners: container now uses
rounded-md (6px) matching the actual tab view's setBorderRadius(6).

Co-Authored-By: Evan <47493765+iamEvanYT@users.noreply.github.com>
… focus handler

Root cause of the infinite loop:
1. Focus handler moves tab from B to A
2. activateTab() emits 'active-changed'
3. 'active-changed' triggered relocateDisplacedTabs()
4. Both windows have the same tab in their focusedTabMap
5. relocateDisplacedTabs sees B 'wants' the tab and if B appears
   focused (Electron quirk during WebContentsView manipulation),
   it moves the tab back → triggering step 1 again

Fix:
- Removed relocateDisplacedTabs entirely. All tab moves are handled
  by explicit paths: focus handler, switch-to-tab IPC,
  moveTabToWindowHook (pinned tab click), and space-change handler.
  The reactive 'catch-all' was redundant and caused fights with
  these explicit handlers.
- Focus handler now uses the async moveTabToWindowIfNeeded (screenshot
  → move → placeholder) serialized through runTabSyncMutation, with a
  150ms debounce to ignore spurious focus events from Electron's
  WebContentsView manipulation.
- Removed moveTabToWindowImmediate (replaced by proper async approach).

Co-Authored-By: Evan <47493765+iamEvanYT@users.noreply.github.com>
The focus handler now programmatically calls window.focus() AFTER
the tab has been moved, activated, and made visible. This ensures
the correct window stays in front even when Electron briefly shifts
focus during WebContentsView manipulation.

Also increased debounce to 200ms to better absorb spurious events.

Co-Authored-By: Evan <47493765+iamEvanYT@users.noreply.github.com>
Three changes to fix focus cycling:

1. Set debounce timestamp BEFORE queuing the async mutation (not just
   after). Previously, during the ~100ms screenshot capture, a spurious
   focus event for the other window could queue a competing mutation that
   would move the tab back. Now the debounce gate is locked immediately
   when any focus-triggered move starts.

2. activateTab() now calls tab.layer.focus() after making the tab visible.
   This tells the LayerManager to give the tab's webContents input focus
   in the target window. Without this, after the old window's
   reallocateFocus fires (focusing its browserUI webContents), the target
   window had no explicitly focused tab layer, leaving it in an ambiguous
   focus state.

3. Removed window.browserWindow.focus() call (user request) — the correct
   approach is to focus the tab's layer, not the window itself.

Co-Authored-By: Evan <47493765+iamEvanYT@users.noreply.github.com>
Root cause: when a tab moves between windows, the old window's
LayerManager calls reallocateFocus() (triggered by the focused layer
being hidden/removed). This calls webContents.focus() on the next
available layer, which can steal OS focus to the background window,
causing infinite focus cycling.

Fix: LayerManager.reallocateFocus() now checks if its window is
focused before executing. If not focused, it sets a pending flag.
When the window later gains focus, the deferred reallocation runs
once — regardless of how many times it was requested while unfocused.

This is a clean, general fix that prevents ANY background window from
stealing focus via webContents.focus(). The tab-sync debounce hack
is removed since it's no longer needed.

Co-Authored-By: Evan <47493765+iamEvanYT@users.noreply.github.com>
When layer.focus() succeeds, there's no need for a deferred
reallocateFocus — focus has already been assigned to the correct
layer.

Co-Authored-By: Evan <47493765+iamEvanYT@users.noreply.github.com>
- Remove stale position cache in TabLayoutNode (always recompute)
- Include layout-derived spaces in IPC payload (pinned tab coverage)
- Validate same-space/window in createLayoutNode
- Hide pinned tabs in old active node on space switch
- Use _pipCount instead of O(n) scan for PiP check in space switch
- Only hide tabs in batchMoveTabs when actually leaving source layout
- Wrap restore batch in try/finally for endBatch safety
- Guard clickPinnedTab after async sendPlaceholderForTab

Co-Authored-By: Evan <47493765+iamEvanYT@users.noreply.github.com>
@iamEvanYT

Copy link
Copy Markdown
Member

@greptile @coderabbitai review (aside)

@coderabbitai

coderabbitai Bot commented May 25, 2026

Copy link
Copy Markdown
Contributor

@iamEvanYT Sure, triggering a review of the PR now!

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

greptile-apps[bot]

This comment was marked as resolved.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/main/services/tab-service/tab-service.ts (1)

754-760: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Mirror the post-await destruction guard in doubleClickPinnedTab.

This path still calls existingTab.setWindow(window) after await sendPlaceholderForTab(...). If the tab or target window is destroyed during that await, you'll mutate stale state or throw on a user-driven close race.

💡 Minimal fix
         if (existingTab.getWindow().id !== window.id) {
           const oldWindow = existingTab.getWindow();
           await sendPlaceholderForTab(existingTab, oldWindow);
+          if (existingTab.isDestroyed || window.destroyed) return true;
           existingTab.setWindow(window);
           node.setActiveLayout(targetLayout);
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/services/tab-service/tab-service.ts` around lines 754 - 760, The
code calls existingTab.setWindow(window) and node.setActiveLayout(targetLayout)
after awaiting sendPlaceholderForTab(existingTab, oldWindow), which can race
with tab/window destruction; mirror the post-await destruction guard used in
doubleClickPinnedTab by re-checking that existingTab and window are still valid
(not destroyed/closed and still attached to expected parents) before mutating
them—specifically, after the await verify existingTab is not disposed and
existingTab.getWindow().id still equals oldWindow.id (or that target window
still exists) and only then call existingTab.setWindow(window) and
node.setActiveLayout(targetLayout); otherwise abort the move.
♻️ Duplicate comments (1)
src/main/services/tab-service/tab-service.ts (1)

1261-1265: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't rely on WeakMap GC to retire old WebContents mappings.

A WeakMap only drops an entry after the old WebContents becomes unreachable. getTabByWebContents(oldWebContents) can still resolve to this tab while any caller is holding that stale object, so sleep/view-replacement paths need an explicit delete(...) for the retired key.

#!/bin/bash
set -euo pipefail

printf '\n== webContentsIndex mutation sites ==\n'
rg -n -C2 'webContentsIndex\.(set|delete)\(' src/main/services/tab-service

printf '\n== WebContents lifecycle sites on Tab/TabService ==\n'
rg -n -C3 '\b(teardownView|wakeUp|sleep|webContents\b|window-changed|destroyed)\b' \
  src/main/services/tab-service \
  src/main/services/tab-service/core

Expected result: you'll find webContentsIndex.set(...) sites but no matching webContentsIndex.delete(...) on teardown/replacement paths, which confirms the stale reverse-index window.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/services/tab-service/tab-service.ts` around lines 1261 - 1265, The
webContentsIndex WeakMap is left pointing at retired WebContents objects, so
update paths that replace or tear down a tab's webContents (e.g., the
asleep/wakeUp branch shown, sleep(), teardownView(), any
view-replacement/destroy handlers) must explicitly remove the old mapping before
or when assigning a new one; locate webContentsIndex.set(...) usages and add
this.webContentsIndex.delete(oldWebContents) (using the previous tab.webContents
reference) in wakeUp, sleep, teardownView, and any window-changed/destroyed
handlers so getTabByWebContents cannot return stale mappings.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/main/services/tab-service/tab-service.ts`:
- Around line 754-760: The code calls existingTab.setWindow(window) and
node.setActiveLayout(targetLayout) after awaiting
sendPlaceholderForTab(existingTab, oldWindow), which can race with tab/window
destruction; mirror the post-await destruction guard used in
doubleClickPinnedTab by re-checking that existingTab and window are still valid
(not destroyed/closed and still attached to expected parents) before mutating
them—specifically, after the await verify existingTab is not disposed and
existingTab.getWindow().id still equals oldWindow.id (or that target window
still exists) and only then call existingTab.setWindow(window) and
node.setActiveLayout(targetLayout); otherwise abort the move.

---

Duplicate comments:
In `@src/main/services/tab-service/tab-service.ts`:
- Around line 1261-1265: The webContentsIndex WeakMap is left pointing at
retired WebContents objects, so update paths that replace or tear down a tab's
webContents (e.g., the asleep/wakeUp branch shown, sleep(), teardownView(), any
view-replacement/destroy handlers) must explicitly remove the old mapping before
or when assigning a new one; locate webContentsIndex.set(...) usages and add
this.webContentsIndex.delete(oldWebContents) (using the previous tab.webContents
reference) in wakeUp, sleep, teardownView, and any window-changed/destroyed
handlers so getTabByWebContents cannot return stale mappings.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 835e8e2b-6253-474a-bcdf-c94e81335569

📥 Commits

Reviewing files that changed from the base of the PR and between 7b38870 and 2f0b468.

📒 Files selected for processing (5)
  • src/main/saving/tabs/restore.ts
  • src/main/services/tab-service/core/tab-layout-node.ts
  • src/main/services/tab-service/ipc/tab-ipc.ts
  • src/main/services/tab-service/layout/tab-layout.ts
  • src/main/services/tab-service/tab-service.ts

devin-ai-integration Bot and others added 10 commits May 25, 2026 23:37
Mirror the isDestroyed/window.destroyed check already present in
clickPinnedTab after sendPlaceholderForTab — prevents mutating
stale state if tab or window is destroyed during capturePage().

Co-Authored-By: Evan <47493765+iamEvanYT@users.noreply.github.com>
- Call notifyExtensionsOfChanges on every tab 'updated' event (title, url,
  muted, audible, isLoading, asleep, PiP state changes)
- Add notifyIndexChanges helper to notify all tabs in a window+profile
  when their index may have shifted
- Call notifyIndexChanges after: tab creation, tab destruction, tab
  window-change (both old and new window), moveTabToSpace, batchMoveTabs
- Add migration checklist doc comparing old Tab Manager vs new Tab Service

Co-Authored-By: Evan <47493765+iamEvanYT@users.noreply.github.com>
The old Tab class emitted 'tab-updated' in setupTabLevelListeners() on
every 'updated' event. This was lost during the Tab Service v2 migration
and has now been restored via notifyExtensionsOfChanges(). Updated the
checklist to accurately reflect this was a regression fix, not new
functionality.

Also fixes markdown table formatting for CI.

Co-Authored-By: Evan <47493765+iamEvanYT@users.noreply.github.com>
The previous implementation iterated layout nodes sorted by node.position
(derived from Math.min of tabs' positions). This could produce
non-deterministic results when nodes from different spaces shared the
same position value, or when pinned tab nodes were filtered out by the
getWindow().id check.

Now directly queries getTabsInWindowSpace per layout and sorts by
tab.position, which matches the renderer's sidebar ordering and
guarantees unique indices within a window+profile.

Co-Authored-By: Evan <47493765+iamEvanYT@users.noreply.github.com>
Comprehensive guide for agents working in this directory. Covers
architecture, class relationships, key patterns (extension notifications,
multi-layout membership, IPC caching, tab sync), common pitfalls, and
file overview. Includes maintenance rule requiring agents to keep this
file updated as code changes.

Co-Authored-By: Evan <47493765+iamEvanYT@users.noreply.github.com>
1. Simplified getTabsInWindowProfile to just filter getTabsInWindow by
   profileId and sort by position. The previous layout-based approach
   (iterating getTabsInWindowSpace per layout) was failing because
   pinned tabs and cross-space scenarios produced inconsistent results.
   The extension API index is simply position-order among all tabs in
   the same window+profile regardless of space.

2. Added notifyIndexChanges call to moveTab (drag-drop reorder) so the
   extension system's cached tab details are refreshed after reorders.
   Note: chrome.tabs.onUpdated doesn't fire for index-only changes
   (per Chrome API spec), but the cached index is updated correctly
   for subsequent chrome.tabs.get/query calls.

Co-Authored-By: Evan <47493765+iamEvanYT@users.noreply.github.com>
@iamEvanYT

Copy link
Copy Markdown
Member

@cursor review

@iamEvanYT

Copy link
Copy Markdown
Member

@cursor @greptile @coderabbitai review (aside)

@cursor

cursor Bot commented May 26, 2026

Copy link
Copy Markdown

Taking a look!

Open in Web Open in Cursor 

@coderabbitai

coderabbitai Bot commented May 26, 2026

Copy link
Copy Markdown
Contributor

@iamEvanYT Sure, triggering a review of the PR now!

✅ Actions performed

Full review triggered.

@iamEvanYT

Copy link
Copy Markdown
Member

bugbot run

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 14

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/main/services/tab-service/core/web-context-menu.ts (1)

40-58: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Resolve the target window from tab at click time.

createNewTab() closes over the window that was current when the context menu was installed. Pinned/STAW tabs can later move to another BrowserWindow, so actions like “Open Link in New Tab” will keep using the old window/currentSpace unless this resolves from tab.getWindow() instead.

💡 Proposed fix
 export function createWebContextMenu(tab: Tab, window: BrowserWindow) {
@@
       const createNewTab = async (url: string, overrideWindow?: BrowserWindow) => {
-        const targetWindow = overrideWindow ?? window;
+        const targetWindow = overrideWindow ?? tab.getWindow();
         const spaceId = targetWindow.currentSpaceId ?? tab.spaceId;
         if (!spaceId) return;
         const newTab = await tabService.createTab(targetWindow.id, tab.profileId, spaceId, undefined, { url });
         tabService.activateTab(newTab);
       };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/services/tab-service/core/web-context-menu.ts` around lines 40 - 58,
The createNewTab closure currently captures the initial window variable so
actions like "Open Link in New Tab" keep using the old BrowserWindow; modify
createNewTab in createWebContextMenu to resolve the target window at click time
by calling tab.getWindow() (or the Tab instance method that returns its current
BrowserWindow) and fall back to the passed overrideWindow or the original window
only if getWindow() is null; ensure you null-check the resolved window and use
its currentSpaceId (or tab.spaceId) before calling tabService.createTab and
tabService.activateTab so the new tab opens in the tab's current
BrowserWindow/space.
♻️ Duplicate comments (2)
docs/tab-layout-refactor-plan.md (1)

5-5: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add language identifiers to fenced code blocks.

These fences still omit a language token, which keeps MD040 active. Please annotate each block (for example text, typescript).

Also applies to: 26-26, 45-45, 135-135, 148-148, 157-157, 170-170

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/tab-layout-refactor-plan.md` at line 5, Several fenced code blocks use
bare triple backticks (```) which trigger MD040; update each triple-backtick
fence in this document to include an explicit language identifier (for example
```text or ```typescript) so Markdown linter recognizes the language. Locate
every instance of the plain ``` fence (including the ones noted in the review)
and replace it with an appropriate token for the block content (e.g., ```text
for plain output snippets, ```typescript for TS examples) to silence MD040 and
improve syntax highlighting.
src/renderer/src/components/providers/pinned-tabs-provider.tsx (1)

79-80: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Preserve position in unpin flow.

Line 79-Line 80 drops the optional position argument even though the context contract includes it, so caller-specified unpin placement is lost.

Suggested fix
-const unpinToTabList = useCallback(async (pinnedTabId: string) => {
-  return flow.tabService.unpinToTabList(pinnedTabId);
+const unpinToTabList = useCallback(async (pinnedTabId: string, position?: number) => {
+  return flow.tabService.unpinToTabList(pinnedTabId, position);
 }, []);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/renderer/src/components/providers/pinned-tabs-provider.tsx` around lines
79 - 80, The unpinToTabList callback drops the optional position argument from
the context contract; update the unpinToTabList signature to accept the optional
position (e.g., unpinToTabList(pinnedTabId: string, position?: number)) and
forward it to the underlying service by calling
flow.tabService.unpinToTabList(pinnedTabId, position) so caller-specified unpin
placement is preserved.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/main/controllers/loaded-profiles-controller/index.ts`:
- Around line 146-147: The code currently falls back to
browserWindowsController.getWindows()[0] when window is missing, which may open
tabs in the wrong profile; instead resolve or create a window scoped to the
given profileId before calling createTab: lookup an existing window for
profileId via browserWindowsController.getWindows().find(w => w.profileId ===
profileId) (or use a browserWindowsController.getWindowForProfile(profileId)
helper), and if none exists create a new profile-scoped window before assigning
targetWindow (replace use of the global window ||
browserWindowsController.getWindows()[0] with this profile-scoped resolution) so
createTab is always invoked in the correct profile context.

In `@src/main/saving/tabs/restore.ts`:
- Around line 118-138: restoreLayoutNodes currently translates
nodeData.tabUniqueIds to tabIds but drops nodeData.frontTabUniqueId, losing
front-tab state; map nodeData.frontTabUniqueId to a frontTabId via
uniqueIdToTabId (similar to the tabIds loop) and pass that frontTabId into
tabService.createLayoutNode (or extend createLayoutNode's signature to accept an
optional frontTabId) so recreated layout nodes preserve the front tab; update
restoreLayoutNodes (and createLayoutNode implementation/signature) to accept and
use the restored frontTabId when available.

In `@src/main/services/tab-service/AGENTS.md`:
- Around line 13-18: The markdown file contains unlabeled fenced code blocks
that trigger MD040; update each unlabeled triple-backtick block in AGENTS.md to
use a language identifier (use "text") — specifically the blocks containing the
short key/value list (tabService → TabService ...), the "Tab property changes →
Tab emits 'updated' → wireTabEvents handler →" block, and the directory listing
block starting with "tab-service/" (and any other unlabeled fences such as those
noted around 51-55 and 115-138) so they read ```text ... ``` instead of plain
```; keep the block contents unchanged.
- Around line 39-45: The Supporting Modules table rows in AGENTS.md are
malformed because several rows only provide two cells while the header requires
three (Module | File | Purpose); update each listed row (e.g., entries for
tab-sync.ts, tab-lifecycle-timer.ts, tab-context-menus.ts, web-context-menu.ts,
save-image-as.ts) so they contain three pipe-separated columns matching the
header (Module, File, Purpose) and ensure the separator row (| --- | --- | ---
|) is present; keep the module name (e.g., `tab-sync.ts`) in the first column,
the file path/details in the second, and the purpose text in the third for each
row.

In `@src/main/services/tab-service/core/tab-context-menus.ts`:
- Around line 145-148: The context menu currently uses hasURL to enable both
"Pin Tab" and "Unpin Tab" (new MenuItem with label based on isPinned), which
disables "Unpin Tab" for pinned tabs with no URL; change the enabled logic so
that when isPinned is true the menu item is always enabled (e.g. enabled =
isPinned ? true : hasURL) so unpinning remains available; update the MenuItem
creation (refer to the MenuItem, isPinned, hasURL, click symbols) accordingly.
- Around line 93-100: The duplicate-tab creation call to
tabService.createTab(...) currently only attaches .then(...) and lacks rejection
handling, risking unhandled promise rejections; update the call site to handle
failures by either awaiting the promise inside an async function with try/catch
or by chaining a .catch(...) to tabService.createTab(window.id, tab.profileId,
targetSpaceId, undefined, { url: tab.url, position: tab.position + 0.5 }) and
log or surface the error, ensuring normalizePositions(window.id, targetSpaceId)
runs only on success (or handle recovery on failure) so failures are handled
deterministically.

In `@src/main/services/tab-service/index.ts`:
- Around line 54-61: The init guard tabServiceInitialized is set before running
startup steps so any thrown error will leave the guard true and block future
retries; change the flow in the initializer (the function that contains
tabService.loadPinnedTabs(), tabService.startBackgroundTasks(),
tabPersistenceService.start(), tabIPC.initialize(), initTabSync()) to run those
startup calls first (optionally wrapped in a try/catch), and only set
tabServiceInitialized = true after all calls complete successfully (or set it
inside the try after successful completion and keep it false on error so
subsequent initializeTabService() attempts can retry).

In `@src/main/services/tab-service/layout/tab-layout.ts`:
- Around line 273-302: The registerNode() method attaches 'tab-added',
'tab-removed' and 'destroyed' listeners to TabLayoutNode but
removeNodeWithoutDestroy() does not detach them, so nodes moved to another
layout still trigger updates on this layout; fix by storing the bound handlers
(e.g. in a small Map keyed by node.id or properties on the node) when calling
node.on in registerNode(), and in removeNodeWithoutDestroy() (and any other
unregister path around the 320-333 area) call node.off('tab-added', handler),
node.off('tab-removed', handler) and node.off('destroyed', handler) to fully
remove those listeners and prevent stale tabToNode updates and spurious
'layout-node-destroyed' emits.

In `@src/main/services/tab-service/layout/tab-positioner.ts`:
- Around line 47-55: The midpoint calculation uses the possibly-stale
tab.position; instead, derive the baseline from the sorted array: locate the
index via sorted.findIndex and use sorted[index].position as the current
position baseline (or fall back to tab.position if index === -1), then compute
the midpoint with sorted[index + 1].position; update the early-return branch and
the final return to reference sorted[index].position rather than tab.position so
insert positions are computed from the sorted list.

In `@src/main/services/tab-service/tab-service.ts`:
- Around line 1021-1025: The active-changed listener currently always calls
updateTabVisibility(wId, sId) which ignores layout.visibility and causes tabs in
hidden layouts to be force-shown; fix by making the visibility check explicit:
either have the layout.on("active-changed", ...) handler only call
updateTabVisibility when layout.visible is true, or modify updateTabVisibility
to early-return when the originating layout (or its visible flag) is false so
activateTab() and pinned/STAW activations in non-current spaces do not reveal
tabs until the user actually switches spaces; apply the same guard to the other
active-changed handlers (the similar block around the activateTab/pinned logic)
so hidden layouts never trigger UI updates.
- Around line 904-960: When removing/destroying the source node in
moveTabToSpace, ensure the source layout selects a replacement active node if
the moved tab was the currently focused tab: after the removal/destroy logic in
moveTabToSpace, check if sourceLayout.getFocusedTab()?.id === tab.id and if so
call a layout API to pick/activate a replacement (e.g.
sourceLayout.selectReplacementActiveNode() or
sourceLayout.focusNextAvailableTab()); alternatively call
sourceLayout.removeFocusedTab() immediately for that specific sourceLayout and
then invoke its replacement-selection routine so the visible window isn’t left
blank. Ensure you use the existing symbols moveTabToSpace, sourceLayout,
getNodeForTab, getFocusedTab and removeFocusedTab to locate where to add this
behavior.
- Around line 1493-1533: handleNewTabRequested currently always uses
sourceTab.spaceId when creating the new tab, which can be stale for pinned/STAW
tabs; instead query the target window's active space and use that for the new
tab's spaceId. Update the function to get the target window object for windowId
(e.g., via browserWindowsController.get(windowId) or similar API), read its
active/current space id (e.g., targetWindow.getCurrentSpace() or
targetWindow.activeSpaceId), and pass that space id into createTabInternal
(falling back to sourceTab.spaceId if no active space is available); also ensure
when creating a popup you still call popupWindow.setCurrentSpace(...) using the
resolved active space where appropriate.
- Around line 597-621: createPinnedTabFromTab currently creates a PinnedTab but
never propagates the existing layout node, so clicks later rely on stale
locations; update createPinnedTabFromTab to copy the live tab's layout node into
pinnedTab.layoutNode (from the source Tab) and call
propagatePinnedTabNode(pinnedTab, /* appropriate args if required */) the same
way clickPinnedTab does, ensuring the new pinned tab is registered in the layout
before saving/emit events (refer to createPinnedTabFromTab, clickPinnedTab,
pinnedTab.layoutNode, and propagatePinnedTabNode to locate the spots to modify).

In
`@src/renderer/src/components/browser-ui/browser-sidebar/_components/tab-group.tsx`:
- Around line 126-129: The context-menu click handler calls
flow.tabService.showContextMenu(tab.id) without verifying tab.id; add a guard in
that handler (the anonymous React.MouseEvent handler around
flow.tabService.showContextMenu) to return early if tab.id is null/undefined (or
otherwise invalid) before invoking flow.tabService.showContextMenu, mirroring
adjacent handlers that check the id so the service never receives an invalid id.

---

Outside diff comments:
In `@src/main/services/tab-service/core/web-context-menu.ts`:
- Around line 40-58: The createNewTab closure currently captures the initial
window variable so actions like "Open Link in New Tab" keep using the old
BrowserWindow; modify createNewTab in createWebContextMenu to resolve the target
window at click time by calling tab.getWindow() (or the Tab instance method that
returns its current BrowserWindow) and fall back to the passed overrideWindow or
the original window only if getWindow() is null; ensure you null-check the
resolved window and use its currentSpaceId (or tab.spaceId) before calling
tabService.createTab and tabService.activateTab so the new tab opens in the
tab's current BrowserWindow/space.

---

Duplicate comments:
In `@docs/tab-layout-refactor-plan.md`:
- Line 5: Several fenced code blocks use bare triple backticks (```) which
trigger MD040; update each triple-backtick fence in this document to include an
explicit language identifier (for example ```text or ```typescript) so Markdown
linter recognizes the language. Locate every instance of the plain ``` fence
(including the ones noted in the review) and replace it with an appropriate
token for the block content (e.g., ```text for plain output snippets,
```typescript for TS examples) to silence MD040 and improve syntax highlighting.

In `@src/renderer/src/components/providers/pinned-tabs-provider.tsx`:
- Around line 79-80: The unpinToTabList callback drops the optional position
argument from the context contract; update the unpinToTabList signature to
accept the optional position (e.g., unpinToTabList(pinnedTabId: string,
position?: number)) and forward it to the underlying service by calling
flow.tabService.unpinToTabList(pinnedTabId, position) so caller-specified unpin
placement is preserved.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 094c1105-a791-4e93-96ca-e55e5650b3b8

📥 Commits

Reviewing files that changed from the base of the PR and between 55b0bb6 and 0c3c29d.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (91)
  • docs/tab-layout-refactor-plan.md
  • docs/tab-service-architecture.md
  • docs/tab-service-migration-checklist.md
  • package.json
  • src/main/app/basic-auth.ts
  • src/main/app/urls.ts
  • src/main/browser.ts
  • src/main/controllers/app-menu-controller/index.ts
  • src/main/controllers/app-menu-controller/menu/helpers.ts
  • src/main/controllers/app-menu-controller/menu/items/file.ts
  • src/main/controllers/app-menu-controller/menu/items/tabs.ts
  • src/main/controllers/handoff-controller/index.ts
  • src/main/controllers/index.ts
  • src/main/controllers/loaded-profiles-controller/index.ts
  • src/main/controllers/pinned-tabs-controller/index.ts
  • src/main/controllers/quit-controller/handlers/before-quit.ts
  • src/main/controllers/sessions-controller/protocols/_protocols/flow-internal/active-favicon.ts
  • src/main/controllers/tabs-controller/bounds.ts
  • src/main/controllers/tabs-controller/index.ts
  • src/main/controllers/tabs-controller/recently-closed-manager.ts
  • src/main/controllers/tabs-controller/recently-closed.ts
  • src/main/controllers/tabs-controller/tab-groups/glance.ts
  • src/main/controllers/tabs-controller/tab-groups/index.ts
  • src/main/controllers/tabs-controller/tab-groups/split.ts
  • src/main/controllers/tabs-controller/tab-layout.ts
  • src/main/controllers/tabs-controller/tab-lifecycle.ts
  • src/main/controllers/tabs-controller/tab-sync.ts
  • src/main/controllers/tabs-controller/tab.ts
  • src/main/controllers/windows-controller/layer-manager/index.ts
  • src/main/controllers/windows-controller/types/browser.ts
  • src/main/ipc/app/new-tab.ts
  • src/main/ipc/browser/find-in-page.ts
  • src/main/ipc/browser/history.ts
  • src/main/ipc/browser/navigation.ts
  • src/main/ipc/browser/pinned-tabs.ts
  • src/main/ipc/browser/prompts/page.ts
  • src/main/ipc/browser/tabs.ts
  • src/main/ipc/index.ts
  • src/main/ipc/webauthn/conditional.ts
  • src/main/modules/flags.ts
  • src/main/saving/db/schema.ts
  • src/main/saving/tabs/index.ts
  • src/main/saving/tabs/restore.ts
  • src/main/saving/tabs/serialization.ts
  • src/main/services/tab-service/AGENTS.md
  • src/main/services/tab-service/core/pinned-tab.ts
  • src/main/services/tab-service/core/recently-closed-manager.ts
  • src/main/services/tab-service/core/save-image-as.ts
  • src/main/services/tab-service/core/tab-context-menus.ts
  • src/main/services/tab-service/core/tab-layout-node.ts
  • src/main/services/tab-service/core/tab.ts
  • src/main/services/tab-service/core/web-context-menu.ts
  • src/main/services/tab-service/design.md
  • src/main/services/tab-service/index.ts
  • src/main/services/tab-service/ipc/preload-api.ts
  • src/main/services/tab-service/ipc/tab-ipc.ts
  • src/main/services/tab-service/layout/tab-layout.ts
  • src/main/services/tab-service/layout/tab-positioner.ts
  • src/main/services/tab-service/persistence/pinned-tab-persistence.ts
  • src/main/services/tab-service/persistence/tab-persistence-service.ts
  • src/main/services/tab-service/tab-lifecycle-timer.ts
  • src/main/services/tab-service/tab-service.ts
  • src/main/services/tab-service/tab-sync.ts
  • src/preload/index.ts
  • src/renderer/src/components/browser-ui/browser-content.tsx
  • src/renderer/src/components/browser-ui/browser-sidebar/_components/bottom/bottom-extras-menu.tsx
  • src/renderer/src/components/browser-ui/browser-sidebar/_components/bottom/space-switcher.tsx
  • src/renderer/src/components/browser-ui/browser-sidebar/_components/browser-action-list.tsx
  • src/renderer/src/components/browser-ui/browser-sidebar/_components/pin-grid/normal/pin-grid.tsx
  • src/renderer/src/components/browser-ui/browser-sidebar/_components/pin-grid/pinned-tab-button.tsx
  • src/renderer/src/components/browser-ui/browser-sidebar/_components/pin-grid/slot-machine/main.tsx
  • src/renderer/src/components/browser-ui/browser-sidebar/_components/site-controls/extensions.tsx
  • src/renderer/src/components/browser-ui/browser-sidebar/_components/space-pages-carousel.tsx
  • src/renderer/src/components/browser-ui/browser-sidebar/_components/tab-drop-target.tsx
  • src/renderer/src/components/browser-ui/browser-sidebar/_components/tab-group.tsx
  • src/renderer/src/components/browser-ui/main.tsx
  • src/renderer/src/components/browser-ui/target-url-indicator.tsx
  • src/renderer/src/components/omnibox/main.tsx
  • src/renderer/src/components/providers/pinned-tabs-provider.tsx
  • src/renderer/src/components/providers/tab-service-provider.tsx
  • src/renderer/src/components/providers/tabs-provider.tsx
  • src/renderer/src/components/settings/sections/general/update-card.tsx
  • src/renderer/src/lib/omnibox-new/suggestors/open-tabs.ts
  • src/renderer/src/lib/omnibox/data-providers/open-tabs.ts
  • src/renderer/src/lib/omnibox/omnibox.ts
  • src/renderer/src/routes/history/page.tsx
  • src/shared/flow/flow.ts
  • src/shared/flow/interfaces/browser/pinned-tabs.ts
  • src/shared/flow/interfaces/browser/tab-service.ts
  • src/shared/flow/interfaces/browser/tabs.ts
  • src/shared/types/tab-service.ts
💤 Files with no reviewable changes (19)
  • src/main/controllers/tabs-controller/tab-layout.ts
  • src/main/saving/tabs/index.ts
  • src/main/services/tab-service/core/save-image-as.ts
  • src/main/ipc/browser/tabs.ts
  • src/shared/flow/interfaces/browser/pinned-tabs.ts
  • src/main/controllers/tabs-controller/tab-lifecycle.ts
  • src/main/controllers/tabs-controller/tab-sync.ts
  • src/main/saving/tabs/serialization.ts
  • src/main/controllers/tabs-controller/bounds.ts
  • src/main/controllers/tabs-controller/recently-closed-manager.ts
  • src/main/controllers/tabs-controller/tab-groups/split.ts
  • src/main/controllers/tabs-controller/tab-groups/glance.ts
  • src/main/controllers/pinned-tabs-controller/index.ts
  • src/main/controllers/tabs-controller/tab.ts
  • src/shared/flow/interfaces/browser/tabs.ts
  • src/main/ipc/browser/pinned-tabs.ts
  • src/main/controllers/tabs-controller/tab-groups/index.ts
  • src/main/controllers/tabs-controller/recently-closed.ts
  • src/main/controllers/tabs-controller/index.ts

Comment on lines +146 to +147
const targetWindow = window || browserWindowsController.getWindows()[0];
if (!targetWindow) throw new Error("No window available");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid cross-profile fallback when windowId is missing.

Falling back to the first global window can open a profile’s extension tab in a different profile window/session. Resolve a window scoped to profileId (or create one) before calling createTab.

Suggested fix
-        const targetWindow = window || browserWindowsController.getWindows()[0];
+        const targetWindow =
+          window ?? browserWindowsController.getWindows().find((candidate) => candidate.profileId === profileId);
         if (!targetWindow) throw new Error("No window available");
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const targetWindow = window || browserWindowsController.getWindows()[0];
if (!targetWindow) throw new Error("No window available");
const targetWindow =
window ?? browserWindowsController.getWindows().find((candidate) => candidate.profileId === profileId);
if (!targetWindow) throw new Error("No window available");
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/controllers/loaded-profiles-controller/index.ts` around lines 146 -
147, The code currently falls back to browserWindowsController.getWindows()[0]
when window is missing, which may open tabs in the wrong profile; instead
resolve or create a window scoped to the given profileId before calling
createTab: lookup an existing window for profileId via
browserWindowsController.getWindows().find(w => w.profileId === profileId) (or
use a browserWindowsController.getWindowForProfile(profileId) helper), and if
none exists create a new profile-scoped window before assigning targetWindow
(replace use of the global window || browserWindowsController.getWindows()[0]
with this profile-scoped resolution) so createTab is always invoked in the
correct profile context.

Comment on lines +118 to 138
function restoreLayoutNodes(persistedNodes: PersistedTabLayoutNodeData[], uniqueIdToTabId: Map<string, number>): void {
for (const nodeData of persistedNodes) {
const tabIds: number[] = [];
for (const uniqueId of groupData.tabUniqueIds) {
for (const uniqueId of nodeData.tabUniqueIds) {
const tabId = uniqueIdToTabId.get(uniqueId);
if (tabId !== undefined) {
tabIds.push(tabId);
}
}

if (tabIds.length < 2) {
// Tab groups need at least 2 tabs
try {
await tabPersistenceManager.removeTabGroup(groupData.groupId);
} catch (error) {
console.error("Failed to remove stale tab group:", error);
}
tabPersistenceService.removeLayoutNode(nodeData.id);
continue;
}

try {
const group = tabsController.createTabGroup(groupData.mode, tabIds as [number, ...number[]], groupData.groupId);
// Get the window from the first tab
const firstTab = tabService.getTabById(tabIds[0]);
if (!firstTab) continue;

// Restore glance front tab
if (groupData.mode === "glance" && groupData.glanceFrontTabUniqueId) {
const frontTabId = uniqueIdToTabId.get(groupData.glanceFrontTabUniqueId);
if (frontTabId !== undefined && group instanceof GlanceTabGroup) {
group.setFrontTab(frontTabId);
}
}
} catch (error) {
console.error("Failed to restore tab group:", error);
}
tabService.createLayoutNode(firstTab.getWindow().id, nodeData.mode, tabIds);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Restore frontTabUniqueId when recreating layout nodes.

restoreLayoutNodes maps tabUniqueIds but never restores frontTabUniqueId, so persisted front-tab state is lost on session restore.

Suggested direction
 function restoreLayoutNodes(persistedNodes: PersistedTabLayoutNodeData[], uniqueIdToTabId: Map<string, number>): void {
   for (const nodeData of persistedNodes) {
@@
-    tabService.createLayoutNode(firstTab.getWindow().id, nodeData.mode, tabIds);
+    const frontTabId =
+      nodeData.frontTabUniqueId !== undefined ? uniqueIdToTabId.get(nodeData.frontTabUniqueId) : undefined;
+    tabService.createLayoutNode(firstTab.getWindow().id, nodeData.mode, tabIds, frontTabId);
   }
 }

(If createLayoutNode doesn’t accept frontTabId yet, its signature should be extended so restore can preserve this field.)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/saving/tabs/restore.ts` around lines 118 - 138, restoreLayoutNodes
currently translates nodeData.tabUniqueIds to tabIds but drops
nodeData.frontTabUniqueId, losing front-tab state; map nodeData.frontTabUniqueId
to a frontTabId via uniqueIdToTabId (similar to the tabIds loop) and pass that
frontTabId into tabService.createLayoutNode (or extend createLayoutNode's
signature to accept an optional frontTabId) so recreated layout nodes preserve
the front tab; update restoreLayoutNodes (and createLayoutNode
implementation/signature) to accept and use the restored frontTabId when
available.

Comment on lines +13 to +18
```
tabService → TabService instance (central orchestrator)
tabPersistenceService → TabPersistenceService (save/restore to SQLite)
tabIPC → TabIPC (renderer communication)
initializeTabService() → called at app startup after DB is ready
```

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add language identifiers to fenced code blocks

The unlabeled fenced blocks trigger markdownlint MD040 warnings.

Proposed fix
-```
+```text
 tabService        → TabService instance (central orchestrator)
 tabPersistenceService → TabPersistenceService (save/restore to SQLite)
 tabIPC            → TabIPC (renderer communication)
 initializeTabService() → called at app startup after DB is ready

- +text
Tab property changes → Tab emits "updated" → wireTabEvents handler →

  1. tab.notifyExtensionsOfChanges() (emits "tab-updated" on webContents → extensions library)
  2. tabService.emitContentChange() (→ TabIPC debounced → renderer)

-```
+```text
tab-service/
  index.ts                    Entry point, singleton creation, exports
  tab-service.ts              TabService class (~1800 lines)
  tab-sync.ts                 Cross-window sync, placeholders
  tab-lifecycle-timer.ts      Auto-sleep/archive timer
  core/
    tab.ts                    Tab class (~850 lines)
    tab-layout-node.ts        TabLayoutNode class (~320 lines)
    pinned-tab.ts             PinnedTab class (~140 lines)
    recently-closed-manager.ts Recently closed tab tracking
    tab-context-menus.ts      Sidebar context menu
    web-context-menu.ts       Page context menu
    save-image-as.ts          Save image dialog
  layout/
    tab-layout.ts             TabLayout class (~380 lines)
    tab-positioner.ts         Position math (~70 lines)
  ipc/
    tab-ipc.ts                IPC handlers + debounced emission (~570 lines)
    preload-api.ts            Preload bridge API types
  persistence/
    tab-persistence-service.ts Autosave/restore tabs
    pinned-tab-persistence.ts  Pinned tab DB operations
</details>


Also applies to: 51-55, 115-138

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 markdownlint-cli2 (0.22.1)</summary>

[warning] 13-13: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

</details>

</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @src/main/services/tab-service/AGENTS.md around lines 13 - 18, The markdown
file contains unlabeled fenced code blocks that trigger MD040; update each
unlabeled triple-backtick block in AGENTS.md to use a language identifier (use
"text") — specifically the blocks containing the short key/value list
(tabService → TabService ...), the "Tab property changes → Tab emits 'updated' →
wireTabEvents handler →" block, and the directory listing block starting with
"tab-service/" (and any other unlabeled fences such as those noted around 51-55
and 115-138) so they read text ... instead of plain ```; keep the block
contents unchanged.


</details>

<!-- fingerprinting:phantom:poseidon:hawk -->

<!-- This is an auto-generated comment by CodeRabbit -->

Comment on lines +39 to +45
| Module | File | Purpose |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- |
| `tab-sync.ts` | Tab sync across windows. Screenshot placeholders, `moveTabToWindowIfNeeded`, `ensureNodeInLayout`. Pinned tabs always sync; normal tabs sync when setting enabled. |
| `tab-lifecycle-timer.ts` | 10s interval that auto-sleeps/archives inactive tabs. |
| `tab-context-menus.ts` | Sidebar tab right-click menu (Copy URL, Mute, Duplicate, Move To, Close, Pin/Unpin, Reopen). |
| `web-context-menu.ts` | Web page right-click context menu. |
| `save-image-as.ts` | "Save Image As" dialog helper. |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix malformed Supporting Modules table rows

The rows currently have fewer columns than the header, which breaks markdown table parsing.

Proposed fix
 | Module                   | File                                                                                                                                                               | Purpose |
 | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- |
-| `tab-sync.ts`            | Tab sync across windows. Screenshot placeholders, `moveTabToWindowIfNeeded`, `ensureNodeInLayout`. Pinned tabs always sync; normal tabs sync when setting enabled. |
-| `tab-lifecycle-timer.ts` | 10s interval that auto-sleeps/archives inactive tabs.                                                                                                              |
-| `tab-context-menus.ts`   | Sidebar tab right-click menu (Copy URL, Mute, Duplicate, Move To, Close, Pin/Unpin, Reopen).                                                                       |
-| `web-context-menu.ts`    | Web page right-click context menu.                                                                                                                                 |
-| `save-image-as.ts`       | "Save Image As" dialog helper.                                                                                                                                     |
+| `tab-sync.ts`            | `tab-sync.ts`                                                                                                                                                       | Tab sync across windows. Screenshot placeholders, `moveTabToWindowIfNeeded`, `ensureNodeInLayout`. Pinned tabs always sync; normal tabs sync when setting enabled. |
+| `tab-lifecycle-timer.ts` | `tab-lifecycle-timer.ts`                                                                                                                                            | 10s interval that auto-sleeps/archives inactive tabs. |
+| `tab-context-menus.ts`   | `core/tab-context-menus.ts`                                                                                                                                         | Sidebar tab right-click menu (Copy URL, Mute, Duplicate, Move To, Close, Pin/Unpin, Reopen). |
+| `web-context-menu.ts`    | `core/web-context-menu.ts`                                                                                                                                          | Web page right-click context menu. |
+| `save-image-as.ts`       | `core/save-image-as.ts`                                                                                                                                             | "Save Image As" dialog helper. |
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 41-41: Table column count
Expected: 3; Actual: 2; Too few cells, row will be missing data

(MD056, table-column-count)


[warning] 42-42: Table column count
Expected: 3; Actual: 2; Too few cells, row will be missing data

(MD056, table-column-count)


[warning] 43-43: Table column count
Expected: 3; Actual: 2; Too few cells, row will be missing data

(MD056, table-column-count)


[warning] 44-44: Table column count
Expected: 3; Actual: 2; Too few cells, row will be missing data

(MD056, table-column-count)


[warning] 45-45: Table column count
Expected: 3; Actual: 2; Too few cells, row will be missing data

(MD056, table-column-count)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/services/tab-service/AGENTS.md` around lines 39 - 45, The Supporting
Modules table rows in AGENTS.md are malformed because several rows only provide
two cells while the header requires three (Module | File | Purpose); update each
listed row (e.g., entries for tab-sync.ts, tab-lifecycle-timer.ts,
tab-context-menus.ts, web-context-menu.ts, save-image-as.ts) so they contain
three pipe-separated columns matching the header (Module, File, Purpose) and
ensure the separator row (| --- | --- | --- |) is present; keep the module name
(e.g., `tab-sync.ts`) in the first column, the file path/details in the second,
and the purpose text in the third for each row.

Comment on lines +93 to +100
void tabService
.createTab(window.id, tab.profileId, targetSpaceId, undefined, {
url: tab.url,
position: tab.position + 0.5
})
.then(() => {
tabService.normalizePositions(window.id, targetSpaceId);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify this promise chain currently lacks rejection handling.
rg -nP 'void\\s+tabService\\s*\\.\\s*createTab\\([\\s\\S]*?\\)\\s*\\.then\\(' src/main/services/tab-service/core/tab-context-menus.ts
rg -nP '\\.catch\\(' src/main/services/tab-service/core/tab-context-menus.ts

Repository: MultiboxLabs/flow-browser

Length of output: 215


Add rejection handling to duplicate-tab creation to avoid unhandled promise rejections (File: src/main/services/tab-service/core/tab-context-menus.ts, Lines: 93-100)

The duplicate-tab path chains .then(...) without any failure handling; if tabService.createTab(...) rejects, it can result in an unhandled promise rejection.

💡 Proposed fix
           void tabService
             .createTab(window.id, tab.profileId, targetSpaceId, undefined, {
               url: tab.url,
               position: tab.position + 0.5
             })
             .then(() => {
               tabService.normalizePositions(window.id, targetSpaceId);
-            });
+            })
+            .catch((error) => {
+              console.error("Failed to duplicate tab:", error);
+            });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
void tabService
.createTab(window.id, tab.profileId, targetSpaceId, undefined, {
url: tab.url,
position: tab.position + 0.5
})
.then(() => {
tabService.normalizePositions(window.id, targetSpaceId);
});
void tabService
.createTab(window.id, tab.profileId, targetSpaceId, undefined, {
url: tab.url,
position: tab.position + 0.5
})
.then(() => {
tabService.normalizePositions(window.id, targetSpaceId);
})
.catch((error) => {
console.error("Failed to duplicate tab:", error);
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/services/tab-service/core/tab-context-menus.ts` around lines 93 -
100, The duplicate-tab creation call to tabService.createTab(...) currently only
attaches .then(...) and lacks rejection handling, risking unhandled promise
rejections; update the call site to handle failures by either awaiting the
promise inside an async function with try/catch or by chaining a .catch(...) to
tabService.createTab(window.id, tab.profileId, targetSpaceId, undefined, { url:
tab.url, position: tab.position + 0.5 }) and log or surface the error, ensuring
normalizePositions(window.id, targetSpaceId) runs only on success (or handle
recovery on failure) so failures are handled deterministically.

Comment on lines +597 to +621
public createPinnedTabFromTab(tabId: number, position?: number): PinnedTab | null {
const tab = this.tabs.get(tabId);
if (!tab || !tab.url) return null;

const maxPos = this.getMaxPinnedTabPosition(tab.profileId);
const finalPosition = position ?? maxPos + 1;

const pinnedTab = PinnedTab.create(tab.profileId, tab.url, tab.faviconURL, finalPosition);

this.pinnedTabs.set(pinnedTab.uniqueId, pinnedTab);

// Mark the tab as owned by this pinned tab (ephemeral — remove stale DB record)
tab.owner = { kind: "pinned", pinnedTabId: pinnedTab.uniqueId };
this.emitContentChange(tab.getWindow().id, tab.id);

// Associate the tab
pinnedTab.associate(tab.spaceId, tab.id);

this.wirePinnedTabEvents(pinnedTab);
this.normalizePinnedTabPositions(tab.profileId);
this.pinnedTabDb.save(pinnedTab);
this.emit("pinned-tab-changed");
this.emitStructuralChange(tab.getWindow().id);

return pinnedTab;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Propagate the existing node when pinning a live tab.

clickPinnedTab() sets pinnedTab.layoutNode and calls propagatePinnedTabNode(...), but createPinnedTabFromTab() never does. A freshly pinned existing tab therefore only exists in its current layout; the first click from another space/window misses targetLayout.getNodeForTab(...) and falls back to activating the old location instead of the requested one.

💡 Proposed fix
     // Associate the tab
     pinnedTab.associate(tab.spaceId, tab.id);
+    const layout = this.getLayout(tab.getWindow().id, tab.spaceId);
+    const node = layout?.getNodeForTab(tab.id);
+    if (node) {
+      pinnedTab.layoutNode = node;
+      this.propagatePinnedTabNode(node, pinnedTab.profileId);
+    }
 
     this.wirePinnedTabEvents(pinnedTab);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public createPinnedTabFromTab(tabId: number, position?: number): PinnedTab | null {
const tab = this.tabs.get(tabId);
if (!tab || !tab.url) return null;
const maxPos = this.getMaxPinnedTabPosition(tab.profileId);
const finalPosition = position ?? maxPos + 1;
const pinnedTab = PinnedTab.create(tab.profileId, tab.url, tab.faviconURL, finalPosition);
this.pinnedTabs.set(pinnedTab.uniqueId, pinnedTab);
// Mark the tab as owned by this pinned tab (ephemeral — remove stale DB record)
tab.owner = { kind: "pinned", pinnedTabId: pinnedTab.uniqueId };
this.emitContentChange(tab.getWindow().id, tab.id);
// Associate the tab
pinnedTab.associate(tab.spaceId, tab.id);
this.wirePinnedTabEvents(pinnedTab);
this.normalizePinnedTabPositions(tab.profileId);
this.pinnedTabDb.save(pinnedTab);
this.emit("pinned-tab-changed");
this.emitStructuralChange(tab.getWindow().id);
return pinnedTab;
public createPinnedTabFromTab(tabId: number, position?: number): PinnedTab | null {
const tab = this.tabs.get(tabId);
if (!tab || !tab.url) return null;
const maxPos = this.getMaxPinnedTabPosition(tab.profileId);
const finalPosition = position ?? maxPos + 1;
const pinnedTab = PinnedTab.create(tab.profileId, tab.url, tab.faviconURL, finalPosition);
this.pinnedTabs.set(pinnedTab.uniqueId, pinnedTab);
// Mark the tab as owned by this pinned tab (ephemeral — remove stale DB record)
tab.owner = { kind: "pinned", pinnedTabId: pinnedTab.uniqueId };
this.emitContentChange(tab.getWindow().id, tab.id);
// Associate the tab
pinnedTab.associate(tab.spaceId, tab.id);
const layout = this.getLayout(tab.getWindow().id, tab.spaceId);
const node = layout?.getNodeForTab(tab.id);
if (node) {
pinnedTab.layoutNode = node;
this.propagatePinnedTabNode(node, pinnedTab.profileId);
}
this.wirePinnedTabEvents(pinnedTab);
this.normalizePinnedTabPositions(tab.profileId);
this.pinnedTabDb.save(pinnedTab);
this.emit("pinned-tab-changed");
this.emitStructuralChange(tab.getWindow().id);
return pinnedTab;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/services/tab-service/tab-service.ts` around lines 597 - 621,
createPinnedTabFromTab currently creates a PinnedTab but never propagates the
existing layout node, so clicks later rely on stale locations; update
createPinnedTabFromTab to copy the live tab's layout node into
pinnedTab.layoutNode (from the source Tab) and call
propagatePinnedTabNode(pinnedTab, /* appropriate args if required */) the same
way clickPinnedTab does, ensuring the new pinned tab is registered in the layout
before saving/emit events (refer to createPinnedTabFromTab, clickPinnedTab,
pinnedTab.layoutNode, and propagatePinnedTabNode to locate the spots to modify).

Comment on lines +904 to +960
public moveTabToSpace(tabId: number, spaceId: string, newPosition?: number): void {
const tab = this.tabs.get(tabId);
if (!tab) return;

const sourceSpaceId = tab.spaceId;
if (sourceSpaceId === spaceId) return;

const windowId = tab.getWindow().id;
const sourceLayout = this.getLayout(windowId, sourceSpaceId);
const targetLayout = this.getOrCreateLayout(windowId, spaceId);

// Hide the tab before moving (it's leaving the source space)
if (tab.visible) {
tab.visible = false;
tab.layer?.setVisible(false);
}

// Remove from source layout
if (sourceLayout) {
const node = sourceLayout.getNodeForTab(tab.id);
if (node) {
// Destroy single node from source, or remove tab from multi-node.
// The "destroyed" event cascades cleanup to all member layouts.
if (node.mode === "single") {
sourceLayout.destroyNode(node.id);
} else {
node.removeTab(tab);
}
}
}

// Update tab's space
tab.setSpace(spaceId);

// Create a new node in the target layout
targetLayout.createSingleNode(tab);

// Clear focused tab references to this tab in the source space across ALL layouts.
for (const layout of this.layouts.values()) {
if (layout.spaceId === sourceSpaceId && layout.getFocusedTab()?.id === tab.id) {
layout.removeFocusedTab();
}
}

if (newPosition !== undefined) {
tab.updateStateProperty("position", newPosition);
}

// Normalize both spaces
this.positioner.normalizePositions(this.getTabsInWindowSpace(windowId, spaceId));
this.positioner.normalizePositions(this.getTabsInWindowSpace(windowId, sourceSpaceId));

// Notify extensions that indices shifted (tab moved between spaces)
this.notifyIndexChanges(windowId, tab.profileId);

// Notify renderer that source space changed (tab removed)
this.emitStructuralChange(windowId);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Select a replacement active node when moving the active tab out of the current space.

This removes or destroys the source node, but never asks sourceLayout to pick the next tab. If the moved tab was the active tab in the currently visible space, activeNode becomes null and the window is left blank until some later action activates another node.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/services/tab-service/tab-service.ts` around lines 904 - 960, When
removing/destroying the source node in moveTabToSpace, ensure the source layout
selects a replacement active node if the moved tab was the currently focused
tab: after the removal/destroy logic in moveTabToSpace, check if
sourceLayout.getFocusedTab()?.id === tab.id and if so call a layout API to
pick/activate a replacement (e.g. sourceLayout.selectReplacementActiveNode() or
sourceLayout.focusNextAvailableTab()); alternatively call
sourceLayout.removeFocusedTab() immediately for that specific sourceLayout and
then invoke its replacement-selection routine so the visible window isn’t left
blank. Ensure you use the existing symbols moveTabToSpace, sourceLayout,
getNodeForTab, getFocusedTab and removeFocusedTab to locate where to add this
behavior.

Comment on lines +1021 to +1025
// Forward events
layout.on("active-changed", (wId, sId) => {
this.updateTabVisibility(wId, sId);
this.emit("active-changed", wId, sId);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't reshow tabs when a hidden layout changes active node.

The active-changed listener always calls updateTabVisibility(), and updateTabVisibility() ignores layout.visible. That means activateTab() on a non-current space—or pinned/STAW activation in another layout—can make a hidden space's tabs visible immediately, even though the comment in activateTab() says those activations should stay invisible until the user switches spaces.

💡 Proposed fix
       layout.on("active-changed", (wId, sId) => {
-        this.updateTabVisibility(wId, sId);
+        if (layout.visible) {
+          this.updateTabVisibility(wId, sId);
+        }
         this.emit("active-changed", wId, sId);
       });

Also applies to: 1096-1141

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/services/tab-service/tab-service.ts` around lines 1021 - 1025, The
active-changed listener currently always calls updateTabVisibility(wId, sId)
which ignores layout.visibility and causes tabs in hidden layouts to be
force-shown; fix by making the visibility check explicit: either have the
layout.on("active-changed", ...) handler only call updateTabVisibility when
layout.visible is true, or modify updateTabVisibility to early-return when the
originating layout (or its visible flag) is false so activateTab() and
pinned/STAW activations in non-current spaces do not reveal tabs until the user
actually switches spaces; apply the same guard to the other active-changed
handlers (the similar block around the activateTab/pinned logic) so hidden
layouts never trigger UI updates.

Comment on lines +1493 to +1533
private handleNewTabRequested(
sourceTab: Tab,
url: string,
disposition: "new-window" | "foreground-tab" | "background-tab" | "default" | "other",
constructorOptions: Electron.WebContentsViewConstructorOptions | undefined,
handlerDetails: Electron.HandlerDetails | undefined,
options: { noLoadURL?: boolean }
): void {
let windowId = sourceTab.getWindow().id;

if (disposition === "new-window") {
const parsedFeatures: Record<string, string | number> = {};
if (handlerDetails?.features) {
for (const feature of handlerDetails.features.split(",")) {
const [key, value] = feature.trim().split("=");
if (key && value) {
parsedFeatures[key] = Number.isNaN(+value) ? value : +value;
}
}
}

const popupWindow = browserWindowsController.instantCreate("popup", {
...(parsedFeatures.width ? { width: +parsedFeatures.width } : {}),
...(parsedFeatures.height ? { height: +parsedFeatures.height } : {}),
...(parsedFeatures.left ? { x: +parsedFeatures.left } : {}),
...(parsedFeatures.top ? { y: +parsedFeatures.top } : {})
});
windowId = popupWindow.id;
popupWindow.setCurrentSpace(sourceTab.spaceId);
}

const insertPosition = disposition !== "new-window" ? sourceTab.position + 0.5 : undefined;

const isBackground = disposition === "background-tab";
const newTab = this.createTabInternal(windowId, sourceTab.profileId, sourceTab.spaceId, undefined, {
url,
noLoadURL: options.noLoadURL,
webContentsViewOptions: constructorOptions,
position: insertPosition,
makeActive: !isBackground
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Open renderer-initiated tabs in the window's active space, not the pinned tab's creation space.

handleNewTabRequested() always uses sourceTab.spaceId. For pinned/STAW tabs, that can be a stale creation space while the view is currently rendered in another window/space, so window.open / foreground-tab / background-tab requests can be created off-screen in the wrong layout.

💡 Proposed fix
-    const newTab = this.createTabInternal(windowId, sourceTab.profileId, sourceTab.spaceId, undefined, {
+    const targetWindow = browserWindowsController.getWindowById(windowId);
+    const targetSpaceId = targetWindow?.currentSpaceId ?? sourceTab.spaceId;
+    const newTab = this.createTabInternal(windowId, sourceTab.profileId, targetSpaceId, undefined, {
       url,
       noLoadURL: options.noLoadURL,
       webContentsViewOptions: constructorOptions,
       position: insertPosition,
       makeActive: !isBackground
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/services/tab-service/tab-service.ts` around lines 1493 - 1533,
handleNewTabRequested currently always uses sourceTab.spaceId when creating the
new tab, which can be stale for pinned/STAW tabs; instead query the target
window's active space and use that for the new tab's spaceId. Update the
function to get the target window object for windowId (e.g., via
browserWindowsController.get(windowId) or similar API), read its active/current
space id (e.g., targetWindow.getCurrentSpace() or targetWindow.activeSpaceId),
and pass that space id into createTabInternal (falling back to sourceTab.spaceId
if no active space is available); also ensure when creating a popup you still
call popupWindow.setCurrentSpace(...) using the resolved active space where
appropriate.

Comment on lines 126 to 129
(e: React.MouseEvent) => {
e.preventDefault();
flow.tabs.showContextMenu(tab.id);
flow.tabService.showContextMenu(tab.id);
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Guard context-menu calls when tab.id is missing.

This handler can pass an invalid tab id, while adjacent handlers already guard for missing ids.

Suggested fix
     const handleContextMenu = useCallback(
       (e: React.MouseEvent) => {
         e.preventDefault();
+        if (!tab.id) return;
         flow.tabService.showContextMenu(tab.id);
       },
       [tab.id]
     );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
(e: React.MouseEvent) => {
e.preventDefault();
flow.tabs.showContextMenu(tab.id);
flow.tabService.showContextMenu(tab.id);
},
(e: React.MouseEvent) => {
e.preventDefault();
if (!tab.id) return;
flow.tabService.showContextMenu(tab.id);
},
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/renderer/src/components/browser-ui/browser-sidebar/_components/tab-group.tsx`
around lines 126 - 129, The context-menu click handler calls
flow.tabService.showContextMenu(tab.id) without verifying tab.id; add a guard in
that handler (the anonymous React.MouseEvent handler around
flow.tabService.showContextMenu) to return early if tab.id is null/undefined (or
otherwise invalid) before invoking flow.tabService.showContextMenu, mirroring
adjacent handlers that check the id so the service never receives an invalid id.

greptile-apps[bot]

This comment was marked as resolved.

iamEvanYT and others added 6 commits May 26, 2026 14:53
…ider

- Remove unused TabServiceProvider (duplicate IPC subscriptions)
- Rename TabGroup → TabLayoutNodeView / TabLayoutNode across provider and sidebar
- Rename drag payload type tab-group → tab-layout-node
- Fix stale PersistedTabData comment in shared/types/tabs.ts

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 new potential issues.

View 19 additional findings in Devin Review.

Open in Devin Review

Comment on lines +276 to +277
if (this.view && this.layer) {
window.layerManager?.push(this.layer);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Stale LayerManager reference in Layer after cross-window tab move

When setWindow moves a tab to a new window, it pops the existing Layer from the old window's LayerManager and pushes it directly to the new window's LayerManager without recreating the Layer object. The Layer retains its internal this.manager reference pointing to the old window's LayerManager. This causes layer.focus() (at src/main/controllers/windows-controller/layer-manager/index.ts:52) to call this.manager.clearPendingFocusReallocation() on the wrong manager, and setVisible(false) to trigger reallocateFocus() on the wrong manager. The old code in the deleted tab.ts:750-776 always recreated the Layer with new Layer(window.layerManager, ...) when the window changed. This affects all cross-window tab moves (STAW focus-move, pinned tab clicks across windows, tab relocation from closing windows).

Prompt for agents
In Tab.setWindow() (core/tab.ts:264-302), when a Layer already exists and the tab moves to a new window, the Layer object is reused without updating its internal `manager` reference. The old code (deleted tab.ts) recreated the Layer: `this.layer = new Layer(window.layerManager, this.view, zIndexes[layerType], focusPriorities[layerType], createModalTo(layerType))`. The fix should recreate the Layer for the new window's LayerManager when the window changes, similar to how `setLayerType` (tab.ts:308-319) already does it correctly. Specifically, in the `if (this.view && this.layer)` branch, instead of just pushing the old layer, pop it from the old manager, create a new Layer with `window.layerManager`, and push that. Preserve the layer's visibility state across the recreation.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +258 to +265
const tab = await this.tabService.createTab(window.id, space.profileId, spaceId, undefined, {
url: url || undefined,
typedNavigation: typedFromAddressBar === true
});

if (isForeground) {
this.tabService.activateTab(tab);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 IPC newTab handler always activates tab regardless of isForeground parameter

createTab() calls createTabInternal() which auto-activates the new tab by default (options.makeActive !== false at tab-service.ts:240). The IPC handler at tab-ipc.ts:258 calls createTab() without passing makeActive: false when isForeground is false, so every tab created through the tab-service:new-tab IPC channel is immediately activated. The if (isForeground) { this.tabService.activateTab(tab) } check on line 263 becomes redundant. The old handler (ipc/browser/tabs.ts:282-291) correctly relied on createTab NOT auto-activating. This breaks background tab creation — e.g., the slot machine Easter egg creates tabs with flow.tabService.newTab(url, false) expecting them to stay in the background, but they all get activated sequentially.

Suggested change
const tab = await this.tabService.createTab(window.id, space.profileId, spaceId, undefined, {
url: url || undefined,
typedNavigation: typedFromAddressBar === true
});
if (isForeground) {
this.tabService.activateTab(tab);
}
const tab = await this.tabService.createTab(window.id, space.profileId, spaceId, undefined, {
url: url || undefined,
typedNavigation: typedFromAddressBar === true,
makeActive: isForeground !== false
});
if (isForeground) {
this.tabService.activateTab(tab);
}
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +54 to 56
tabService.on("focused-tab-changed", (windowId, spaceId) => {
this.syncFocusedWindowHandoffActivity(windowId, spaceId, "active-tab-changed");
});

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Handoff activity not updated on space switch (macOS regression)

The old HandoffController listened to tabsController.on("current-space-changed") to update the macOS Handoff URL when the user switched spaces. The new code replaces this with tabService.on("focused-tab-changed") (src/main/controllers/handoff-controller/index.ts:54), but setCurrentWindowSpace (tab-service.ts:1233) does NOT emit focused-tab-changed when switching to a space that already has an established focused tab. The emitStructuralChange and updateTabVisibility calls in setCurrentWindowSpace don't produce a focused-tab-changed or active-changed event if the new space's layout already had its active node set from a previous visit. This means the macOS Handoff activity URL becomes stale when switching between spaces.

Prompt for agents
The HandoffController at src/main/controllers/handoff-controller/index.ts:54-56 listens only to "focused-tab-changed" but TabService.setCurrentWindowSpace (tab-service.ts:1233) does not emit this event when switching to a space that already has a focused tab established. The fix should either: (1) Add a "current-space-changed" event to TabServiceEvents and emit it from setCurrentWindowSpace, then listen for it in HandoffController, or (2) Emit "focused-tab-changed" from setCurrentWindowSpace when the visible layout changes and has a focused tab. Option 1 is simpler and matches the old architecture more closely.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@iamEvanYT iamEvanYT added the enhancement New feature or request label May 26, 2026 — with Devin.ai Integration
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant