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
141 changes: 104 additions & 37 deletions src/components/layout/connections-section.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,34 @@
"use client"

import { useMemo, useState } from "react"
import { Trash2 } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { useGraphStore } from "@/stores/graph-store"
import { useUserStore } from "@/stores/user-store"
import { useModalStore } from "@/stores/modal-store"
import { pickString, DISPLAY_KEY_FALLBACKS } from "@/lib/node-display"
import { displayNodeType } from "@/lib/utils"
import { deleteEdge } from "@/lib/graph-api"
import type { SchemaNode } from "@/app/ontology/page"
import type { GraphNode } from "@/lib/graph-api"

interface ConnectionsSectionProps {
nodeRefId: string
schemas: SchemaNode[]
currentNode?: GraphNode
onNavigate?: (node: GraphNode) => void
}

type GroupBy = "edge_type" | "node_type"

export function ConnectionsSection({ nodeRefId, schemas, onNavigate }: ConnectionsSectionProps) {
export function ConnectionsSection({ nodeRefId, schemas, currentNode, onNavigate }: ConnectionsSectionProps) {
const [groupBy, setGroupBy] = useState<GroupBy>("edge_type")
const [confirmingDelete, setConfirmingDelete] = useState<string | null>(null)
const nodes = useGraphStore((s) => s.nodes)
const edges = useGraphStore((s) => s.edges)
const removeEdge = useGraphStore((s) => s.removeEdge)
const isAdmin = useUserStore((s) => s.isAdmin)
const openAddEdge = useModalStore((s) => s.openAddEdge)

const connections = useMemo(() => {
const nodeMap = new Map(nodes.map((n) => [n.ref_id, n]))
Expand All @@ -29,7 +38,7 @@ export function ConnectionsSection({ nodeRefId, schemas, onNavigate }: Connectio
const peerId = e.source === nodeRefId ? e.target : e.source
const peer = nodeMap.get(peerId)
if (!peer) return []
return [{ edge_type: e.edge_type, peer }]
return [{ edge_type: e.edge_type, peer, edge_ref_id: e.ref_id }]
})
}, [edges, nodes, nodeRefId])

Expand Down Expand Up @@ -59,34 +68,56 @@ export function ConnectionsSection({ nodeRefId, schemas, onNavigate }: Connectio
return title ?? peer.ref_id
}

async function handleConfirmDelete(edgeRefId: string) {
if (!isAdmin) return
try {
await deleteEdge(edgeRefId)
} catch {
// Best-effort — remove locally regardless
}
removeEdge(edgeRefId)
setConfirmingDelete(null)
}

return (
<div className="space-y-3">
{/* Header */}
<div className="flex items-center justify-between gap-2">
<p className="text-[10px] font-mono text-muted-foreground uppercase tracking-wider">
Connections
</p>
<div className="flex items-center gap-0.5 rounded-md border border-border/30 p-0.5">
<button
onClick={() => setGroupBy("edge_type")}
className={`rounded px-2 py-0.5 text-[9px] font-mono transition-colors ${
groupBy === "edge_type"
? "bg-border/50 text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
Edge Type
</button>
<button
onClick={() => setGroupBy("node_type")}
className={`rounded px-2 py-0.5 text-[9px] font-mono transition-colors ${
groupBy === "node_type"
? "bg-border/50 text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
Node Type
</button>
<div className="flex items-center gap-1.5">
{currentNode && (
<button
onClick={() => openAddEdge(currentNode)}
className="text-[9px] font-mono text-primary hover:text-primary/80 transition-colors px-1.5 py-0.5 rounded border border-primary/30 hover:border-primary/60"
aria-label="Add connection"
>
+ Add connection
</button>
)}
<div className="flex items-center gap-0.5 rounded-md border border-border/30 p-0.5">
<button
onClick={() => setGroupBy("edge_type")}
className={`rounded px-2 py-0.5 text-[9px] font-mono transition-colors ${
groupBy === "edge_type"
? "bg-border/50 text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
Edge Type
</button>
<button
onClick={() => setGroupBy("node_type")}
className={`rounded px-2 py-0.5 text-[9px] font-mono transition-colors ${
groupBy === "node_type"
? "bg-border/50 text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
Node Type
</button>
</div>
</div>
</div>

Expand All @@ -101,21 +132,57 @@ export function ConnectionsSection({ nodeRefId, schemas, onNavigate }: Connectio
{groupBy === "node_type" ? displayNodeType(groupKey) : groupKey}{" "}
<span className="text-muted-foreground/60">({conns.length})</span>
</p>
{conns.map((conn, i) => (
<button
key={`${conn.peer.ref_id}-${i}`}
className="w-full flex items-center justify-between gap-2 rounded-md px-2 py-1.5 bg-muted/20 border border-border/20 cursor-pointer hover:bg-muted/40 transition-colors text-left"
onClick={() => onNavigate?.(conn.peer)}
>
<span className="text-xs truncate min-w-0">{resolveTitle(conn.peer)}</span>
<Badge
variant="outline"
className="text-[9px] px-1.5 py-0 h-4 border-border/50 text-muted-foreground font-mono shrink-0"
{conns.map((conn, i) => {
const isConfirming = confirmingDelete === conn.edge_ref_id
return (
<div
key={`${conn.peer.ref_id}-${i}`}
className="w-full flex items-center gap-2 rounded-md px-2 py-1.5 bg-muted/20 border border-border/20 hover:bg-muted/40 transition-colors"
>
{displayNodeType(conn.peer.node_type)}
</Badge>
</button>
))}
<button
className="flex-1 flex items-center justify-between gap-2 cursor-pointer text-left min-w-0"
onClick={() => onNavigate?.(conn.peer)}
>
<span className="text-xs truncate min-w-0">{resolveTitle(conn.peer)}</span>
<Badge
variant="outline"
className="text-[9px] px-1.5 py-0 h-4 border-border/50 text-muted-foreground font-mono shrink-0"
>
{displayNodeType(conn.peer.node_type)}
</Badge>
</button>
{isAdmin && conn.edge_ref_id !== undefined && (
isConfirming ? (
<div className="flex items-center gap-1 shrink-0">
<span className="text-[9px] text-muted-foreground">Remove?</span>
<button
onClick={() => handleConfirmDelete(conn.edge_ref_id!)}
className="text-[9px] font-mono text-destructive hover:text-destructive/80 transition-colors"
aria-label="Confirm remove"
>
Yes
</button>
<button
onClick={() => setConfirmingDelete(null)}
className="text-[9px] font-mono text-muted-foreground hover:text-foreground transition-colors"
aria-label="Cancel remove"
>
No
</button>
</div>
) : (
<button
onClick={() => setConfirmingDelete(conn.edge_ref_id!)}
className="shrink-0 text-muted-foreground hover:text-destructive transition-colors"
aria-label="Remove connection"
>
<Trash2 className="h-3 w-3" />
</button>
)
)}
</div>
)
})}
</div>
))}
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/components/layout/node-preview-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1647,7 +1647,7 @@ export function NodePreviewPanel({ node, onBack, schemas }: NodePreviewPanelProp

{/* Connections — always visible regardless of unlock state */}
<div className="pt-2 border-t border-border/30">
<ConnectionsSection nodeRefId={currentNode.ref_id} schemas={schemas} onNavigate={handleNavigate} />
<ConnectionsSection nodeRefId={currentNode.ref_id} schemas={schemas} currentNode={currentNode} onNavigate={handleNavigate} />
</div>
</div>
</ScrollArea>
Expand Down
3 changes: 3 additions & 0 deletions src/components/layout/toolkit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
Network,
Boxes,
BookMarked,
BookText,
ClipboardList,
Heart,
Menu,
Expand Down Expand Up @@ -184,6 +185,7 @@ export function Toolkit({
<Divider />

<ToolkitButton icon={Plus} ariaLabel="Add to graph" onClick={() => openAdd("source")} />
<ToolkitButton icon={BookText} ariaLabel="Add Lingo Node" onClick={() => openAdd("node", "Lingo")} />
<ToolkitButton
icon={MessageSquare}
ariaLabel="Graph Agent"
Expand Down Expand Up @@ -324,6 +326,7 @@ export function ToolkitFAB({
{/* Action buttons — icon + label */}
{[
{ icon: Plus, label: "Add to graph", action: () => openAdd("source"), active: false },
{ icon: BookText, label: "Add Lingo Node", action: () => openAdd("node", "Lingo"), active: false },
{ icon: MessageSquare, label: "Graph Agent", action: onToggleAgent ?? (() => {}), active: agentOpen ?? false },
{ icon: BookMarked, label: "My Content", action: onToggleMyContent, active: myContentOpen },
{ icon: Heart, label: "Following", action: onToggleFollowing, active: followingOpen },
Expand Down
16 changes: 14 additions & 2 deletions src/components/modals/add-edge-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ import { NodeSearchInput } from "@/components/ui/node-search-input"
import { useModalStore } from "@/stores/modal-store"
import { useSchemaStore } from "@/stores/schema-store"
import { useUserStore } from "@/stores/user-store"
import { useGraphStore } from "@/stores/graph-store"
import { getPrice, payL402 } from "@/lib/sphinx"
import { createEdge, type GraphNode } from "@/lib/graph-api"
import { createEdge, type GraphNode, type GraphEdge } from "@/lib/graph-api"
import { displayNodeType } from "@/lib/utils"

type Status = "idle" | "submitting" | "success" | "error"
Expand Down Expand Up @@ -51,6 +52,7 @@ export function AddEdgeForm() {
const close = useModalStore((s) => s.close)
const openModal = useModalStore((s) => s.open)
const setBudget = useUserStore((s) => s.setBudget)
const addNodes = useGraphStore((s) => s.addNodes)

const schemaEdges = useSchemaStore((s) => s.edges)

Expand Down Expand Up @@ -192,7 +194,7 @@ export function AddEdgeForm() {
}

const doCreate = async () => {
await createEdge({
const result = await createEdge({
source: selectedSource.ref_id,
target: selectedTarget.ref_id,
edge_type: edgeType,
Expand All @@ -201,6 +203,16 @@ export function AddEdgeForm() {
// schema types don't need it (the schema already exists).
...(customMode ? { create_schema_if_missing: true } : {}),
})
// Reflect the new edge in the graph store immediately so ConnectionsSection
// updates without a reload.
const createdEdge: GraphEdge = {
source: selectedSource.ref_id,
target: selectedTarget.ref_id,
edge_type: edgeType,
// Backend may return ref_id on the created edge
ref_id: (result as { ref_id?: string } | null)?.ref_id,
}
addNodes([], [createdEdge])
setStatus("success")
setTimeout(() => close(), 1500)
}
Expand Down
3 changes: 2 additions & 1 deletion src/components/modals/add-node-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,12 @@ function parseFieldValue(type: string, raw: string): unknown {

export function AddNodeForm() {
const { close } = useModalStore()
const preselectedNodeType = useModalStore((s) => s.preselectedNodeType)
const setBudget = useUserStore((s) => s.setBudget)
const pubKey = useUserStore((s) => s.pubKey)
const schemas = useSchemaStore((s) => s.schemas)

const [selectedType, setSelectedType] = useState<string>("")
const [selectedType, setSelectedType] = useState<string>(preselectedNodeType ?? "")
const [fieldValues, setFieldValues] = useState<Record<string, string>>({})
const [domains, setDomains] = useState<SchemaDomainsResponse | null>(null)
const [price, setPrice] = useState<number | null>(null)
Expand Down
30 changes: 30 additions & 0 deletions src/lib/__tests__/add-edge-modal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ vi.mock("@/stores/user-store", () => ({
sel({ setBudget: vi.fn() }),
}))

// Graph store — AddEdgeForm calls addNodes on success.
const mockAddNodes = vi.fn()
vi.mock("@/stores/graph-store", () => ({
useGraphStore: (sel: (s: Record<string, unknown>) => unknown) =>
sel({ addNodes: mockAddNodes }),
}))

// ---------------------------------------------------------------------------
// Fixture nodes
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -343,6 +350,29 @@ describe("AddEdgeForm", () => {
})
})

it("calls addNodes([], [createdEdge]) on successful edge creation", async () => {
mockCreateEdge.mockResolvedValueOnce({ ref_id: "new-edge-ref" })
withSource(null)
render(<AddEdgeForm />)
await selectNode("Search source node…", FIXTURE_SOURCE)
await selectNode("Search target node…", FIXTURE_TARGET)
const trigger = screen.getByText("Choose an edge type...").closest("button") as HTMLButtonElement
await userEvent.click(trigger)
await userEvent.click(screen.getByText("HAS_TOPIC"))
await userEvent.click(screen.getByRole("button", { name: /add edge/i }))
await waitFor(() => expect(mockAddNodes).toHaveBeenCalled())
expect(mockAddNodes).toHaveBeenCalledWith(
[],
expect.arrayContaining([
expect.objectContaining({
source: "node-source-ref",
target: "node-target-ref",
edge_type: "HAS_TOPIC",
}),
])
)
})

it("calls close after success auto-close timeout", async () => {
withSource(null)
render(<AddEdgeForm />)
Expand Down
Loading
Loading