diff --git a/vscode/package.json b/vscode/package.json
index 6fe12e8..f0438c4 100644
--- a/vscode/package.json
+++ b/vscode/package.json
@@ -2,7 +2,7 @@
"publisher": "clurdra",
"name": "superpowers-vscode-clurdra",
"displayName": "Superpowers-clurdra",
- "version": "0.2.129",
+ "version": "0.2.130",
"packageManager": "pnpm@10.27.0",
"description": "Superpowers specs and plans Kanban explorer",
"author": "clurdra",
diff --git a/vscode/webview-ui/src/components/IssueDetailPanel.tsx b/vscode/webview-ui/src/components/IssueDetailPanel.tsx
index bf34eb3..5b66fa6 100644
--- a/vscode/webview-ui/src/components/IssueDetailPanel.tsx
+++ b/vscode/webview-ui/src/components/IssueDetailPanel.tsx
@@ -8,6 +8,7 @@ import { isIssueMine, issueOwnerLogin } from '../lib/issueOwnership'
import { toReadOnlySchema } from '../lib/readOnlySchema'
import { COLUMN_LABELS, COLUMN_ORDER } from '../types'
import { PropertyGrid } from './property-grid'
+import { Tooltip } from './ui/tooltip'
/** 跨机绝对路径只比 basename(同名 .json)映射到本机 profiles 列表。 */
function matchProfileOption(
@@ -616,77 +617,86 @@ export function IssueDetailPanel({
)
: null}
-
+
+
+
{/* Close/delete write to gitea by number — hide for youtrack cards to
avoid acting on a gitea issue of the same number. */}
{issue.source !== 'youtrack' && !readOnly && (
<>
{issue.handoffAttachmentId && me !== undefined && (issue.assignees ?? []).includes(me) && (
-
+
+
+
)}
{!issue.handoffAttachmentId && issue.column !== 'done' && ((issue.assignees ?? []).length === 0 || (me !== undefined && (issue.assignees ?? []).includes(me))) && (
-
+
+
+
)}
{issue.handoffAttachmentId && !(me !== undefined && (issue.assignees ?? []).includes(me)) && (
-
- 待接管
-
+
+
+ 待接管
+
+
)}
{(issue.column === 'in-progress' || issue.column === 'review') && (
+
+
+
+ )}
+
- )}
-
-
+
+
+
+
>
)}
diff --git a/vscode/webview-ui/src/components/ui/tooltip.tsx b/vscode/webview-ui/src/components/ui/tooltip.tsx
new file mode 100644
index 0000000..28e54d2
--- /dev/null
+++ b/vscode/webview-ui/src/components/ui/tooltip.tsx
@@ -0,0 +1,96 @@
+import type { ReactElement, ReactNode } from 'react'
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { createPortal } from 'react-dom'
+import { cn } from '@/lib/utils'
+
+/** 悬停多久才弹,避免扫过一排按钮时闪一串气泡。 */
+const SHOW_DELAY_MS = 300
+/** 气泡与触发元素之间的间距。 */
+const GAP_PX = 6
+/** 贴边留白,气泡不顶到 webview 边缘。 */
+const GUTTER_PX = 8
+
+interface TooltipProps {
+ /** 气泡文案;为空则不弹。 */
+ label: ReactNode
+ children: ReactNode
+ className?: string
+}
+
+/**
+ * 悬停提示气泡。
+ *
+ * 原生 `title` 在 webview 里延迟长、样式是系统的,暗色主题下很突兀,
+ * 这里自己渲染:portal 到 body,避开 overflow 容器裁剪;位置优先在上方,
+ * 上方放不下就翻到下方,左右钳在视口内。
+ */
+export function Tooltip({ label, children, className }: TooltipProps): ReactElement {
+ const [pos, setPos] = useState<{ top: number, left: number } | null>(null)
+ const wrapRef = useRef(null)
+ const bubbleRef = useRef(null)
+ const timerRef = useRef | undefined>(undefined)
+
+ const hide = useCallback((): void => {
+ clearTimeout(timerRef.current)
+ setPos(null)
+ }, [])
+
+ const show = useCallback((): void => {
+ if (!label)
+ return
+ clearTimeout(timerRef.current)
+ timerRef.current = setTimeout(() => {
+ const rect = wrapRef.current?.getBoundingClientRect()
+ if (rect)
+ setPos({ top: rect.top, left: rect.left + rect.width / 2 })
+ }, SHOW_DELAY_MS)
+ }, [label])
+
+ useEffect(() => () => clearTimeout(timerRef.current), [])
+
+ // 气泡渲染后才知道自身尺寸,这时才能定最终位置:默认在上方,
+ // 上方空间不够就翻到触发元素下方;水平居中后钳进视口。
+ useEffect(() => {
+ if (!pos)
+ return
+ const bubble = bubbleRef.current
+ const rect = wrapRef.current?.getBoundingClientRect()
+ if (!bubble || !rect)
+ return
+ const { width, height } = bubble.getBoundingClientRect()
+ const above = rect.top - height - GAP_PX
+ const top = above >= GUTTER_PX ? above : rect.bottom + GAP_PX
+ let left = rect.left + rect.width / 2 - width / 2
+ left = Math.max(GUTTER_PX, Math.min(left, window.innerWidth - GUTTER_PX - width))
+ bubble.style.top = `${top}px`
+ bubble.style.left = `${left}px`
+ bubble.style.visibility = 'visible'
+ }, [pos])
+
+ return (
+ <>
+
+ {children}
+
+ {pos && label && createPortal(
+
+ {label}
+
,
+ document.body,
+ )}
+ >
+ )
+}