# Graph View

The **Graph** admin page (`/graph`) renders a force-directed view of your
account's Neo4j subgraph. Labels on the canvas follow the zoom level, so you
see the most useful identity at every scale.

## 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.

## 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). 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

Trashed Conversation nodes are hidden by default. 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.

## Filtering by channel and message kind

When you select **AdminConversation** or **PublicConversation** in the
filter popover, two extra rows appear underneath the chip list:

- **Channel** — Web / WhatsApp. Select one to scope the canvas to
  conversations that came in over that channel only. Selecting both is
  the same as selecting neither (all channels). After the migration that
  ships with this release, every conversation carries an explicit
  channel value — pre-existing conversations are backfilled to "Web"
  because only the WhatsApp and Telegram intake paths ever set non-Web
  values.
- **Message** — User / Assistant / WhatsApp. When you've also pivoted
  into a conversation neighbourhood (or your search hits messages
  directly), this row scopes the messages on canvas to the chosen kind.
  WhatsApp messages persist with their own sublabel so you can isolate
  the live-channel cohort from the agent-path cohort within the same
  conversation.

These sub-facets compose with the chip selection. Searching with the
AdminConversation chip selected now also reaches the body text of every
admin message — typing a rare word like "ATM" returns every conversation
that mentions it, not just conversations with that word in the title.

## 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.
