# Graph View

The **Graph** admin page (`/graph`) renders a force-directed view of your
account's Neo4j subgraph. Node captions are hidden on first load so the
whole-graph overview stays uncluttered, and appear the moment you interact.
Once shown, captions follow the zoom level so you see the most useful identity
at every scale.

## Default view

Opening `/graph` with no filter chips selected shows **all your top-level
nodes** straight away, so the canvas is populated on arrival rather than
blank. Top-level nodes are the operator-meaningful entry points (People,
Organizations, Tasks, Jobs, and the rest of the filter chip roster); their
child and infrastructural nodes stay off this view and are reached by
clicking into a node's neighbourhood. Conversations are deliberately not
top-level — see **Conversations are off the default canvas** below.

This view is capped at **1000 nodes**. When your graph has more than that, the
canvas shows 1000 of them and a note reads, for example, `Showing 1,000 of
3,412 top-level nodes`, so you know some are not on screen. When the count is
under the cap the note reads `Showing 312 of 312 top-level nodes`. Selecting
one or more filter chips switches to the chip-filtered view, which returns
every matching node and asks you to narrow if a single label set exceeds its
own larger limit.

## Sales-progression nodes

For estate-agent accounts, a sale-agreed deal is held in the graph as a `Sale`
(the agreed transaction), its `Chain` (the ordered links of connected sales),
and a `ChainLink` per property in the chain (carrying the ten milestone dates
and the seller, buyer, and solicitor contacts). These are the source of truth
the negotiator's chase-progression and progression-inbox workflows read and
update — they replace the old free-text progression notes. Like conversations,
they are not top-level chips; you reach a chain by searching for its address or
a party name and pivoting into its neighbourhood.

## Search and pivot

Type a term in the search box to highlight matching nodes on the canvas. Hits get an amber border so you can pick them out of a busy view. Click any highlighted node to open its side panel and pivot into its neighbourhood — both clicks (hit and non-hit) behave identically.

When a search is active and you click a node, the neighbourhood you pivot into is **narrowed to the search-relevant subset**. For example: searching "david" with 175 matches and clicking yourself returns the Davids you're connected to, not your entire LinkedIn graph. The narrowing applies once per pivot — clearing the search and pivoting again returns the full neighbourhood.

Searches reach **every textual property** of every operator-meaningful label, including denormalised fields the platform writes specifically so search can reach them — for example, the current job title of each LinkedIn connection (originally stored on the connection edge, copied to the Person node so the fulltext index can match it).

Search behaves the same here, in the agent's own memory recall, and on the `/data` page: with **no filter chips selected** a search reaches **all** labels — the same scope the agent sees — and every surface applies the **same default relevance floor**, so the same query returns the same nodes everywhere. There is no longer a "select a chip first" gate; an empty chip set means "search everything". Selecting chips narrows the result to those labels.

## Full-screen the canvas

The toolbar carries a **maximize** button (the expand icon, beside Refresh).
Clicking it lifts the toolbar and canvas out of the page and paints them over
the whole browser window, hiding the sidebar and header so a dense
neighbourhood gets the full screen. The button turns into a **minimize** icon
while maximized. Press **Escape** or click minimize to return to the boxed
view. When the filter popover is open, the first Escape closes the popover and
the next exits full screen, so one keypress never does both, and the minimize
button is always on screen so you can never get stuck.

This is an in-page overlay, so the browser tab and address bar stay put. It is
not the operating system's full-screen mode. The maximize button shows only on
the `/graph` page, not where the graph is embedded in another pane.

## Captions hidden on first load

When you open `/graph` (or refresh the page) the whole graph auto-fits into the
viewport, which on a busy graph would render every caption at once and overlap
them into unreadable clutter. So captions start hidden. They appear on your
first interaction of any kind — zoom, drag, a filter chip, a search, clicking a
node — and stay visible for the rest of that visit. To get the clean
caption-free overview back, reload the page.

This is a one-off marker tied to page load, not a zoom threshold: after the
first interaction, captions follow the zoom tiers below and do not hide again
until the next reload.

## Conversation label tiers

Conversation nodes carry the most operator-meaningful identity in the
subgraph (the conversation name or summary, the date it started, the message
count). Once captions are showing they render in one of three tiers, switched
by canvas scale:

| Zoom | Label shape | Example |
|------|-------------|---------|
| Zoomed out (< 0.7×) | Compact — one line, capped at 24 characters. Preserves the no-overlap contract that matters when nodes are tightly packed. | `Maxyfi branding conflict…` |
| Mid zoom (0.7× to 1.3×) | Wrapped — up to two lines of 24 characters each, soft-ellipsis on overflow. Full name is visible without hover. | `Maxyfi branding conflict` / `with Rubytech` |
| Zoomed in (≥ 1.3×) | Detailed — wrapped name plus a metadata line reading `YYYY-MM-DD · N msgs`. | `Maxyfi branding conflict` / `with Rubytech` / `2026-04-23 · 7 msgs` |

Non-Conversation nodes (People, Messages, Tasks, WorkflowRuns, etc.) keep
their concise single-line labels at every zoom level — the canvas stays
readable when you zoom out to see a large subgraph.

Tier transitions are debounced so spinning the scroll wheel does not cause
label flicker; labels only rewrite once zoom settles on a new tier.

## Cluster-expand on Conversation/Message clicks (cluster-integrity fix)

Clicking a Conversation node OR any Message node pulls the WHOLE
conversation cluster onto the canvas: the Conversation node itself plus
every Message belonging to it (via `PART_OF`), capped at 200 messages
for layout reasons. The arrow chain along the conversation (the `NEXT`
edges) renders for free because the inter-node relationship pass picks
up edges where both endpoints are in the visible window.

Pre-fix, clicking a middle Message expanded only its prev+next
neighbours; the head, tail, and Conversation node dropped off, visually
disintegrating the conversation. The new behaviour keeps the cluster
intact across click navigation. `PART_OF` edges are now rendered between
visible Conversation/Message pairs (previously suppressed because they
"added no information when the Conversation node wasn't on canvas" — an
assumption that broke the moment the cluster-expand put it there).

The breadcrumb above the canvas tracks each pivot — every entry except
the last is clickable to pop the view-stack back to that point.

## Tooltips and side panel

Hovering a node still shows the full 5-line tooltip (display name, labels,
id, created at, updated at). Clicking a Conversation opens the side panel
with the full property table — zoom-tier changes never alter these paths.

The side panel carries a **Trash** button for live nodes and a **Restore**
button for trashed nodes. Soft-delete is reversible: trashed nodes
remain in the graph and reappear when **Show trashed** is on.

## Deleting a node

Two surfaces, same outcome:

- **Mouse (desktop):** drag a node to the dashed Trash zone in the upper-
  right corner of the canvas.
- **Touch (mobile/tablet):** the dashed Trash zone is hidden because
  vis-network's drag hit-test never fires on touch. Tap the node to open
  the side panel, then tap **Trash**.

Both paths POST to the same soft-delete endpoint; the operator-side
behaviour is identical.

## Mobile layout

Below 640px viewport width the toolbar wraps: the search input claims
its own row, the search-result slider claims its own row (full-width with
an enlarged thumb for touch), and the Filter button + node count share
the bottom row. The "← Back" control collapses to a left-arrow icon to
preserve toolbar space at depth.

## Trashed conversations

Conversations are off the default canvas (see below), so you meet trashed
ones the same way you meet live ones — via search or drilldown. Trashed
nodes are hidden by default there too; toggle **Show trashed** in the filter
popover to surface them. They render with a faded fill and dashed border,
with their zoom-tier labels intact. The `N msgs` count excludes trashed
Messages, so the detailed-tier label reflects only live turns in the
conversation.

## Conversations are off the default canvas

Conversations no longer appear as top-level nodes or filter chips. Both
admin conversations (your own chats with the agent) and public
conversations (the agent's chats with customers) are chat transcripts, not
operator-entry domain entities, so they stay off the populated-on-arrival
canvas and out of the filter popover.

They remain fully in the graph and fully searchable. You reach a
conversation two ways:

- **Search** — type a term and the conversation (or a message inside it)
  surfaces as a search hit; clicking it pivots into the cluster. Search
  reaches the body text of every message, so a rare word like "ATM"
  returns every conversation that mentions it, not just ones with that
  word in the title.
- **Drilldown** — clicking any rendered node next to a conversation, or
  the conversation via a search hit, pulls the whole conversation cluster
  onto the canvas (see **Cluster-expand on Conversation/Message clicks**).

Because conversations carry no chip, the per-chip Channel (Web / WhatsApp)
and Message-kind (User / Assistant / WhatsApp) sub-facet filters are no
longer surfaced. The underlying channel and message-sublabel values are
unchanged on the nodes; only the chip-driven filter UI is retired.

## Sidebar conversations list

The Recents list above the chat sidebar carries a per-row marker:
WhatsApp conversations show a small WhatsApp glyph next to the
conversation name. The dropdown above the list filters Recents to a
specific channel — flipping it to **WhatsApp** hides web-chat
conversations and vice versa.

## Agents in the graph

Both admin and public agents appear as `:Agent` nodes in the graph. Open
the **Agents** entry from the sidebar to see them all. Each agent
carries a `:HANDLED_BY` edge from every conversation it has handled, so
you can pivot from an agent to the conversations it ran. The admin
agent's IDENTITY, SOUL, KNOWLEDGE, and KNOWLEDGE-SUMMARY documents
appear as :KnowledgeDocument nodes connected via `HAS_*` edges, the same
projection shape used for public agents.

## Agent-execution telemetry

`ToolCall`, `StepResult`, `WorkflowStep`, and `WorkflowRun` nodes are
agent-execution telemetry — kept for audit but noisy for day-to-day graph
navigation (they make up roughly 9% of a typical brand's live nodes).
They are hidden from `/graph` by default. To see them — for audit, debug,
or tracing a specific agent run — open the filter popover and tick
**Include agent actions** (the checkbox directly below **Show trashed**).
Flipping it on surfaces the four labels as chips in the popover roster AND
keeps them on the canvas when you pivot into a neighbourhood. The toggle
is session-scoped: every new session starts with agent actions hidden, so
the 90% domain-navigation path stays clean without having to remember to
switch them off. Flipping it off again also drops them from any already-
expanded neighbourhood so a click near a `ToolCall` does not re-introduce
it.

## Direct edge management

`memory-edge` creates or deletes a typed directed edge between two nodes that already exist in the graph. Both nodes must belong to the same account — mismatched or foreign nodes are rejected with a structured error before any mutation runs.

**Create:** MERGE is idempotent. First call returns `{created: true}`; a repeated call with the same endpoints and type returns `{created: false}`. Properties supplied on the call are stamped onto the relationship on CREATE only; a subsequent idempotent hit does not overwrite them.

**Delete:** If the edge is present it is deleted and `{deleted: true}` is returned. If absent, the call is a no-op and returns `{deleted: false}`.

`relationshipType` is uppercase-coerced. Types that start with an underscore (e.g. `_SOFT_DELETE`) are reserved for platform internals and are rejected.

Typical flow: call `memory-search` for each endpoint to retrieve their `elementId` values, then call `memory-edge action=create relationshipType=RELATES_TO fromId=<id> toId=<id>`. The new edge appears when you hop-expand either endpoint on the `/graph` canvas.
