Skip to content

feat: implement pinned tabs#200

Draft
iamEvanYT wants to merge 33 commits intomainfrom
opencode/pinned-tabs-b24e
Draft

feat: implement pinned tabs#200
iamEvanYT wants to merge 33 commits intomainfrom
opencode/pinned-tabs-b24e

Conversation

@iamEvanYT
Copy link
Member

@iamEvanYT iamEvanYT commented Feb 28, 2026

Summary

Implements a full Pinned Tabs feature — persistent URL shortcuts displayed in the sidebar pin grid. Users can drag browser tabs onto the grid to pin them, click to activate/create associated tabs, double-click to navigate back to the default URL, and reorder via drag-and-drop.

Changes

Database & Types

  • Added pinned_tabs table to drizzle schema (unique_id, profile_id, default_url, favicon_url, position) with profile index
  • Generated SQL migration (drizzle/0001_tough_scream.sql)
  • Defined shared PersistedPinnedTabData and PinnedTabData types (runtime associatedTabId for live tab tracking)

Main Process

  • PinnedTabsController — In-memory cache backed by SQLite with CRUD operations, runtime bidirectional tab associations, position normalization, and change listener system
  • IPC handlersget-data, create-from-tab, click, double-click, remove, reorder, show-context-menu (with native context menu for Unpin/Reset to Default/Copy URL)
  • Wired tab destruction events to automatically break pinned tab associations
  • Load pinned tabs from DB at app startup

Preload / Flow API

  • FlowPinnedTabsAPI interface with full method set + onChanged callback
  • Implemented in preload with wrapAPI(pinnedTabsAPI, "browser") permissions
  • Added pinnedTabs to global flow type declaration

Renderer

  • PinnedTabsProvider — React context managing per-profile pinned tab state, listens for changes via flow.pinnedTabs.onChanged, exposes all actions
  • Mounted inside TabsProvider in browser UI component tree
  • PinGrid — Rewired to use real data from usePinnedTabs(), accepts tab-group drags to create pinned tabs, shows empty state with drag hint
  • PinnedTabButton — Full implementation with click/double-click/context-menu, drag-and-drop reordering (left/right edge detection), active state gradient borders via useFaviconColors, dragging opacity
  • Tab list filtering — Browser tabs associated with pinned tabs are hidden from the sidebar tab list (filtered at SpaceContentPage level), since they're already visible in the pin grid
  • Slot machine easter egg — Fixed by extracting a self-contained SlotButton component that replicates the visual style without pinned-tab-specific logic

UI Polish

  • Removed cursor-pointer from pinned tab buttons — this is a desktop app, not a website

Key Design Decisions

  • Separate table rather than a column on tabs — pinned tabs are conceptually distinct persistent bookmarks, not browser tabs
  • Runtime-only associations — no persisted link between pinned tab and browser tab; associations are in-memory maps that start empty on each app launch. On restart, all tabs reappear in the sidebar list until re-associated via click/drag
  • Immediate writes — unlike the dirty-tracking batch flush pattern for regular tabs, pinned tab changes are infrequent enough to write immediately
  • Per-profile scoping — pinned tabs belong to a profile, not a space, matching the existing pin grid carousel behavior

Verification

  • bun run typecheck passes cleanly (both typecheck:node and typecheck:web, zero errors)

Open with Devin

Add a new pinned_tabs table to the drizzle schema with columns for
unique_id (PK), profile_id, default_url, favicon_url, and position.
Includes an index on profile_id for efficient per-profile queries.
Generated the corresponding SQL migration (0001_tough_scream.sql).
Define PersistedPinnedTabData (DB-backed fields: uniqueId, profileId,
defaultUrl, faviconUrl, position) and PinnedTabData (extends with
runtime associatedTabId for live browser tab association).
Full controller with in-memory cache backed by SQLite, supporting:
- CRUD operations (create, remove, reorder, get by profile)
- Runtime tab associations via bidirectional maps (pinned <-> browser tab)
- Position normalization to keep ordering consistent
- Change listener system for broadcasting updates to renderer
- Singleton export for use across main process
Add pinned-tabs IPC handlers for: get-data, create-from-tab, click,
double-click, remove, reorder, and show-context-menu. Register the
handler module in the IPC index. Wire tab destruction events to break
pinned tab associations. Load pinned tabs from DB at app startup.
Add FlowPinnedTabsAPI interface with methods for getData, createFromTab,
click, doubleClick, remove, reorder, showContextMenu, and onChange.
Implement the API in the preload script mapping to IPC channels, wrap
with browser permissions, and add pinnedTabs to the global flow type.
Create PinnedTabsProvider with usePinnedTabs hook that manages per-profile
pinned tab state, listens for changes via flow.pinnedTabs.onChange, and
exposes actions (create, click, doubleClick, remove, reorder, contextMenu).
Mount the provider inside TabsProvider in the browser UI component tree.
Rewrite PinGrid to use real data from usePinnedTabs(), accept tab-group
drags to create pinned tabs, and show an empty state with drag hint.
Rewrite PinnedTabButton to accept PinnedTabData with click/double-click/
context-menu handlers, drag-and-drop reordering between pinned tabs using
left/right edge detection, active state gradient borders via
useFaviconColors, and dragging opacity feedback.
Replace the broken PinnedTabButton reference with a self-contained
SlotButton component that replicates the visual appearance (favicon
with gradient border on active/winner state) without requiring any
pinned-tab-specific props like drag-and-drop or context menus.
@github-actions
Copy link
Contributor

github-actions bot commented Feb 28, 2026

Build artifacts for all platforms are ready! 🚀

Download the artifacts for:

One-line installer (Unstable):
bunx flow-debug-build --open 22533891881

(execution 22533891881 / attempt 1)

@iamEvanYT iamEvanYT marked this pull request as draft February 28, 2026 20:11
This is a desktop app, not a website — pointer cursor is inappropriate
for interactive elements.
When a browser tab is associated with a pinned tab, it's already visible
in the pin grid — showing it again in the tab list below is redundant.
Filter out associated tabs at the SpaceContentPage level before rendering
tab groups. Associations are runtime-only (in-memory maps, never persisted),
so on app restart all tabs reappear in the list until re-associated.
@greptile-apps
Copy link

greptile-apps bot commented Feb 28, 2026

Greptile Summary

Implements a comprehensive pinned tabs feature with persistent URL shortcuts displayed in the sidebar. Users can drag browser tabs to pin them, click to activate/create associated tabs, double-click to navigate back to the default URL, and reorder via drag-and-drop.

Architecture:

  • Database layer — New pinned_tabs table with profile-scoped storage, indexed by profile_id
  • Controller layerPinnedTabsController manages in-memory cache backed by SQLite with immediate writes, runtime bidirectional tab associations (not persisted), position normalization, and change notification system
  • IPC layer — Full handler set for CRUD operations, click/double-click handlers, context menu with Unpin/Reset/Copy URL actions, integrates with tab lifecycle events to clean up associations
  • Renderer layer — React context provider, drag-and-drop with edge detection for reordering, favicon color extraction for gradient borders, responsive grid layout with empty state

Key implementation details:

  • Pinned tabs are conceptually distinct from browser tabs — separate table rather than a column flag
  • Runtime associations start empty on launch; tabs remain in sidebar until re-associated via click or drag
  • Ephemeral tabs (associated with pinned tabs) are filtered from sidebar at provider level but preserve focusedTabId for active state detection
  • Space switching automatically moves pinned-tab-associated ephemeral tabs to the new space (pinned tabs are per-profile, not per-space)
  • Position normalization after each mutation ensures contiguous integer positions (0, 1, 2...)
  • Slot machine easter egg fixed by extracting self-contained SlotButton component

Confidence Score: 5/5

  • This PR is safe to merge with high confidence — well-architected feature with clean separation of concerns, proper type safety, and comprehensive functionality
  • Score reflects excellent code quality across all layers: database schema properly indexed, controller implements robust state management with immediate writes and change notifications, IPC handlers cover all user actions with proper cleanup, React components follow established patterns with proper context integration, ephemeral tab filtering correctly implemented, and typecheck passes cleanly for both node and web
  • No files require special attention — implementation is consistent and thorough throughout

Important Files Changed

Filename Overview
src/main/controllers/pinned-tabs-controller/index.ts Implements in-memory pinned tabs controller with SQLite persistence, runtime browser tab associations, position normalization, and change notification system
src/main/ipc/browser/pinned-tabs.ts IPC handlers for all pinned tab operations, includes space switching logic to keep associated ephemeral tabs visible, context menu with native Electron menu
src/main/saving/db/schema.ts Added pinned_tabs table with profile_id index, clean schema matches types and migration
src/renderer/src/components/providers/pinned-tabs-provider.tsx React context provider managing pinned tabs state, listens to changes via IPC, exposes all operations through hooks
src/renderer/src/components/browser-ui/browser-sidebar/_components/pin-grid/pinned-tab-button.tsx Pinned tab button with favicon color extraction for gradient borders, drag-and-drop reordering with edge detection, handles click/double-click/context-menu
src/renderer/src/components/browser-ui/browser-sidebar/_components/pin-grid/normal/pin-grid.tsx Pin grid component with responsive column layout, accepts tab drags to create pins, shows empty state with drag hint

Sequence Diagram

sequenceDiagram
    participant User
    participant PinGrid as Pin Grid (React)
    participant Provider as PinnedTabsProvider
    participant Preload as Flow API
    participant IPC as IPC Handler
    participant Controller as PinnedTabsController
    participant TabsCtrl as TabsController

    User->>PinGrid: Click pinned tab button
    PinGrid->>Provider: click(pinnedTabId)
    Provider->>Preload: flow.pinnedTabs.click()
    Preload->>IPC: invoke("pinned-tabs:click")
    
    alt Associated tab exists
        IPC->>Controller: getAssociatedTabId()
        Controller-->>IPC: tabId
        IPC->>TabsCtrl: getTabById(tabId)
        TabsCtrl-->>IPC: tab
        IPC->>TabsCtrl: setActiveTab(tab)
        IPC-->>Preload: true
    else No associated tab
        IPC->>Controller: getById(pinnedTabId)
        Controller-->>IPC: pinnedTab
        IPC->>TabsCtrl: createTab(defaultUrl, ephemeral: true)
        TabsCtrl-->>IPC: newTab
        IPC->>Controller: associateTab(pinnedTabId, newTab.id)
        Controller->>Controller: Update associations map
        Controller->>Controller: notifyChanged()
        IPC->>TabsCtrl: setActiveTab(newTab)
        IPC-->>Preload: true
    end
    
    Preload-->>Provider: true
    Note over Controller: Change notification triggers
    Controller->>IPC: onChanged callback
    IPC->>Preload: send("pinned-tabs:on-changed")
    Preload->>Provider: onChanged(allByProfile)
    Provider->>Provider: Update state
    Provider->>PinGrid: Re-render with updated data
Loading

Last reviewed commit: 4e7d8cd

greptile-apps[bot]

This comment was marked as resolved.

The isActive check was comparing associatedTabId === focusedTabId, but
both are null on launch (no associations, no focused tab), causing
null === null to be true for every pinned tab. Now requires
associatedTabId to be non-null before comparing.
Add an 'ephemeral' flag to Tab that prevents persistence to the database.
Ephemeral tabs:
- Are never written to the tabs table (markDirty is skipped)
- Are never saved to recently closed on destroy
- Are filtered out of tab data sent to the renderer (no sidebar flash)
- Do not survive app restart

When a tab is dragged to pin, makeTabEphemeral() removes its existing
DB row and triggers a structural refresh so the renderer drops it from
the tab list. When a pinned tab creates a new associated tab via click,
it's created with ephemeral: true from the start.
…border works

getWindowTabsData() was building the space/profile sets from visibleTabs
(ephemeral-filtered), causing focusedTabs/activeTabs maps to miss spaces
whose only tab is ephemeral. The pin grid's active border check then
failed because focusedTabId was undefined for the current space.

Fix: iterate over all tabs (including ephemeral) for windowSpaces and
windowProfiles, while keeping visibleTabs only for the tabDatas array
sent to the sidebar tab list.
…TabData

TabsFocusedIdContext was set to focusedTab?.id, where focusedTab was
resolved by looking up the ID in tabById (built from tabsData.tabs).
Since ephemeral tabs are excluded from tabsData.tabs, any ephemeral
focused tab resolved to null, making focusedTabId null in the renderer.

The pin grid compares associatedTabId against focusedTabId to show the
active border — with focusedTabId always null for ephemeral tabs, the
border never appeared.

Fix: read the raw numeric ID directly from tabsData.focusedTabIds for
the current space. This bypasses the TabData resolution and preserves
ephemeral tab IDs that the pin grid needs for active state detection.
Add the ability to drag pinned tab cards from the pin grid back down to
the tab list area to unpin them. This:

1. Adds makeTabPersistent() to TabsController — the reverse of
   makeTabEphemeral(). Re-serializes the tab and marks it dirty so
   it gets persisted again, then triggers a structural change so the
   renderer adds it back to the sidebar tab list.

2. Adds pinned-tabs:unpin-to-tab-list IPC handler that removes the
   pinned tab and makes the associated browser tab persistent again.

3. Wires through the full API chain: Flow interface, preload, and
   PinnedTabsProvider all expose unpinToTabList().

4. Updates TabDropTarget to accept 'pinned-tab' type drags in addition
   to 'tab-group' drags. Dropping a pinned tab on the tab list area
   calls unpinToTabList() which removes the pin and shows the tab.
Two issues fixed:

1. Pinned tabs could only be dropped on TabDropTarget (bottom of list).
   Now TabGroup components also accept 'pinned-tab' type drags, so users
   can drop between existing tabs at any position.

2. Unpinned tabs always appeared at the top of the list. The
   unpinToTabList call now accepts an optional position parameter that
   flows through the full stack (Flow API -> preload -> IPC -> controller).
   The IPC handler sets the tab's position before making it persistent
   and calls normalizePositions to keep the list contiguous.
…rs, and unpin without association

- Add focusedTabUrls to WindowTabsData so the renderer can display the
  address bar URL for ephemeral (pinned-tab) focused tabs
- Escalate ephemeral tab content changes to structural refreshes so
  focusedTabUrls stays current
- Show subtle background highlight on pin grid when dragging a tab over
  existing pins
- Increase empty state border/text opacity from /10 and /30 to /20 and /50
- Create a new persistent tab when unpinning a pinned tab that has no
  associated browser tab (drag to tab list)
…ed tabs

- Context menu 'Unpin' now destroys the associated ephemeral tab so it
  doesn't remain alive but invisible in the background
- Switch PinnedTabButton from onClick to onMouseDown for activation,
  matching SidebarTab behavior and eliminating ~100-200ms perceived delay
@iamEvanYT
Copy link
Member Author

@greptile review

…ndicators

Reuses the existing closest-edge drop indicators from pinned tab reordering
so that browser tabs dragged onto the pin grid can be inserted at a specific
position rather than always appending to the end. The full chain (controller,
IPC, Flow API, provider, UI) now supports an optional position parameter on
createFromTab.
greptile-apps[bot]

This comment was marked as resolved.

Position 0 creates a tie with the existing first item, making sort order
unpredictable. Using -0.5 ensures correct ordering before normalizePositions
reassigns contiguous integers.
@iamEvanYT
Copy link
Member Author

@greptile review

Adds PinGridCarousel — a passive follower carousel that renders one PinGrid
page per space and smooth-scrolls in sync when the active space changes.
The carousel uses overflow-x-hidden (no user swipe) since space switching
is driven by the tab list carousel and space switcher.

PinGrid now accepts profileId as a prop instead of reading from context,
allowing each carousel page to render pins for its space's profile.
Groups consecutive same-profile spaces into one carousel page so switching
between them causes no scroll animation. Uses a spaceIndex-to-pageIndex
mapping to resolve the correct page for the current space.
…thin the same profile

Pinned tabs are per-profile, but associated ephemeral tabs were stuck in the
space where they were created. This caused two issues:
1. Clicking a pinned tab in Space B that was opened in Space A would force
   the user back to Space A instead of showing the tab in Space B.
2. Switching between spaces of the same profile would hide the associated tab.

Fix by moving ephemeral pinned-tab-associated tabs to the current space:
- On space change: auto-move all ephemeral tabs for the profile to the new space
- On click/double-click: move the associated tab to the current space before activating
…nsform-based animation

The scroll-based carousel used overflow-x: hidden, which per CSS spec forces
overflow-y to compute as auto, creating a vertical scroll context that
prevented the nested SidebarScrollArea from scrolling.

Replaced with CSS transform + transition on an inner wrapper. The outer
container uses overflow: clip which doesn't create a scroll context,
allowing the SidebarScrollArea's max-h-40 overflow to work correctly.
… div and using flex layout

The <div ref={dropRef}> wrapping SidebarScrollArea broke CSS height resolution
for the Radix Viewport's height: 100%. Move drop target ref onto the grid div
inside the scroll area instead, and switch SidebarScrollArea from height: 100%
to flex-1 min-h-0 so the Viewport height is determined by flex layout (which
honors max-height) rather than percentage resolution (which requires an explicit
height property on the parent).
Copy link
Contributor

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

Choose a reason for hiding this comment

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

Devin Review found 2 potential issues.

View 7 additional findings in Devin Review.

Open in Devin Review

…tract, and code quality

- Send focusedTabLoadingStates/focusedTabFullscreenStates in WindowTabsData so
  loading indicator, reload/stop button, and fullscreen guard work for ephemeral
  pinned-tab tabs
- Skip ephemeral tabs in the archive/sleep interval to prevent silent destruction
- Return { ...pinnedTab, associatedTabId } from create-from-tab IPC to match the
  PinnedTabData contract
- Fall back to async spacesController.get() when getCached() misses in the
  current-space-changed handler
- Convert PinGridCarousel render-time side effect to useEffect
…rkaround maps

Include ephemeral tabs (pinned-tab-associated) in the main tabs array with
an ephemeral flag on TabData, letting the renderer filter them from the
sidebar tab list. This removes the three workaround types/maps
(focusedTabUrls, focusedTabLoadingStates, focusedTabFullscreenStates) and
their plumbing, simplifying the data flow for ephemeral tab state.
@iamEvanYT
Copy link
Member Author

@greptile review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant