Skip to content
Open
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
40 changes: 39 additions & 1 deletion packages/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,14 @@ import {
UI_FONTS,
type Appearance,
} from "./appearance.js";
import {
AnnotationCellProvider,
AnnotationGlobalProvider,
useAnnotations,
} from "./annotation-context.js";
import { annotationId } from "./annotations-store.js";
import { AnnotationPopup } from "./components/AnnotationPopup.js";
import { AnnotationToolbar } from "./components/AnnotationToolbar.js";

// react-grid-layout, width-measured + responsive (stacks to 1 column on narrow
// viewports). The canvas engine: drag to place, resize from edges, vertical
Expand Down Expand Up @@ -184,6 +192,10 @@ function RuntimeApp({ config }: { config: AppConfig }): React.ReactElement {
clearOverrides(layoutKey);
}, [layoutKey]);

// Annotate mode: flag graph entities, attach notes, copy a payload for an
// agent. Web-local, persisted per-notebook; never writes to the graph.
const ann = useAnnotations(config.notebook, config.label);

// Tabs partition the one flat cell list into named pages (host-shell view
// tier). Derived from the *declared* cells so the bar is stable across runtime
// status. Empty ⇒ no tab bar (single canvas). State is shared across tabs, so
Expand Down Expand Up @@ -316,6 +328,7 @@ function RuntimeApp({ config }: { config: AppConfig }): React.ReactElement {
onStateChange={handleStateChange}
handlers={handlers}
>
<AnnotationGlobalProvider value={ann.globalValue}>
<Shell
header={
<div className="sticky top-0 z-30 border-b border-border bg-background">
Expand Down Expand Up @@ -507,6 +520,25 @@ function RuntimeApp({ config }: { config: AppConfig }): React.ReactElement {
setOpen={setCmdOpen}
sections={commandSections}
/>
{ann.pending && (
<AnnotationPopup
key={annotationId(ann.pending.cellId, ann.pending.draft.key)}
pending={ann.pending}
onSave={ann.save}
onRemove={ann.remove}
onClose={ann.closePopup}
/>
)}
<AnnotationToolbar
active={ann.active}
onToggleActive={ann.setActive}
session={ann.session}
onInstruction={ann.setInstruction}
onRemove={ann.remove}
onClear={ann.clear}
onCopy={ann.copy}
/>
</AnnotationGlobalProvider>
</JSONUIProvider>
);
}
Expand Down Expand Up @@ -950,7 +982,13 @@ function CellBody({ cell }: { cell: CellExecution }): React.ReactElement {
</Alert>
)}
{cell.spec !== null && (
<Renderer spec={cell.spec} registry={webRegistry} />
<AnnotationCellProvider
cellId={cell.cell.id}
lens={cell.cell.lens}
queryRef={cell.cell.query?.ref}
>
<Renderer spec={cell.spec} registry={webRegistry} />
</AnnotationCellProvider>
)}
{cell.error === null && cell.spec === null && cell.pending && (
<div className="space-y-2">
Expand Down
236 changes: 236 additions & 0 deletions packages/web/src/annotation-context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
import React, {
createContext,
useCallback,
useContext,
useMemo,
useState,
} from "react";
import type { Notebook } from "@modernrelay/notebook-core";
import {
annotationId,
annotationsKey,
clearAnnotations,
loadAnnotations,
saveAnnotations,
serializeAnnotations,
type Annotation,
type AnnotationIntent,
type AnnotationSession,
} from "./annotations-store.js";

/** What a lens passes when an entity is clicked for annotation. */
export interface AnnotationDraft {
key: string;
headline: string;
data: Record<string, unknown>;
}

/** The in-flight popup target — an entity click awaiting a note. */
export interface PendingAnnotation {
cellId: string;
lens: string;
queryRef?: string;
draft: AnnotationDraft;
/** Anchor rect (viewport coords) of the clicked element, for popup placement. */
rect: { top: number; left: number; bottom: number; right: number; width: number };
existing?: Annotation;
}

interface GlobalValue {
active: boolean;
open(
target: {
cellId: string;
lens: string;
queryRef?: string;
draft: AnnotationDraft;
},
rect: DOMRect,
): void;
/** 1-based annotation order for this entity, or null when not annotated. */
numberOf(cellId: string, key: string): number | null;
}

const GlobalContext = createContext<GlobalValue | null>(null);

interface CellValue {
cellId: string;
lens: string;
queryRef?: string;
}
const CellContext = createContext<CellValue | null>(null);

export function AnnotationGlobalProvider({
value,
children,
}: {
value: GlobalValue;
children: React.ReactNode;
}): React.ReactElement {
return (
<GlobalContext.Provider value={value}>{children}</GlobalContext.Provider>
);
}

/** Wraps each cell's `<Renderer>` so its leaf lens learns its cell id/lens/query. */
export function AnnotationCellProvider({
cellId,
lens,
queryRef,
children,
}: {
cellId: string;
lens: string;
queryRef?: string;
children: React.ReactNode;
}): React.ReactElement {
const value = useMemo(
() => ({ cellId, lens, queryRef }),
[cellId, lens, queryRef],
);
return <CellContext.Provider value={value}>{children}</CellContext.Provider>;
}

/** Leaf-lens hook: annotate-on-click + per-entity marker number for this cell. */
export function useAnnotation(): {
active: boolean;
annotate: (draft: AnnotationDraft, e: React.MouseEvent) => void;
/** 1-based marker number for this entity, or null when not annotated. */
numberOf: (key: string) => number | null;
} {
const g = useContext(GlobalContext);
const c = useContext(CellContext);
const active = g?.active ?? false;
const annotate = useCallback(
(draft: AnnotationDraft, e: React.MouseEvent) => {
if (!g || !c) return;
e.stopPropagation();
g.open(
{ cellId: c.cellId, lens: c.lens, queryRef: c.queryRef, draft },
(e.currentTarget as HTMLElement).getBoundingClientRect(),
);
},
[g, c],
);
const numberOf = useCallback(
(key: string) => (g && c ? g.numberOf(c.cellId, key) : null),
[g, c],
);
return { active, annotate, numberOf };
}

/** Host hook: owns the persisted session, annotate mode, and the popup target. */
export function useAnnotations(notebook: Notebook, label: string) {
const storageKey = useMemo(() => annotationsKey(notebook), [notebook]);
const [session, setSession] = useState<AnnotationSession>(() =>
loadAnnotations(storageKey),
);
Comment thread
greptile-apps[bot] marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Notebook switch stale session

Medium Severity

session is loaded from localStorage only in the initial useState call while storageKey is derived from the current notebook. If the hook’s notebook input changes without remounting, the UI keeps the prior notebook’s annotations and the next persist writes them under the new notebook’s storage key.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2f5adb9. Configure here.

const [active, setActive] = useState(false);
const [pending, setPending] = useState<PendingAnnotation | null>(null);

const persist = useCallback(
(next: AnnotationSession) => {
setSession(next);
saveAnnotations(storageKey, next);
},
[storageKey],
);

const open = useCallback<GlobalValue["open"]>(
(target, rect) => {
const id = annotationId(target.cellId, target.draft.key);
setPending({
...target,
rect: {
top: rect.top,
left: rect.left,
bottom: rect.bottom,
right: rect.right,
width: rect.width,
},
existing: session.items.find((a) => a.id === id),
});
},
[session.items],
);

const numberOf = useCallback(
(cellId: string, key: string) => {
const i = session.items.findIndex(
(a) => a.id === annotationId(cellId, key),
);
return i === -1 ? null : i + 1;
},
[session.items],
);

const save = useCallback(
(note: string, intent?: AnnotationIntent) => {
if (!pending) return;
const a: Annotation = {
id: annotationId(pending.cellId, pending.draft.key),
cellId: pending.cellId,
lens: pending.lens,
...(pending.queryRef ? { queryRef: pending.queryRef } : {}),
key: pending.draft.key,
headline: pending.draft.headline,
data: pending.draft.data,
note,
...(intent ? { intent } : {}),
};
persist({
...session,
items: [...session.items.filter((x) => x.id !== a.id), a],
});
setPending(null);
},
[pending, session, persist],
);

const remove = useCallback(
(id: string) => {
persist({ ...session, items: session.items.filter((a) => a.id !== id) });
setPending(null);
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remove closes unrelated popup

Medium Severity

The shared remove handler always clears pending, so deleting any annotation from the floating list closes the note popup even when the user is editing a different entity. In-progress notes can be lost without saving.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d192174. Configure here.

[session, persist],
);

const setInstruction = useCallback(
(instruction: string) => persist({ ...session, instruction }),
[session, persist],
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale session overwrites annotations

Medium Severity

save, remove, and setInstruction rebuild the session from the session value captured in useCallback instead of the latest state. Back-to-back saves, a save plus panel remove, or editing the overall instruction right after saving can persist an older items list and drop annotations that were just written.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4010583. Configure here.


const clear = useCallback(() => {
persist({ items: [], instruction: "" });
clearAnnotations(storageKey);
}, [persist, storageKey]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Clear all leaves popup open

Medium Severity

clear wipes the session and storage but never clears pending, so the annotate popup can stay open after “Clear all” with stale existing state. Saving from that popup re-adds an annotation the user believed they removed.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d192174. Configure here.


const copy = useCallback(async (): Promise<boolean> => {
const md = serializeAnnotations(notebook, label, session);
try {
await navigator.clipboard?.writeText(md);
return true;
} catch {
return false;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copy reports success without clipboard

Medium Severity

copy returns true when navigator.clipboard is missing because optional-chained writeText is never invoked and the try block still completes. The toolbar shows a success checkmark although nothing was copied.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d192174. Configure here.

}, [notebook, label, session]);

const globalValue = useMemo<GlobalValue>(
() => ({ active, open, numberOf }),
[active, open, numberOf],
);

return {
active,
setActive,
session,
pending,
closePopup: useCallback(() => setPending(null), []),
save,
remove,
setInstruction,
clear,
copy,
globalValue,
};
}
Loading
Loading