Skip to content
This repository was archived by the owner on Aug 17, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 92 additions & 68 deletions packages/opencode/src/cli/cmd/tui/component/ticket-hover-card.tsx
Original file line number Diff line number Diff line change
@@ -1,84 +1,108 @@
// grunt-it: single floating tooltip for hivemind ticket-ref hovers (#233).
// grunt-it: shadcn-svelte HoverCard-style tooltip for hivemind ticket-ref hovers (#233).
//
// Rendered ONCE at the app-root level (NOT as a child of any TicketRef). Reads the
// global hoveredTicketId signal from the Hivemind context; when set, shows the ticket
// detail at a fixed screen anchor (top of screen, right-aligned). When null, renders
// nothing (and consumes no space).
// Rendered ONCE at the app-root level. Reads the global hoveredTicket signal from the
// Hivemind context; when set, shows the ticket detail at a position anchored near the
// trigger's cursor coords (just below + slightly right, with right-edge overflow guard).
//
// Architecture motivation: a per-ref tooltip box rendered inside the same column as
// the ref's text caused a hover-flicker loop in v1 — the tooltip overlapped the text
// region, mouse moved off the text onto the tooltip, onMouseOut fired, tooltip hid,
// mouse was back on text, onMouseOver fired, tooltip showed again. By rendering the
// tooltip at a screen anchor far from any mouse-interactive surface, the hover state
// is stable.
// Sticky-hover semantics: the card also has its own onMouseOver/Out — entering the card
// cancels the trigger's close-timer (so the cursor can traverse the gap between trigger
// and card without dismissing). Leaving the card schedules a close-timer of its own.
// Net result: shadcn HoverCard ergonomics in the terminal.

import { Show } from "solid-js"
import { useTerminalDimensions } from "@opentui/solid"
import { useHivemind } from "../context/hivemind"
import { useTheme } from "../context/theme"

const CARD_WIDTH = 60
const CARD_MAX_HEIGHT = 16

export function TicketHoverCard() {
const hive = useHivemind()
const { theme } = useTheme()
const dims = useTerminalDimensions()

return (
<Show when={hive.hoveredTicketId() !== null}>
<box
position="absolute"
top={1}
right={2}
backgroundColor={theme.backgroundPanel}
border={true}
borderColor={theme.border}
paddingLeft={1}
paddingRight={1}
paddingTop={0}
paddingBottom={0}
zIndex={3000}
maxWidth={70}
>
{(() => {
const id = hive.hoveredTicketId()
if (id === null) return null
const d = hive.ticketDetail(id)
if (d === undefined) {
return <text fg={theme.textMuted}>loading #{id}...</text>
}
if (d === null) {
return <text fg={theme.textMuted}>#{id}: not found</text>
<Show when={hive.hoveredTicket()}>
{(anchor) => {
// Position: just below the trigger cursor, offset right by 2 cols. If that would
// push off the right edge, flip the card so its right edge aligns with the cursor.
const left = () => {
const desired = anchor().anchorX + 1
const overflow = desired + CARD_WIDTH - dims().width
if (overflow > 0) return Math.max(0, anchor().anchorX - CARD_WIDTH)
return desired
}
const top = () => {
const desired = anchor().anchorY + 1
// If near the bottom, flip above the cursor.
if (desired + CARD_MAX_HEIGHT > dims().height) {
return Math.max(0, anchor().anchorY - CARD_MAX_HEIGHT)
}
return (
<>
<text fg={theme.text} wrapMode="word">
<b>
#{d.id} {d.title}
</b>
</text>
<text fg={theme.textMuted}>
<span>{d.status}</span>
{" · "}
<span>{d.priority}</span>
<Show when={d.zone}>
{" · "}
<span>zone={d.zone}</span>
</Show>
<Show when={d.owner}>
{" · "}
<span>owner={d.owner}</span>
</Show>
</text>
<Show when={d.scope}>
<text fg={theme.textMuted} wrapMode="word">
{(d.scope ?? "").slice(0, 220)}
{(d.scope ?? "").length > 220 ? "..." : ""}
</text>
</Show>
<text fg={theme.textMuted}>
<span>click to open in hivemind-ui</span>
</text>
</>
)
})()}
</box>
return desired
}

return (
<box
position="absolute"
left={left()}
top={top()}
backgroundColor={theme.backgroundPanel}
border={true}
borderColor={theme.border}
paddingLeft={1}
paddingRight={1}
paddingTop={0}
paddingBottom={0}
zIndex={3000}
maxWidth={CARD_WIDTH}
onMouseOver={() => hive.setCardHovered(true)}
onMouseOut={() => hive.setCardHovered(false)}
>
{(() => {
const id = anchor().id
const d = hive.ticketDetail(id)
if (d === undefined) {
return <text fg={theme.textMuted}>loading #{id}...</text>
}
if (d === null) {
return <text fg={theme.textMuted}>#{id}: not found</text>
}
return (
<>
<text fg={theme.text} wrapMode="word">
<b>
#{d.id} {d.title}
</b>
</text>
<text fg={theme.textMuted}>
<span>{d.status}</span>
{" · "}
<span>{d.priority}</span>
<Show when={d.zone}>
{" · "}
<span>zone={d.zone}</span>
</Show>
<Show when={d.owner}>
{" · "}
<span>owner={d.owner}</span>
</Show>
</text>
<Show when={d.scope}>
<text fg={theme.textMuted} wrapMode="word">
{(d.scope ?? "").slice(0, 220)}
{(d.scope ?? "").length > 220 ? "..." : ""}
</text>
</Show>
<text fg={theme.textMuted}>
<span>click to open in hivemind-ui</span>
</text>
</>
)
})()}
</box>
)
}}
</Show>
)
}
22 changes: 13 additions & 9 deletions packages/opencode/src/cli/cmd/tui/component/ticket-ref.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
// grunt-it: clickable hivemind ticket-ref (#229) component for assistant chat (#233).
//
// v2 architecture (after v1 flicker bug):
// - This component is just a colored clickable text. NO local tooltip — that was the source
// of the flicker (per-ref tooltip box overlapped its own text, hover→unhover→hover loop).
// - Hover/leave events set a SINGLE global "currentlyHoveredTicket" signal in the Hivemind
// context. Detail fetch + caching also lives there (shared across all refs).
// - The single floating <TicketHoverCard /> reads that signal and renders the detail at a
// fixed screen anchor — no overlap with any text, so no flicker.
// shadcn-svelte HoverCard parity: hover the trigger → after a small open delay the card
// appears near the trigger; cursor can move INTO the card and it stays open; close happens
// after a small leave delay so a brief detour doesn't dismiss it. All the timer + sticky
// behavior lives in the Hivemind context — this component just emits hover-enter / leave +
// the cursor coords for anchoring.
//
// Click opens hivemind-ui at /tasks/<id> in the system browser.

Expand All @@ -16,6 +14,8 @@ import { useHivemind } from "../context/hivemind"

const HIVEMIND_UI_BASE = process.env.HIVEMIND_UI_BASE ?? "http://localhost:5173"

type MouseEventLike = { x: number; y: number }

export function TicketRef(props: { id: number }) {
const { theme } = useTheme()
const hive = useHivemind()
Expand All @@ -24,8 +24,12 @@ export function TicketRef(props: { id: number }) {
<text
fg={theme.markdownLink ?? theme.primary}
attributes={1}
onMouseOver={() => hive.setHoveredTicket(props.id)}
onMouseOut={() => hive.setHoveredTicket(null)}
onMouseOver={(evt: MouseEventLike) => {
hive.triggerHoverEnter(props.id, evt.x, evt.y)
}}
onMouseOut={() => {
hive.triggerHoverLeave()
}}
onMouseUp={() => {
open(`${HIVEMIND_UI_BASE}/tasks/${props.id}`).catch(() => {})
}}
Expand Down
79 changes: 68 additions & 11 deletions packages/opencode/src/cli/cmd/tui/context/hivemind.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -218,16 +218,26 @@ export const { use: useHivemind, provider: HivemindProvider } = createSimpleCont
}

// ----- ticket-hover state (#233) ---------------------------------------------------------
// One global "currently-hovered ticket id" signal. TicketRefs set/clear it on
// enter/leave; the single floating TicketHoverCard reads it and renders the detail.
// No per-ref tooltip boxes overlapping their own text → no flicker.
const [hoveredTicketId, setHoveredTicketId] = createSignal<number | null>(null)
// shadcn-svelte-style HoverCard: card pops up near the trigger after a small open delay,
// STAYS open if the cursor moves into the card (close delay gives time to traverse the gap),
// closes after a small leave delay so a brief detour doesn't dismiss it.
//
// Three signals power this:
// hoveredTicket — { id, anchorX, anchorY } | null. What the card renders + anchors to.
// The card itself also calls setCardHover(true/false) on its own onMouseOver/Out to
// suppress the close timer while the cursor is inside the card body.
type HoverAnchor = { id: number; anchorX: number; anchorY: number }
const [hoveredTicket, setHoveredTicketSignal] = createSignal<HoverAnchor | null>(null)
const ticketCache = new Map<number, TicketDetail | null>()
const ticketInflight = new Map<number, Promise<TicketDetail | null>>()
const [ticketCacheTick, setTicketCacheTick] = createSignal(0)
let openTimer: ReturnType<typeof setTimeout> | undefined
let closeTimer: ReturnType<typeof setTimeout> | undefined
let cardHovered = false
const OPEN_DELAY_MS = 150
const CLOSE_DELAY_MS = 200

function ticketDetail(id: number): TicketDetail | null | undefined {
// Read the tick so consumers re-render when the cache updates.
ticketCacheTick()
return ticketCache.get(id)
}
Expand Down Expand Up @@ -262,10 +272,55 @@ export const { use: useHivemind, provider: HivemindProvider } = createSimpleCont
return p
}

function setHoveredTicket(id: number | null) {
setHoveredTicketId(id)
if (id !== null && !ticketCache.has(id)) {
void fetchTicket(id)
/** TicketRef calls this when mouse enters the trigger. Schedules the card open. */
function triggerHoverEnter(id: number, anchorX: number, anchorY: number) {
if (closeTimer) {
clearTimeout(closeTimer)
closeTimer = undefined
}
// Already showing this ticket? Just keep it open.
const current = hoveredTicket()
if (current?.id === id) return
// Schedule open. If user moves off the trigger before delay elapses, openTimer is
// cleared and nothing renders — no flash for accidental hovers.
if (openTimer) clearTimeout(openTimer)
openTimer = setTimeout(() => {
openTimer = undefined
setHoveredTicketSignal({ id, anchorX, anchorY })
if (!ticketCache.has(id)) void fetchTicket(id)
}, OPEN_DELAY_MS)
}

/** TicketRef calls this when mouse leaves the trigger. Schedules close, can be cancelled
* if the cursor moves into the card body (via setCardHovered(true)). */
function triggerHoverLeave() {
if (openTimer) {
clearTimeout(openTimer)
openTimer = undefined
}
if (closeTimer) clearTimeout(closeTimer)
closeTimer = setTimeout(() => {
closeTimer = undefined
if (!cardHovered) setHoveredTicketSignal(null)
}, CLOSE_DELAY_MS)
}

/** TicketHoverCard calls this when mouse enters/leaves the card body. Entering keeps
* the card open even after the trigger's leave-timer fires. */
function setCardHovered(hovered: boolean) {
cardHovered = hovered
if (hovered) {
if (closeTimer) {
clearTimeout(closeTimer)
closeTimer = undefined
}
} else {
// Mouse left the card → start the close timer (in case the trigger is also unhovered).
if (closeTimer) clearTimeout(closeTimer)
closeTimer = setTimeout(() => {
closeTimer = undefined
if (!cardHovered) setHoveredTicketSignal(null)
}, CLOSE_DELAY_MS)
}
}

Expand All @@ -275,8 +330,10 @@ export const { use: useHivemind, provider: HivemindProvider } = createSimpleCont
},
refresh,
// ticket-hover
hoveredTicketId,
setHoveredTicket,
hoveredTicket,
triggerHoverEnter,
triggerHoverLeave,
setCardHovered,
ticketDetail,
fetchTicket,
}
Expand Down
Loading