Files
kandev/apps/web/lib/walkthrough-editor-anchor.ts
T
José Almeida 3e237e177a feat: add agent-authored code walkthroughs (#1647)
* feat: add code-walkthrough backend (MCP tool, service, persistence, WS events)

* feat: render agent code walkthroughs as line-anchored cards + editor window

* test: add code-walkthrough re-emit mock-agent scenario

* fix: rework code-walkthrough UX (floating card, line reveal, minimize, clickable file)

* fix: improve code walkthrough editor UX

* fix: backfill walkthrough launcher after websocket connect

* fix: improve walkthrough requests and editor anchors

* fix: clear walkthrough highlights on close

* feat: add walkthrough discard and prompt customization

* fix: send walkthrough requests through saved prompt

* fix: support postgres built-in prompt seeding

* fix: address walkthrough review feedback

* fix: address walkthrough review follow-ups

* fix: harden walkthrough lifecycle races

* fix: reuse walkthrough notification cleanup

* fix: guard stale walkthrough file opens

* fix: abort stale file opens before paint

* fix: stabilize github settings e2e readiness

* fix: use stable github settings e2e selector
2026-07-10 08:34:57 +01:00

161 lines
4.5 KiB
TypeScript

import { useSyncExternalStore } from "react";
export type WalkthroughViewportRect = {
left: number;
top: number;
right: number;
bottom: number;
width: number;
height: number;
};
export type WalkthroughEditorAnchor = {
key: string;
taskId: string;
stepIndex: number;
file: string;
repo?: string;
line: number;
lineEnd: number;
rect: WalkthroughViewportRect;
container?: HTMLElement;
};
type Listener = () => void;
type ElementFromPoint = (x: number, y: number) => Element | null;
let currentAnchor: WalkthroughEditorAnchor | null = null;
const listeners = new Set<Listener>();
function emit() {
for (const listener of listeners) listener();
}
function rectsEqual(a: WalkthroughViewportRect, b: WalkthroughViewportRect): boolean {
return (
a.left === b.left &&
a.top === b.top &&
a.right === b.right &&
a.bottom === b.bottom &&
a.width === b.width &&
a.height === b.height
);
}
function anchorsEqual(
a: WalkthroughEditorAnchor | null,
b: WalkthroughEditorAnchor | null,
): boolean {
if (a === b) return true;
if (!a || !b) return false;
return (
a.key === b.key &&
a.taskId === b.taskId &&
a.stepIndex === b.stepIndex &&
a.file === b.file &&
a.repo === b.repo &&
a.line === b.line &&
a.lineEnd === b.lineEnd &&
a.container === b.container &&
rectsEqual(a.rect, b.rect)
);
}
export function setWalkthroughEditorAnchor(anchor: WalkthroughEditorAnchor | null): void {
if (anchorsEqual(currentAnchor, anchor)) return;
currentAnchor = anchor;
emit();
}
export function clearWalkthroughEditorAnchor(key: string): void {
if (currentAnchor?.key !== key) return;
currentAnchor = null;
emit();
}
export function getWalkthroughEditorAnchor(): WalkthroughEditorAnchor | null {
return currentAnchor;
}
export function subscribeWalkthroughEditorAnchor(listener: Listener): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function useWalkthroughEditorAnchor(): WalkthroughEditorAnchor | null {
return useSyncExternalStore(
subscribeWalkthroughEditorAnchor,
getWalkthroughEditorAnchor,
getWalkthroughEditorAnchor,
);
}
function center(rect: WalkthroughViewportRect) {
return {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
};
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function rectIntersectsViewport(rect: WalkthroughViewportRect): boolean {
if (rect.width <= 0 || rect.height <= 0) return false;
if (typeof window === "undefined") return true;
return (
rect.right > 0 &&
rect.bottom > 0 &&
rect.left < window.innerWidth &&
rect.top < window.innerHeight
);
}
export function isWalkthroughAnchorTargetVisible(
container: HTMLElement | null,
rect: WalkthroughViewportRect,
elementFromPoint: ElementFromPoint = (x, y) => document.elementFromPoint(x, y),
): boolean {
if (!container?.isConnected || !rectIntersectsViewport(rect)) return false;
const containerRect = container.getBoundingClientRect();
if (containerRect.width <= 0 || containerRect.height <= 0) return false;
const style = window.getComputedStyle(container);
if (style.display === "none" || style.visibility === "hidden") return false;
const point = center(rect);
if (point.x < 0 || point.y < 0 || point.x > window.innerWidth || point.y > window.innerHeight) {
return false;
}
const elementAtPoint = elementFromPoint(point.x, point.y);
return !!elementAtPoint && container.contains(elementAtPoint);
}
export function computeWalkthroughConnectorPath(
card: WalkthroughViewportRect,
anchor: WalkthroughViewportRect,
): string | null {
if (card.width <= 0 || card.height <= 0 || anchor.width <= 0 || anchor.height <= 0) return null;
const cardCenter = center(card);
const anchorCenter = center(anchor);
const cardIsLeft = cardCenter.x < anchorCenter.x;
const source = {
x: cardIsLeft ? card.right : card.left,
y: clamp(anchorCenter.y, card.top + 24, card.bottom - 24),
};
const target = {
x: cardIsLeft ? anchor.left : anchor.right,
y: anchorCenter.y,
};
const direction = cardIsLeft ? 1 : -1;
const curve = Math.max(48, Math.min(220, Math.abs(target.x - source.x) * 0.45));
return [
`M ${source.x.toFixed(1)} ${source.y.toFixed(1)}`,
`C ${(source.x + direction * curve).toFixed(1)} ${source.y.toFixed(1)}`,
`${(target.x - direction * curve).toFixed(1)} ${target.y.toFixed(1)}`,
`${target.x.toFixed(1)} ${target.y.toFixed(1)}`,
].join(" ");
}