From 0e483e61815e6114fe25b70ffccffd3f5cefa28c Mon Sep 17 00:00:00 2001
From: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Date: Tue, 30 Jun 2026 22:01:16 +0200
Subject: [PATCH] feat(desktop): session tab hover preview popover
Add a hover preview popover for session tabs in the titlebar showing the
project name, full session title, workspace path, git branch, and (when
more than one server is connected) the server name.
- New TabPreviewPopover built on Kobalte HoverCard with v2 tokens
(--v2-elevation-floating, --v2-background-bg-base) and its own
data-component/data-slot CSS, matching the tooltip-v2 pattern.
- 200ms open delay, instant close, fade+scale entrance per the design.
- Add folder/branch icons to the v2 icon registry (16x16, 1px stroke)
and render them via the shared Icon sprite instead of inline SVG.
- Wire data from existing stores in TabNavItem; popover is suppressed
while editing, dragging, or pressing a tab.
---
.../app/src/components/titlebar-tab-nav.tsx | 54 +++++++-
.../src/components/titlebar-tab-popover.css | 121 ++++++++++++++++++
.../src/components/titlebar-tab-popover.tsx | 83 ++++++++++++
packages/ui/src/v2/components/icon.tsx | 8 ++
4 files changed, 263 insertions(+), 3 deletions(-)
create mode 100644 packages/app/src/components/titlebar-tab-popover.css
create mode 100644 packages/app/src/components/titlebar-tab-popover.tsx
diff --git a/packages/app/src/components/titlebar-tab-nav.tsx b/packages/app/src/components/titlebar-tab-nav.tsx
index 499c50bf63..cdd93bfb4b 100644
--- a/packages/app/src/components/titlebar-tab-nav.tsx
+++ b/packages/app/src/components/titlebar-tab-nav.tsx
@@ -5,12 +5,13 @@ import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language"
-import { ServerConnection } from "@/context/server"
-import { projectForSession } from "@/pages/layout/helpers"
+import { ServerConnection, serverName } from "@/context/server"
+import { displayName, projectForSession } from "@/pages/layout/helpers"
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
import { showToast } from "@/utils/toast"
import type { Session } from "@opencode-ai/sdk/v2"
import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
+import { TabPreviewPopover } from "./titlebar-tab-popover"
import "./titlebar-tab-nav.css"
// MouseEvent.button uses 1 for the middle/wheel button.
@@ -57,6 +58,35 @@ export function TabNavItem(props: {
return projectForSession(session, serverCtx()?.projects.list() ?? [])
})
+ const projectName = createMemo(() => {
+ const session = props.session()
+ if (!session) return
+ return displayName(project() ?? { worktree: session.directory })
+ })
+ const previewPath = createMemo(() => {
+ const session = props.session()
+ if (!session) return
+ const home = serverCtx()?.sync.data.path.home
+ return home ? session.directory.replace(home, "~") : session.directory
+ })
+ const branch = createMemo(() => {
+ const session = props.session()
+ if (!session) return
+ return serverCtx()?.sync.child(session.directory, { bootstrap: false })[0].vcs?.branch
+ })
+ // Only label the server when multiple servers are connected.
+ const serverLabel = createMemo(() => {
+ if (global.servers.list().length <= 1) return
+ const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server)
+ return conn ? serverName(conn) : undefined
+ })
+
+ const [popoverOpen, setPopoverOpen] = createSignal(false)
+ const previewBlocked = () => !!props.dragging || editing() || !!props.pressed || !props.session()
+ createEffect(() => {
+ if (previewBlocked()) setPopoverOpen(false)
+ })
+
const measureTitleOverflow = () => {
if (!titleEl || editing()) {
setTitleOverflowing(false)
@@ -171,7 +201,7 @@ export function TabNavItem(props: {
onCleanup(cleanup)
})
- return (
+ const tab = (
{
tabRoot = el
@@ -280,6 +310,24 @@ export function TabNavItem(props: {
)
+
+ return (
+ {
+ if (value && previewBlocked()) return
+ setPopoverOpen(value)
+ }}
+ data={{
+ projectName: projectName(),
+ title: props.session()?.title,
+ path: previewPath(),
+ branch: branch(),
+ serverName: serverLabel(),
+ }}
+ />
+ )
}
export function DraftTabItem(props: {
diff --git a/packages/app/src/components/titlebar-tab-popover.css b/packages/app/src/components/titlebar-tab-popover.css
new file mode 100644
index 0000000000..a80ef1e576
--- /dev/null
+++ b/packages/app/src/components/titlebar-tab-popover.css
@@ -0,0 +1,121 @@
+[data-component="session-tab-popover-trigger"] {
+ display: flex;
+ width: 100%;
+ min-width: 0;
+}
+
+[data-component="session-tab-popover"] {
+ z-index: 50;
+ box-sizing: border-box;
+ width: 256px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 12px;
+
+ background: var(--v2-background-bg-base);
+ border-radius: 6px;
+ box-shadow: var(--v2-elevation-floating);
+
+ color: var(--v2-text-text-base);
+ font-variant-numeric: tabular-nums;
+ user-select: none;
+ pointer-events: none;
+
+ transform-origin: var(--kb-hovercard-content-transform-origin);
+ /* Entrance: fade in while scaling up from 0.95 to 1.0. */
+ animation: sessionTabPopoverIn 150ms cubic-bezier(0.16, 1, 0.3, 1);
+
+ &:focus-visible,
+ &:focus-within {
+ outline: none;
+ }
+
+ /* Exit: hide instantly (no animation). */
+ &[data-closed] {
+ animation: none;
+ }
+
+ [data-slot="header"] {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ width: 100%;
+ }
+
+ [data-slot="project"] {
+ font-weight: 440;
+ font-size: 13px;
+ line-height: 1;
+ letter-spacing: -0.04px;
+ color: var(--v2-text-text-muted);
+ }
+
+ [data-slot="title"] {
+ font-weight: 530;
+ font-size: 13px;
+ line-height: 1.2;
+ letter-spacing: -0.04px;
+ color: var(--v2-text-text-base);
+ overflow-wrap: anywhere;
+ }
+
+ [data-slot="row"] {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ width: 100%;
+ min-width: 0;
+ }
+
+ [data-slot="icon"] {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ width: 16px;
+ height: 16px;
+ color: var(--v2-icon-icon-muted);
+ }
+
+ [data-slot="detail"] {
+ flex: 1 0 0;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-weight: 440;
+ font-size: 13px;
+ line-height: 1;
+ letter-spacing: -0.04px;
+ color: var(--v2-text-text-muted);
+ }
+
+ [data-slot="server"] {
+ font-weight: 440;
+ font-size: 13px;
+ line-height: 1;
+ letter-spacing: -0.04px;
+ color: var(--v2-text-text-muted);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+}
+
+@keyframes sessionTabPopoverIn {
+ from {
+ opacity: 0;
+ transform: scale(0.95);
+ }
+ to {
+ opacity: 1;
+ transform: scale(1);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ [data-component="session-tab-popover"] {
+ animation: none;
+ }
+}
diff --git a/packages/app/src/components/titlebar-tab-popover.tsx b/packages/app/src/components/titlebar-tab-popover.tsx
new file mode 100644
index 0000000000..11c38beee9
--- /dev/null
+++ b/packages/app/src/components/titlebar-tab-popover.tsx
@@ -0,0 +1,83 @@
+import { HoverCard as Kobalte } from "@kobalte/core/hover-card"
+import { Show, type JSXElement } from "solid-js"
+import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
+import "./titlebar-tab-popover.css"
+
+// Initial hover delay before the preview appears, per design.
+const OPEN_DELAY = 200
+// Mouse-out delay: hide instantly.
+const CLOSE_DELAY = 0
+
+export interface TabPreviewData {
+ projectName?: string
+ title?: string
+ path?: string
+ branch?: string
+ serverName?: string
+}
+
+export function TabPreviewPopover(props: {
+ trigger: JSXElement
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ data: TabPreviewData
+}) {
+ let triggerEl: HTMLDivElement | undefined
+
+ return (
+
+
+ {props.trigger}
+
+
+ {
+ // Portalled content lives outside the themed subtree, so mirror the
+ // active theme like the v2 tooltip does.
+ const theme = triggerEl?.closest("[data-theme]")?.getAttribute("data-theme")
+ if (theme) el.setAttribute("data-theme", theme)
+ }}
+ data-component="session-tab-popover"
+ >
+
+
+ {props.data.projectName}
+
+
+ {props.data.title}
+
+
+
+
+
+
+
+
+ {props.data.path}
+
+
+
+
+
+
+
+
+ {props.data.branch}
+
+
+
+
+ {props.data.serverName}
+
+
+
+
+ )
+}
diff --git a/packages/ui/src/v2/components/icon.tsx b/packages/ui/src/v2/components/icon.tsx
index 63486caac1..63e4ba0e45 100644
--- a/packages/ui/src/v2/components/icon.tsx
+++ b/packages/ui/src/v2/components/icon.tsx
@@ -9,6 +9,14 @@ const icons = {
viewBox: "0 0 16 16",
body: ``,
},
+ folder: {
+ viewBox: "0 0 16 16",
+ body: ``,
+ },
+ branch: {
+ viewBox: "0 0 16 16",
+ body: ``,
+ },
"grid-plus": {
viewBox: "0 0 16 16",
body: ``,