Skip to content
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ Each window in the sidebar, dashboard, and pane panel carries a single **status

Exactly one signal drives the dot, in precedence order **PR > fab > tmux**.

![StatusDot stage × status matrix](docs/img/status-dot-matrix.svg)
![StatusDot stage × status matrix](https://raw.githubusercontent.com/sahil87/run-kit/main/docs/img/status-dot-matrix.svg)

See the [status dot reference](docs/site/status-dot.md) for the full matrix, the per-state rendering, and the design rationale.

Expand Down
1 change: 1 addition & 0 deletions app/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"test:e2e": "playwright test"
},
"dependencies": {
"@floating-ui/react": "^0.27.19",
"@tanstack/react-router": "^1.168.22",
"@xterm/addon-clipboard": "^0.2.0",
"@xterm/addon-fit": "^0.11.0",
Expand Down
54 changes: 54 additions & 0 deletions app/frontend/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

49 changes: 49 additions & 0 deletions app/frontend/src/components/status-dot-label.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { DotShape, StatusDotState } from "@/components/pr-status-line";
import type { WindowInfo } from "@/types";

/**
* Shared label resolver for the status dot, extracted into its own module so
* both `status-dot.tsx` (the dot) and `status-dot-tip.tsx` (the hover-card)
* can import it without forming an import cycle between the two components.
* `status-dot.tsx` re-exports `dotLabel` to keep its public surface stable.
*/

/** Human word for the SHAPE/status axis used in the accessible label. */
const SHAPE_LABEL: Record<DotShape, string> = {
ring: "pending",
solid: "active",
failed: "failed",
done: "done",
skipped: "skipped",
};

// PR-phase status words. The shared SHAPE_LABEL vocabulary is fab-stage language
// ("active"/"done"); for a PR those read unnaturally, so the PR branch maps the
// same shapes onto PR-native words — a PR is "open", "merged", "failing", not
// "active"/"done"/"failed".
const PR_SHAPE_LABEL: Record<DotShape, string> = {
ring: "checks running",
solid: "open",
failed: "failing",
done: "merged",
skipped: "closed",
};

/**
* Compose the accessible label. The fab branch uses the real stage word
* ("apply — active"); the PR branch uses PR-native words ("PR — merged"); the
* tmux fallback uses the bare activity word ("active"/"idle"), no journey.
*
* The tmux fallback is gated on `!win.fabChange`, NOT on `phase === "none"`: a
* fab-bound window whose `fabStage` is unknown/absent maps to `phase: "none"`
* via `fabPhase` (and may carry a `failed`/`done` shape), yet it still
* represents fab state — so it gets a `{stage} — {status}` label (the raw
* `fabStage`, or the literal "fab" when the stage word is absent), never the
* bare tmux activity word. Only a window with no `fabChange` is a true tmux
* fallback.
*/
export function dotLabel(win: WindowInfo, state: StatusDotState): string {
if (state.phase === "pr") return `PR — ${PR_SHAPE_LABEL[state.shape]}`;
if (!win.fabChange) return win.activity; // tmux fallback: "active" | "idle"
return `${win.fabStage ?? "fab"} — ${SHAPE_LABEL[state.shape]}`;
}
202 changes: 202 additions & 0 deletions app/frontend/src/components/status-dot-tip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import { useState, type ReactNode } from "react";
import {
useFloating,
offset,
flip,
shift,
useHover,
useFocus,
useDismiss,
useInteractions,
FloatingPortal,
safePolygon,
autoUpdate,
} from "@floating-ui/react";
import { dotLabel } from "@/components/status-dot-label";
import type { StatusDotState } from "@/components/pr-status-line";
import type { WindowInfo } from "@/types";

/**
* Status-dot docs page (rendered by GitHub). Opens in a new tab from the
* hover-card's docs icon — the canonical "what does this dot mean" reference.
* docs/site is NOT served by the backend, so we link the GitHub blob (no
* anchor → lands at the top of the doc), matching the convention the only
* other in-app docs link uses (top-bar.tsx NOTIFICATIONS_HELP_URL).
*/
const STATUS_DOT_DOCS_URL =
"https://github.com/sahil87/run-kit/blob/main/docs/site/status-dot.md";

/** A single interactive link rendered inside the hover-card. */
export type DotLink = { label: string; href: string; testid: string };

/** Resolved hover-card content for a window+state. */
export type DotTipContent = { label: string; links: DotLink[] };

/**
* Pure content resolver: maps a window + its derived `StatusDotState` to the
* hover-card's text + interactive links. The label REUSES `dotLabel()` so the
* card text is the single source of truth shared with the dot's `aria-label`.
*
* Only PR-phase dots that actually carry a `prUrl` get a link ("Open PR #N").
* Fab-phase and tmux-fallback dots get text only. The docs-link icon is NOT in
* `links[]` — it is a fixed element the card always renders (constant href), so
* it does not flow through per-state logic.
*/
export function dotTipContent(win: WindowInfo, state: StatusDotState): DotTipContent {
const label = dotLabel(win, state);
const links: DotLink[] = [];
if (state.phase === "pr" && win.prUrl) {
links.push({
label: `Open PR #${win.prNumber}`,
href: win.prUrl,
testid: "dot-tip-pr-link",
});
}
return { label, links };
}

/**
* Circled-"i" info glyph for the docs affordance — an inline SVG (matching the
* codebase's hand-built SVG icons, e.g. window-row's pin) rather than a Nerd
* Font glyph, so it renders crisply at any size, themes via `currentColor`, and
* doesn't depend on the user's terminal font being patched. "info" intent reads
* as "know more" without competing with the "Open PR" external-link affordance.
*/
function InfoIcon() {
return (
<svg
aria-hidden="true"
width="12"
height="12"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.4"
className="shrink-0"
>
<circle cx="8" cy="8" r="6.5" />
<line x1="8" y1="7.25" x2="8" y2="11" strokeLinecap="round" />
<circle cx="8" cy="4.75" r="0.5" fill="currentColor" stroke="none" />
</svg>
);
}

/**
* Shared link styling for the card's interactive rows. Click is stopped from
* bubbling so activating a link never selects/navigates the underlying window
* row (the dot sits inside a clickable sidebar row) — mirrors the proven
* PrStatusLine link pattern (pr-status-line.tsx).
*/
const LINK_CLASS =
"text-text-secondary hover:text-text-primary hover:underline whitespace-nowrap coarse:py-1";

type StatusDotTipProps = {
win: WindowInfo;
state: StatusDotState;
/**
* Renders the dot itself. Receives the floating reference setter and the
* reference interaction props (which carry hover/focus/aria wiring) to spread
* onto the dot element — keeping the dot the floating anchor while StatusDot
* owns the dot's shape/color markup.
*/
renderDot: (
setReference: (node: HTMLElement | null) => void,
referenceProps: Record<string, unknown>,
) => ReactNode;
Comment on lines +102 to +105

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skipped — the asserted breakage doesn't occur: tsc --noEmit passes (strict mode) both before and after this PR. getReferenceProps() from @floating-ui/react returns Record<string, unknown>, so this type matches the source exactly; spreading an index-signature object into JSX props is valid under strict TS (it only rejects when a named prop's type conflicts). Narrowing to a DOM-props type would actually misrepresent floating-ui's contract here.

};

/**
* Custom hover-card wrapping a `StatusDot`. Replaces the native HTML `title`
* tooltip: a headless `@floating-ui/react` floating element gives full styling
* control (terminal aesthetic), portals out of the sidebar's `overflow:hidden`
* clip, flips/shifts at viewport edges, and uses a `safePolygon` bridge so the
* pointer can travel dot → card to click a link.
*
* Opens on hover (snappy delay) AND on keyboard focus (Constitution V —
* keyboard-first), dismisses on Escape / blur / pointer-leave. Always shows the
* dot's label text + a docs-link icon; PR-phase dots additionally show an
* "Open PR #N" link (from `dotTipContent`).
*/
export function StatusDotTip({ win, state, renderDot }: StatusDotTipProps) {
const [open, setOpen] = useState(false);
const { label, links } = dotTipContent(win, state);

const { refs, floatingStyles, context } = useFloating({
open,
onOpenChange: setOpen,
placement: "top",
middleware: [offset(6), flip(), shift({ padding: 8 })],
whileElementsMounted: autoUpdate,
});

const hover = useHover(context, {
delay: { open: 150, close: 100 },
handleClose: safePolygon(),
});
const focus = useFocus(context);
const dismiss = useDismiss(context);
// No `useRole({ role: "tooltip" })`: the W3C tooltip pattern requires a
// tooltip to contain NO interactive content, but this card holds real `<a>`
// links (PR + docs). Advertising `role="tooltip"` would (a) lie to AT about
// there being nothing actionable and (b) wire a misleading `aria-describedby`
// from the dot to a "tooltip" that is actually a hover-card. The dot already
// carries its own accessible name via `aria-label`, and the links are
// real Tab-reachable controls — so we add no extra ARIA role here.
const { getReferenceProps, getFloatingProps } = useInteractions([
hover,
focus,
dismiss,
]);

return (
<>
{renderDot(refs.setReference, getReferenceProps())}
{open && (
<FloatingPortal>
<div
ref={refs.setFloating}
style={floatingStyles}
{...getFloatingProps()}
// Don't let clicks inside the card bubble to the underlying row.
onClick={(e) => e.stopPropagation()}
data-testid="status-dot-tip"
className="z-50 flex flex-col gap-1 bg-bg-primary border border-border rounded-md shadow-lg px-2 py-1.5 text-xs font-mono w-max max-w-xs"
>
{/* Label row: status text on the left, a quiet circled-(i) docs
affordance pinned top-right. No visible copy — the icon's
"info / know more" convention plus its aria-label/title carry
the meaning, keeping the card terse and matching its register. */}
<div className="flex items-start gap-3">
<span className="text-text-primary whitespace-nowrap">{label}</span>
<a
href={STATUS_DOT_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="ml-auto mt-px text-text-secondary hover:text-text-primary coarse:p-1"
aria-label="What do status dots mean? (opens docs)"
title="What do status dots mean?"
data-testid="dot-tip-docs-link"
>
<InfoIcon />
</a>
</div>
{links.map((link) => (
<a
key={link.testid}
href={link.href}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className={LINK_CLASS}
data-testid={link.testid}
>
{link.label}
</a>
))}
</div>
</FloatingPortal>
)}
</>
);
}
Loading
Loading