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: ``,