diff --git a/packages/app/src/components/titlebar-tab-nav.tsx b/packages/app/src/components/titlebar-tab-nav.tsx
index 937da5aaf8..5f4dbe8807 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.
@@ -59,6 +60,32 @@ export function TabNavItem(props: {
})
const title = createMemo(() => props.session()?.title ?? props.fallbackTitle)
+ 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()
+
const measureTitleOverflow = () => {
if (!titleEl || editing()) {
setTitleOverflowing(false)
@@ -173,7 +200,7 @@ export function TabNavItem(props: {
onCleanup(cleanup)
})
- return (
+ const tab = (
{
tabRoot = el
@@ -282,6 +309,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..174579a640
--- /dev/null
+++ b/packages/app/src/components/titlebar-tab-popover.css
@@ -0,0 +1,133 @@
+[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.96 to 1.0. */
+ animation: sessionTabPopoverIn 150ms cubic-bezier(0.16, 1, 0.3, 1);
+
+ &:focus-visible,
+ &:focus-within {
+ outline: none;
+ }
+
+ /* Exit: subtle fade + scale down, using the same ease-out curve as entry. */
+ &[data-closed] {
+ animation: sessionTabPopoverOut 100ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
+ }
+
+ [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.96);
+ }
+ to {
+ opacity: 1;
+ transform: scale(1);
+ }
+}
+
+@keyframes sessionTabPopoverOut {
+ from {
+ opacity: 1;
+ transform: scale(1);
+ }
+ to {
+ opacity: 0;
+ transform: scale(0.96);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ [data-component="session-tab-popover"],
+ [data-component="session-tab-popover"][data-closed] {
+ 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..10d4cdef89
--- /dev/null
+++ b/packages/app/src/components/titlebar-tab-popover.tsx
@@ -0,0 +1,99 @@
+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: begin closing immediately (a brief exit animation plays).
+const CLOSE_DELAY = 0
+// After a preview closes, hovering a neighbouring tab within this window skips
+// the open delay — mirrors the tooltip's skipDelayDuration so moving across
+// tabs doesn't re-wait the full delay each time.
+const SKIP_WINDOW = 300
+let lastClosedAt = 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
+
+ // Kobalte reads openDelay lazily when the pointer enters the trigger, so this
+ // resolves the skip window per-hover.
+ const resolveOpenDelay = () => (Date.now() - lastClosedAt < SKIP_WINDOW ? 0 : OPEN_DELAY)
+ const handleOpenChange = (open: boolean) => {
+ if (!open) lastClosedAt = Date.now()
+ props.onOpenChange(open)
+ }
+
+ 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: ``,