import React, { useMemo, useState, useCallback, useEffect, useRef, lazy, Suspense } from 'react';
import type { ReactNode } from 'react';
import { useFetch } from '../../hooks.js';
import { api } from '../../api-wrapper.js';
import { encodeDocTabId, resolveDocRef } from '../../lib/doc-tab-id.js';
import {
  listJoinRequests, approveJoinRequest, rejectJoinRequest,
  listAssertions, promoteAssertion,
  publishSharedMemory, executeQuery,
  writeProfileQueryCatalog,
  fetchSubGraphs,
  type AgentIdentity, type PendingJoinRequest, type PublishResult, type SubGraphInfo,
} from '../../api.js';
import { ImportFilesModal } from '../../components/Modals/ImportFilesModal.js';
import { ShareProjectModal } from '../../components/Modals/ShareProjectModal.js';
import {
  useMemoryEntities,
  type TrustLevel, type MemoryEntity, type Triple,
} from '../../hooks/useMemoryEntities.js';
import {
  useProjectProfile, ProjectProfileContext, useProjectProfileContext,
  type QueryCatalog,
} from '../../hooks/useProjectProfile.js';
import {
  useAgents, AgentsContext, useAgentsContext,
  type AgentSummary,
} from '../../hooks/useAgents.js';
import { AgentChip } from '../../components/AgentChip.js';
import { ActivityFeed } from '../../components/ActivityFeed.js';
import { VerifiedIdentityBanner } from '../../components/VerifiedIdentityBanner.js';
import { SubGraphBar } from '../../components/SubGraphBar.js';
import { GenUIEntityPanel } from '../../genui/index.js';
import { memoryGraphLabels } from '../../lib/memoryLabels.js';
import { canonicalAgentDid, normalizeAccessPolicy } from '../../lib/contextGraphSidebar.js';
import { useTabsStore } from '../../stores/tabs.js';
import {
  useVerifiedMemoryAnchors,
  VIZ_ANCHOR_TYPE, VIZ_AGENT_TYPE,
  VIZ_PRED_ANCHORED_IN, VIZ_PRED_SIGNED_BY, VIZ_PRED_CONSENSUS,
  type PublishAnchor,
} from '../../hooks/useVerifiedMemoryAnchors.js';
import {
  useSwmAttributions,
  type AgentPaletteEntry,
} from '../../hooks/useSwmAttributions.js';
import {
  TRUST_COLORS, TYPE_LABELS,
  PROV_WAS_ATTRIBUTED_TO,
  AGENT_NS, AGENT_CREATED_BY, AGENT_PROMOTED_BY, AGENT_PUBLISHED_BY,
  AGENT_CREATED_AT, AGENT_PROMOTED_AT, AGENT_PUBLISHED_AT,
  LAYER_CONFIG, CODE, CODE_CLASS_COLORS, CODE_PREDICATE_COLORS,
  SOURCE_CONTENT_TYPE, MARKDOWN_FORM, SOURCE_FILE, DKG_SIZE,
  entityAuthorUri, transitionAgentUri, transitionAtISO,
  shortType, shortPred, entityMeta,
  buildLayerGraphOptions, getDescription, neighborhoodTriples,
  matchesSearch, humanizeLabel, layerNoun, useLayerTriples,
  entityTimestamp, formatRelativeTime, formatTimelineBucket, formatTrailTimestamp,
  type LayerView, type LayerContentTab, type KAPane,
  type SubGraphTab, type SubGraphEntitySort,
} from './helpers.js';
import { EmptyState, StatStrip, toneForLayer } from '../../components/ContextGraphPrimitives.js';

export const RdfGraph = lazy(() =>
  import('@origintrail-official/dkg-graph-viz/react').then(m => ({ default: m.RdfGraph }))
);

export function LayerSwitcher({ active, counts, onSwitch, onShare, onImport, onRefresh }: {
  active: LayerView;
  counts: { wm: number; swm: number; vm: number; total: number };
  onSwitch: (v: LayerView) => void;
  onShare: () => void;
  onImport: () => void;
  onRefresh: () => void;
}) {
  const [moreOpen, setMoreOpen] = useState(false);
  const moreRef = useRef<HTMLDivElement | null>(null);
  const switchTo = (layer: LayerView) => {
    setMoreOpen(false);
    onSwitch(layer);
  };

  useEffect(() => {
    if (!moreOpen) return;
    const onPointerDown = (event: PointerEvent) => {
      const target = event.target as Node | null;
      if (target && moreRef.current?.contains(target)) return;
      setMoreOpen(false);
    };
    document.addEventListener('pointerdown', onPointerDown);
    return () => document.removeEventListener('pointerdown', onPointerDown);
  }, [moreOpen]);

  return (
    <div className="v10-layer-switcher">
      <button
        className={`v10-layer-switch-btn ${active === 'overview' ? 'active' : ''}`}
        data-layer="overview"
        aria-label="Overview"
        title="Overview"
        onClick={() => switchTo('overview')}
      >
        <span className="v10-layer-switch-icon">◎</span>
        <span className="v10-layer-switch-label">Overview</span>
      </button>
      <span className="v10-layer-switch-separator" aria-hidden="true" />
      <button
        className={`v10-layer-switch-btn ${active === 'wm' ? 'active' : ''}`}
        data-layer="wm"
        aria-label="Working Memory"
        title="Working Memory"
        onClick={() => switchTo('wm')}
      >
        <span className="v10-layer-switch-icon" style={{ color: '#64748b' }}>◇</span>
        <span className="v10-layer-switch-label v10-layer-switch-label-full">Working Memory</span>
        <span className="v10-layer-switch-label v10-layer-switch-label-compact">WM</span>
        {counts.wm > 0 && <span className="v10-layer-switch-count">{counts.wm}</span>}
      </button>
      <button
        className={`v10-layer-switch-btn ${active === 'swm' ? 'active' : ''}`}
        data-layer="swm"
        aria-label="Shared Working Memory"
        title="Shared Working Memory"
        onClick={() => switchTo('swm')}
      >
        <span className="v10-layer-switch-icon" style={{ color: '#f59e0b' }}>◈</span>
        <span className="v10-layer-switch-label v10-layer-switch-label-full">Shared Working Memory</span>
        <span className="v10-layer-switch-label v10-layer-switch-label-compact">SWM</span>
        {counts.swm > 0 && <span className="v10-layer-switch-count">{counts.swm}</span>}
      </button>
      <button
        className={`v10-layer-switch-btn ${active === 'vm' ? 'active' : ''}`}
        data-layer="vm"
        aria-label="Verifiable Memory"
        title="Verifiable Memory"
        onClick={() => switchTo('vm')}
      >
        <span className="v10-layer-switch-icon" style={{ color: '#22c55e' }}>◉</span>
        <span className="v10-layer-switch-label v10-layer-switch-label-full">Verifiable Memory</span>
        <span className="v10-layer-switch-label v10-layer-switch-label-compact">VM</span>
        {counts.vm > 0 && <span className="v10-layer-switch-count">{counts.vm}</span>}
      </button>
      <span className="v10-layer-switch-separator" aria-hidden="true" />
      <button
        className={`v10-layer-switch-btn ${active === 'graph-overview' ? 'active' : ''}`}
        data-layer="graph-overview"
        aria-label="Subgraphs"
        title="Subgraphs"
        onClick={() => switchTo('graph-overview')}
      >
        <span className="v10-layer-switch-icon">⌬</span>
        <span className="v10-layer-switch-label">Subgraphs</span>
      </button>
      <div
        ref={moreRef}
        className="v10-layer-more"
        onBlur={(event) => {
          if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
            setMoreOpen(false);
          }
        }}
      >
        <button
          type="button"
          className={`v10-layer-switch-btn v10-layer-more-btn ${active === 'query' ? 'active' : ''}`}
          data-layer="query"
          aria-haspopup="menu"
          aria-expanded={moreOpen}
          aria-label="More Context Graph views"
          title="More Context Graph views"
          onClick={() => setMoreOpen(open => !open)}
        >
          <span className="v10-layer-switch-icon">⋯</span>
          <span className="v10-layer-switch-label">More</span>
        </button>
        {moreOpen && (
          <div className="v10-layer-more-menu" role="menu">
            <button
              type="button"
              role="menuitem"
              className={`v10-layer-more-item ${active === 'query' ? 'active' : ''}`}
              onClick={() => switchTo('query')}
            >
              <span className="v10-layer-switch-icon">⟐</span>
              Query Catalogue
            </button>
          </div>
        )}
      </div>
      <div className="v10-layer-switcher-spacer" />
      <div className="v10-layer-switcher-actions">
        <button className="v10-layer-action-btn" onClick={onShare} aria-label="Share Context Graph">
          <span className="v10-layer-action-icon">⤴</span>
          <span className="v10-layer-action-label">Share</span>
        </button>
        <button className="v10-layer-action-btn" onClick={onImport} aria-label="Import into Context Graph">
          <span className="v10-layer-action-icon">↑</span>
          <span className="v10-layer-action-label">Import</span>
        </button>
        <button className="v10-layer-action-btn" onClick={onRefresh} aria-label="Refresh Context Graph data">
          <span className="v10-layer-action-icon">↻</span>
        </button>
      </div>
    </div>
  );
}

// ─── Project Header Strip ────────────────────────────────────
// Persistent project chrome that stays visible across every route
// (overview / layer / sub-graph). Fixes the "I lost the project header
// when I drilled into decisions" problem by surfacing project identity +
// the current sub-graph breadcrumb from a single place. Compact enough
// that it doesn't compete with the big ProjectOverviewCard on the
// overview route itself.
export function ProjectHeaderStrip({
  cg,
  profile,
  activeSubGraph,
  onClearSubGraph,
}: {
  cg: { id: string; name?: string; description?: string };
  profile: ReturnType<typeof useProjectProfile>;
  activeSubGraph: ReturnType<typeof useProjectProfile>['forSubGraph'] extends (s: string) => infer R ? R | null : null;
  onClearSubGraph: () => void;
}) {
  const name = cg.name || profile.displayName || cg.id;
  return (
    <div
      className="v10-project-strip"
      style={{
        '--sg-color': activeSubGraph?.color ?? profile.primaryColor,
      } as React.CSSProperties}
    >
      <span
        className="v10-project-strip-dot"
        style={{ background: profile.primaryColor }}
      />
      <button
        type="button"
        className="v10-project-strip-name"
        onClick={activeSubGraph ? onClearSubGraph : undefined}
        disabled={!activeSubGraph}
        title={activeSubGraph ? 'Back to context graph overview' : cg.id}
      >
        {name}
      </button>
      {activeSubGraph ? (
        <>
          <span className="v10-project-strip-sep">›</span>
          <span className="v10-project-strip-sg">
            <span
              className="v10-project-strip-sg-icon"
              style={{ color: activeSubGraph.color }}
            >
              {activeSubGraph.icon ?? '•'}
            </span>
            {activeSubGraph.displayName ?? activeSubGraph.slug}
          </span>
          {activeSubGraph.description && (
            <span className="v10-project-strip-desc" title={activeSubGraph.description}>
              {activeSubGraph.description}
            </span>
          )}
        </>
      ) : (
        cg.description && (
          <span className="v10-project-strip-desc" title={cg.description}>
            {cg.description}
          </span>
        )
      )}
    </div>
  );
}

// ─── Project Overview Card ───────────────────────────────────

type OverviewRoleState = {
  label: string;
  title: string;
  tone: 'curator' | 'participant' | 'viewer' | 'unknown';
};
type OverviewAgentStatus = 'loading' | 'ok' | 'error';
type OverviewParticipantsStatus = 'loading' | 'ok' | 'error';
type OverviewAgentIdentity = Pick<AgentIdentity, 'agentDid'> & Partial<Pick<AgentIdentity, 'agentAddress'>>;

function overviewRoleState(
  cg: any,
  currentAgent: OverviewAgentIdentity | null,
  currentAgentStatus: OverviewAgentStatus,
  participants: string[],
  participantsStatus: OverviewParticipantsStatus,
): OverviewRoleState {
  const curator = typeof cg?.curator === 'string' ? cg.curator.trim() : '';
  const agentDid = currentAgent?.agentDid?.trim() ?? '';
  const agentIds = new Set(
    [currentAgent?.agentDid, currentAgent?.agentAddress]
      .filter((id): id is string => typeof id === 'string' && id.trim().length > 0)
      .map(canonicalAgentDid),
  );
  if (curator && agentDid && canonicalAgentDid(curator) === canonicalAgentDid(agentDid)) {
    return {
      label: 'Curator',
      title: 'This agent is the curator for this Context Graph.',
      tone: 'curator',
    };
  }
  if (cg?.callerInvolved === true) {
    if (curator && !agentDid && currentAgentStatus === 'loading') {
      return {
        label: 'Role checking',
        title: 'This agent is involved in this Context Graph; curator status is still loading.',
        tone: 'unknown',
      };
    }
    if (curator && !agentDid && currentAgentStatus === 'error') {
      return {
        label: 'Role unknown',
        title: 'This agent is involved in this Context Graph, but curator status could not be confirmed.',
        tone: 'unknown',
      };
    }
    return {
      label: 'Joined',
      title: 'This agent is involved in this Context Graph; curator status is shown only when identity metadata confirms it.',
      tone: 'participant',
    };
  }
  if (cg?.callerInvolved === false) {
    return {
      label: 'Not joined',
      title: 'This agent is not listed as curator or participant for this Context Graph.',
      tone: 'viewer',
    };
  }
  const isListedParticipant = participantsStatus === 'ok' && participants
    .map(participant => participant.trim())
    .filter(Boolean)
    .some(participant => agentIds.has(canonicalAgentDid(participant)));
  if (isListedParticipant) {
    return {
      label: 'Joined',
      title: 'This agent is listed as a participant for this Context Graph.',
      tone: 'participant',
    };
  }
  return {
    label: 'Role unknown',
    title: 'The node did not provide enough role metadata for this Context Graph.',
    tone: 'unknown',
  };
}

function overviewAccessAgentStat(
  cg: any,
  participants: string[],
  participantsStatus: OverviewParticipantsStatus,
) {
  const policy = normalizeAccessPolicy(cg?.accessPolicy);
  if (policy === 'public') {
    return {
      id: 'participants',
      value: 'Open',
      label: 'Public access',
      hint: 'Public Context Graphs do not have an authoritative allowlist count.',
    };
  }
  if (participantsStatus === 'loading') {
    return {
      id: 'participants',
      value: '...',
      label: 'Agents with access',
      hint: 'Participant list is loading.',
    };
  }
  if (participantsStatus === 'error') {
    return {
      id: 'participants',
      value: 'Unavailable',
      label: 'Agents with access',
      hint: 'Participant list unavailable; access count is unknown.',
    };
  }

  const agents = new Set(
    participants
      .map(agent => agent.trim())
      .filter(Boolean)
      .map(canonicalAgentDid),
  );
  const curator = typeof cg?.curator === 'string' ? cg.curator.trim() : '';
  if (curator) agents.add(canonicalAgentDid(curator));

  return {
    id: 'participants',
    value: agents.size.toLocaleString(),
    label: 'Agents with access',
    hint: 'Includes the curator plus allowlisted participants reported by the node.',
  };
}

function overviewAccessState(raw?: string): { label: string; title: string; tone: 'public' | 'curated' | 'unknown' } {
  const policy = normalizeAccessPolicy(raw);
  if (policy === 'public') {
    return {
      label: 'Public',
      title: 'Public Context Graph.',
      tone: 'public',
    };
  }
  if (policy === 'private') {
    return {
      label: 'Curated',
      title: 'Curated Context Graph with access controlled by the curator.',
      tone: 'curated',
    };
  }
  return {
    label: 'Access unknown',
    title: 'The node did not provide an access policy for this Context Graph.',
    tone: 'unknown',
  };
}

export function ProjectOverviewCard({
  cg,
  memory,
  participants,
  participantsStatus = 'ok',
  currentAgent,
  currentAgentStatus = 'ok',
  onSwitchLayer,
  onOpenPrimer,
}: {
  cg: any;
  memory: ReturnType<typeof useMemoryEntities>;
  participants: string[];
  participantsStatus?: OverviewParticipantsStatus;
  currentAgent?: OverviewAgentIdentity | null;
  currentAgentStatus?: OverviewAgentStatus;
  onSwitchLayer?: (layer: LayerView) => void;
  onOpenPrimer?: () => void;
}) {
  const { wm: working, swm: shared, vm: verified } = memory.counts;
  const layerSum = working + shared + verified;
  const role = overviewRoleState(cg, currentAgent ?? null, currentAgentStatus, participants, participantsStatus);
  const access = overviewAccessState(cg?.accessPolicy);
  const isPublicAccess = normalizeAccessPolicy(cg?.accessPolicy) === 'public';
  const accessAgentStat = overviewAccessAgentStat(cg, participants, participantsStatus);
  const layerStatuses = Object.values(memory.layerStatus ?? {});
  const unavailableLayerCount = layerStatuses.filter(status => status === 'error').length;
  const allLayerCountsUnavailable =
    !memory.loading && layerStatuses.length === 3 && unavailableLayerCount === 3;
  const hasUnavailableLayer = !memory.loading && unavailableLayerCount > 0;
  const statusHint = memory.loading
    ? 'Loading memory layers...'
    : allLayerCountsUnavailable || memory.error
        ? 'Live memory counts are unavailable.'
        : memory.partial || hasUnavailableLayer
          ? 'One or more layer counts are currently a lower bound.'
          : 'Canonical current-layer entity counts.';
  const totalEntitiesValue = allLayerCountsUnavailable ? 'Unavailable' : layerSum.toLocaleString();
  const pipeline = [
    {
      key: 'wm' as const,
      label: 'Working Memory',
      desc: 'Private staging area',
      count: working,
      status: memory.layerStatus?.wm ?? 'ok',
      color: '#64748b',
    },
    {
      key: 'swm' as const,
      label: 'Shared Working Memory',
      desc: 'Collaborative review',
      count: shared,
      status: memory.layerStatus?.swm ?? 'ok',
      color: '#f59e0b',
    },
    {
      key: 'vm' as const,
      label: 'Verifiable Memory',
      desc: 'Published on-chain',
      count: verified,
      status: memory.layerStatus?.vm ?? 'ok',
      color: '#22c55e',
    },
  ];
  const pipelineHasUnavailableLayer = pipeline.some(item => item.status === 'error');

  return (
    <div className="v10-po">
      <div className="v10-po-top">
        <span className="v10-po-dot" />
        <div className="v10-po-heading">
          <div className="v10-po-title">{cg.name || cg.id}</div>
          {cg.description && <div className="v10-po-desc">{cg.description}</div>}
        </div>
        <div className="v10-po-badges">
          <span className={`v10-po-badge ${role.tone}`} title={role.title}>{role.label}</span>
          <span className={`v10-po-badge ${access.tone}`} title={access.title}>{access.label}</span>
        </div>
      </div>
      <StatStrip
        className="v10-po-stat-strip"
        items={[
          { id: 'total', value: totalEntitiesValue, label: 'Total entities', hint: statusHint },
          accessAgentStat,
        ]}
      />
      <div className="v10-po-pipeline" aria-label="Knowledge Pipeline">
        <div className="v10-po-pipeline-head">
          <div>
            <div className="v10-po-section-title">Knowledge Pipeline</div>
            <div className="v10-po-section-desc">Entities move from private staging to shared review; published assertion bundles become Knowledge Assets with on-chain provenance.</div>
          </div>
          {onOpenPrimer && (
            <button type="button" className="v10-po-primer-link" onClick={onOpenPrimer}>
              What is a Context Graph?
            </button>
          )}
        </div>
        <div className="v10-po-pipeline-track" aria-hidden="true">
          {!pipelineHasUnavailableLayer && layerSum > 0
            ? pipeline.map(item => {
              const width = item.count > 0 ? (item.count / layerSum) * 100 : 0;
              return (
                <span
                  key={item.key}
                  className={`v10-po-pipeline-seg ${item.key}`}
                  style={{ width: `${width}%`, background: item.color }}
                />
              );
            })
            : <span className="v10-po-pipeline-empty" />}
        </div>
        <div className="v10-po-pipeline-steps">
          {pipeline.map((item, index) => (
            <button
              key={item.key}
              type="button"
              className={`v10-po-pipeline-step ${item.key}`}
              onClick={() => onSwitchLayer?.(item.key)}
              style={{ '--po-layer-color': item.color } as React.CSSProperties}
            >
              <span className="v10-po-pipeline-step-index">{index + 1}</span>
              <span className="v10-po-pipeline-step-copy">
                <span className="v10-po-pipeline-step-label">{item.label}</span>
                <span className="v10-po-pipeline-step-desc">{item.desc}</span>
              </span>
              <span className="v10-po-pipeline-step-count">
                {item.status === 'error'
                  ? 'Unavailable'
                  : `${item.count.toLocaleString()} ${layerNoun(item.key, item.count)}`}
              </span>
            </button>
          ))}
        </div>
      </div>
      {participants.length > 0 && (
        <div className="v10-po-participants">
          <div className="v10-po-participants-label">
            {isPublicAccess ? 'Known participants' : 'Allowlisted participants'}
          </div>
          <div className="v10-po-participants-list">
            {participants.map(addr => (
              <span key={addr} className="v10-po-participant" title={addr}>
                <span className="v10-po-participant-dot" style={{ background: '#3b82f6' }} />
                {addr.slice(0, 6)}…{addr.slice(-4)}
              </span>
            ))}
          </div>
        </div>
      )}
    </div>
  );
}

// ─── Pending Join Requests ───────────────────────────────────

export function PendingJoinRequestsBar({ contextGraphId, onParticipantsChanged }: { contextGraphId: string; onParticipantsChanged?: () => void }) {
  const [requests, setRequests] = useState<PendingJoinRequest[]>([]);
  const [processing, setProcessing] = useState<string | null>(null);

  useEffect(() => {
    listJoinRequests(contextGraphId)
      .then(data => setRequests(data.requests.filter(r => r.status === 'pending')))
      .catch(() => setRequests([]));
  }, [contextGraphId]);

  if (requests.length === 0) return null;

  const handleApprove = async (addr: string) => {
    setProcessing(addr);
    try {
      await approveJoinRequest(contextGraphId, addr);
      setRequests(prev => prev.filter(r => r.agentAddress !== addr));
      onParticipantsChanged?.();
    } catch { /* noop */ } finally { setProcessing(null); }
  };

  const handleReject = async (addr: string) => {
    setProcessing(addr);
    try {
      await rejectJoinRequest(contextGraphId, addr);
      setRequests(prev => prev.filter(r => r.agentAddress !== addr));
      onParticipantsChanged?.();
    } catch { /* noop */ } finally { setProcessing(null); }
  };

  return (
    <div className="v10-ph-join-requests" style={{ margin: '0 16px 8px', padding: '8px 12px', borderRadius: 8, background: 'var(--bg-surface)', border: '1px solid #f59e0b44' }}>
      <span style={{ fontSize: 9, fontWeight: 700, textTransform: 'uppercase' as const, letterSpacing: '0.8px', color: 'var(--text-tertiary)', marginBottom: 6, display: 'block' }}>
        Pending Join Requests <span className="v10-ph-join-badge">{requests.length}</span>
      </span>
      <div className="v10-ph-join-list">
        {requests.map(req => (
          <div key={req.agentAddress} className="v10-ph-join-item">
            <div className="v10-ph-join-info">
              <span className="v10-ph-join-name">{req.name || `${req.agentAddress.slice(0, 6)}…${req.agentAddress.slice(-4)}`}</span>
              <span className="v10-ph-join-addr" title={req.agentAddress}>{req.agentAddress.slice(0, 10)}…</span>
            </div>
            <div className="v10-ph-join-actions">
              <button className="v10-ph-join-btn approve" onClick={() => handleApprove(req.agentAddress)} disabled={processing === req.agentAddress}>
                {processing === req.agentAddress ? '…' : '✓ Approve'}
              </button>
              <button className="v10-ph-join-btn reject" onClick={() => handleReject(req.agentAddress)} disabled={processing === req.agentAddress}>✕</button>
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}

// ─── Layer graph panel (used by LayerContent in both inline and full views) ──

export function LayerGraphPanel({
  layer,
  triples,
  onNodeClick,
  contextGraphId,
}: {
  layer: 'wm' | 'swm' | 'vm';
  triples: Triple[];
  onNodeClick?: (node: any) => void;
  contextGraphId?: string;
}) {
  const { title } = LAYER_CONFIG[layer];

  // All URIs that already appear as subject or object in the base VM triples.
  // We pass this into the anchor hook so synthetic anchor→entity edges only
  // get emitted for entities that actually render, avoiding dangling anchors.
  const visibleEntityUris = useMemo(() => {
    if (layer !== 'vm') return undefined;
    const s = new Set<string>();
    for (const t of triples) {
      s.add(t.subject);
      // Literals (`"..."`) are never anchor roots; skip them.
      if (!t.object.startsWith('"')) s.add(t.object);
    }
    return s;
  }, [triples, layer]);

  // VM-only provenance decorations — synthetic anchor + agent identity
  // triples injected around every published KA root. Keeps the data on-disk
  // untouched; the "halo" of trust lives purely in the viz.
  const { anchors, decorationTriples } = useVerifiedMemoryAnchors(
    layer === 'vm' && contextGraphId ? contextGraphId : undefined,
    visibleEntityUris,
  );

  // SWM-only agent attribution — colours each root KA by the agent that
  // promoted it, so the graph reads as "who proposed what". Also surfaces
  // conflict nodes (multi-agent disagreement) and an agent palette for the
  // legend. No-op on WM / VM.
  const swmAttr = useSwmAttributions(layer === 'swm' && contextGraphId ? contextGraphId : undefined);

  const uniqueTriples = useMemo(() => {
    const seen = new Set<string>();
    const out: Triple[] = [];
    const push = (t: Triple) => {
      const key = `${t.subject}|${t.predicate}|${t.object}`;
      if (seen.has(key)) return;
      seen.add(key);
      out.push({ subject: t.subject, predicate: t.predicate, object: t.object } as Triple);
    };
    for (const t of triples) push(t);
    // Decoration triples are only produced for VM; for other layers the
    // hook returns an empty array so this loop is a no-op.
    for (const t of decorationTriples) push(t);
    return out;
  }, [triples, decorationTriples]);

  // Only SWM layer uses per-URI node tints — for the rest, classColors rules
  // so code graphs stay legible (Package purple / File blue / etc).
  const graphOptions = useMemo(
    () => buildLayerGraphOptions(layer, layer === 'swm' ? swmAttr.nodeColors : undefined),
    [layer, swmAttr.nodeColors],
  );
  const graphKey = `${contextGraphId ?? 'context'}:${layer}`;
  const swmAttributionPending = layer === 'swm' && Boolean(contextGraphId) && swmAttr.loading;

  if (uniqueTriples.length === 0) {
    return (
      <div className="v10-graph-view v10-graph-view-fill v10-layer-empty-shell">
        <EmptyState
          compact
          tone={toneForLayer(layer)}
          icon={LAYER_CONFIG[layer].icon}
          title={`No triples in ${title}`}
          description="The graph will appear when this layer has connected triples."
        />
      </div>
    );
  }

  if (swmAttributionPending) {
    return (
      <div className="v10-graph-view v10-graph-view-fill v10-layer-empty-shell">
        <EmptyState
          compact
          tone="swm"
          icon={LAYER_CONFIG.swm.icon}
          title="Loading Shared Working Memory attribution..."
          description="Agent colors are being prepared before the graph renders."
        />
      </div>
    );
  }

  return (
    <div className="v10-graph-view v10-graph-view-fill" style={{ position: 'relative' }}>
      <Suspense fallback={<span className="v10-graph-placeholder">Loading graph...</span>}>
        <RdfGraph
          key={graphKey}
          data={uniqueTriples}
          format="triples"
          options={graphOptions}
          style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }}
          onNodeClick={onNodeClick}
          initialFit
        />
      </Suspense>
      {layer === 'vm' && anchors.length > 0 && (
        <VerifiedGraphLegend anchors={anchors} />
      )}
      {layer === 'swm' && swmAttr.palette.length > 0 && (
        <SwmAttributionLegend palette={swmAttr.palette} conflicts={swmAttr.conflicts.length} />
      )}
    </div>
  );
}

// ─── SWM attribution legend (floating overlay) ────────────
// Pinned to the top-right of the Shared Working Memory graph, this swatch
// maps each palette slot to the agent who promoted the corresponding KA
// roots. Reads as "who said what" at a glance. When two or more agents
// touch the same entity, a separate amber "review" badge appears listing
// the conflict count — the mechanism works in the single-agent devnet
// (shows 0) and lights up organically once a second agent joins.
export function SwmAttributionLegend({ palette, conflicts }: { palette: AgentPaletteEntry[]; conflicts: number }) {
  return (
    <div className="v10-swm-legend" aria-label="SWM attribution legend">
      <div className="v10-swm-legend-row v10-swm-legend-head">
        <span>SWM attribution</span>
        <span className="v10-swm-legend-count">{palette.length} agent{palette.length === 1 ? '' : 's'}</span>
      </div>
      {palette.slice(0, 8).map(p => (
        <div key={p.agent} className="v10-swm-legend-row" title={p.agent}>
          <span className="v10-swm-legend-swatch" style={{ background: p.color }} />
          <span className="v10-swm-legend-label">{p.label}</span>
          <span className="v10-swm-legend-metric">{p.entityCount}</span>
        </div>
      ))}
      {conflicts > 0 && (
        <div className="v10-swm-legend-row v10-swm-legend-conflict">
          <span className="v10-swm-legend-swatch" style={{ background: '#f59e0b' }}>!</span>
          <span className="v10-swm-legend-label">In review</span>
          <span className="v10-swm-legend-metric">{conflicts}</span>
        </div>
      )}
    </div>
  );
}

// ─── Verifiable Memory graph legend (floating overlay) ─────────
// Lives in the top-right of the VM graph view. Explains the two new glyphs
// (gold anchor, lavender agent) introduced by `useVerifiedMemoryAnchors`
// so viewers can decode the graph at a glance. Also doubles as an anchor
// ledger: total anchors + distinct signers + latest publish time. This is
// where the "DKG secret sauce" gets called out explicitly.
export function VerifiedGraphLegend({ anchors }: { anchors: PublishAnchor[] }) {
  const signerCount = useMemo(() => {
    const s = new Set<string>();
    for (const a of anchors) for (const g of a.agents) s.add(g);
    return s.size;
  }, [anchors]);
  const latest = anchors[0]?.publishedAt;
  const latestLabel = (() => {
    if (!latest) return null;
    try {
      return new Date(latest).toLocaleString(undefined, {
        month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit',
      });
    } catch { return null; }
  })();

  return (
    <div className="v10-vm-legend" aria-label="Verifiable Memory legend">
      <div className="v10-vm-legend-row v10-vm-legend-head">
        <span>VM provenance layer</span>
        <span className="v10-vm-legend-count">{anchors.length} anchor{anchors.length === 1 ? '' : 's'}</span>
      </div>
      <div className="v10-vm-legend-row">
        <span className="v10-vm-legend-swatch" style={{ background: '#f5a524' }}>◉</span>
        <span className="v10-vm-legend-label">On-chain anchor</span>
      </div>
      <div className="v10-vm-legend-row">
        <span className="v10-vm-legend-swatch" style={{ background: '#c084fc' }}>◈</span>
        <span className="v10-vm-legend-label">Agent identity ({signerCount})</span>
      </div>
      <div className="v10-vm-legend-row">
        <span className="v10-vm-legend-swatch" style={{ background: '#4ade80' }}>—</span>
        <span className="v10-vm-legend-label">Signed / anchored edge</span>
      </div>
      {latestLabel && (
        <div className="v10-vm-legend-foot">Latest: {latestLabel}</div>
      )}
    </div>
  );
}

// ─── Memory Strip (expandable layer rows) ────────────────────

type MemoryStripLayer = 'wm' | 'swm' | 'vm';

export function MemoryStrip({
  memory,
  onSwitchLayer,
  onSelectEntity,
  contextGraphId,
  onNodeClick,
  expandedLayer,
  onExpandedLayerChange,
  expandTabs,
  onExpandTabChange,
}: {
  memory: ReturnType<typeof useMemoryEntities>;
  onSwitchLayer: (layer: LayerView) => void;
  onSelectEntity: (uri: string) => void;
  contextGraphId: string;
  onNodeClick?: (node: any) => void;
  expandedLayer?: MemoryStripLayer | null;
  onExpandedLayerChange?: (layer: MemoryStripLayer | null) => void;
  expandTabs?: Record<MemoryStripLayer, LayerContentTab>;
  onExpandTabChange?: (layer: MemoryStripLayer, tab: LayerContentTab) => void;
}) {
  const [localExpanded, setLocalExpanded] = useState<MemoryStripLayer | null>(null);
  const [localExpandTabs, setLocalExpandTabs] = useState<Record<MemoryStripLayer, LayerContentTab>>({
    wm: 'items',
    swm: 'items',
    vm: 'items',
  });
  const expanded = expandedLayer !== undefined ? expandedLayer : localExpanded;
  const activeExpandTabs = expandTabs ?? localExpandTabs;
  const profile = useProjectProfileContext();

  const layerEntities = useMemo(() => {
    const wm: MemoryEntity[] = [];
    const swm: MemoryEntity[] = [];
    const vm: MemoryEntity[] = [];
    for (const e of memory.entityList) {
      if (e.trustLevel === 'verified') vm.push(e);
      else if (e.trustLevel === 'shared') swm.push(e);
      else wm.push(e);
    }
    return { wm, swm, vm };
  }, [memory.entityList]);

  const layerTripleCounts = useMemo(() => {
    let wm = 0, swm = 0, vm = 0;
    for (const t of memory.allTriples) {
      if (t.layer === 'verified') vm++;
      else if (t.layer === 'shared') swm++;
      else wm++;
    }
    return { wm, swm, vm };
  }, [memory.allTriples]);

  const toggleExpand = (layer: MemoryStripLayer) => {
    const next = expanded === layer ? null : layer;
    if (onExpandedLayerChange) onExpandedLayerChange(next);
    else setLocalExpanded(next);
  };

  const handleExpandTab = (layer: MemoryStripLayer, tab: LayerContentTab) => {
    if (onExpandTabChange) onExpandTabChange(layer, tab);
    else setLocalExpandTabs(prev => ({ ...prev, [layer]: tab }));
  };

  const layers: Array<{
    key: MemoryStripLayer;
    label: string;
    color: string;
    icon: string;
    entities: MemoryEntity[];
    count: number;
    promoteLabel: string | null;
    viewLayer: LayerView;
  }> = [
    { key: 'wm', label: 'Working Memory', color: '#64748b', icon: '◇', entities: layerEntities.wm, count: memory.counts.wm, promoteLabel: 'Promote All → Shared', viewLayer: 'wm' },
    { key: 'swm', label: 'Shared Working Memory', color: '#f59e0b', icon: '◈', entities: layerEntities.swm, count: memory.counts.swm, promoteLabel: 'Publish to Verifiable Memory', viewLayer: 'swm' },
    { key: 'vm', label: 'Verifiable Memory', color: '#22c55e', icon: '◉', entities: layerEntities.vm, count: memory.counts.vm, promoteLabel: null, viewLayer: 'vm' },
  ];

  return (
    <div className="v10-memory-strip">
      {layers.map(layer => {
        const isExpanded = expanded === layer.key;
        const activeTab = activeExpandTabs[layer.key] ?? 'items';
        return (
          <React.Fragment key={layer.key}>
            <div
              className={`v10-memory-layer ${layer.key} ${isExpanded ? 'expanded' : ''}`}
              onClick={() => toggleExpand(layer.key)}
            >
              <div className="v10-layer-label">
                <span className="v10-layer-abbr">{layer.label}</span>
                <span className="v10-layer-count">{layer.count}</span>
              </div>
              <div className="v10-layer-items">
                <span className="v10-layer-chevron">▸</span>
                {layer.entities.length === 0 && (
                  <EmptyState
                    inline
                    tone={toneForLayer(layer.key)}
                    icon={layer.icon}
                    title={`No ${layerNoun(layer.key, 2).toLowerCase()} yet`}
                  />
                )}
                {layer.entities.slice(0, 6).map(e => {
                  const { icon } = entityMeta(e, profile);
                  return (
                    <div key={e.uri} className="v10-layer-chip" style={{ borderColor: `${layer.color}40` }}>
                      <span className="v10-chip-dot" style={{ background: layer.color }} />
                      <span className="v10-chip-text">{e.label}</span>
                      <span className="v10-chip-meta">{icon}</span>
                    </div>
                  );
                })}
                {layer.entities.length > 6 && (
                  <span className="v10-chip-meta">+{layer.entities.length - 6} more</span>
                )}
              </div>
            </div>
            <div className={`v10-layer-expand-content ${isExpanded ? 'open' : ''}`}>
              {isExpanded && (
                <MemoryStripExpanded
                  layerKey={layer.key as 'wm' | 'swm' | 'vm'}
                  entities={layer.entities}
                  tripleCount={layerTripleCounts[layer.key as 'wm' | 'swm' | 'vm']}
                  contextGraphId={contextGraphId}
                  memory={memory}
                  activeTab={activeTab as LayerContentTab}
                  onTabChange={tab => handleExpandTab(layer.key, tab)}
                  onSelectEntity={onSelectEntity}
                  onNodeClick={onNodeClick}
                  onSwitchLayer={() => onSwitchLayer(layer.viewLayer)}
                />
              )}
            </div>
          </React.Fragment>
        );
      })}
    </div>
  );
}

// Thin wrapper that computes the layer's triples once and renders LayerContent with a footer.
export function MemoryStripExpanded({
  layerKey,
  entities,
  tripleCount,
  contextGraphId,
  memory,
  activeTab,
  onTabChange,
  onSelectEntity,
  onNodeClick,
  onSwitchLayer,
}: {
  layerKey: 'wm' | 'swm' | 'vm';
  entities: MemoryEntity[];
  tripleCount: number;
  contextGraphId: string;
  memory: ReturnType<typeof useMemoryEntities>;
  activeTab: LayerContentTab;
  onTabChange: (tab: LayerContentTab) => void;
  onSelectEntity: (uri: string) => void;
  onNodeClick?: (node: any) => void;
  onSwitchLayer: () => void;
}) {
  const layerTriples = useLayerTriples(memory, layerKey);
  return (
    <LayerContent
      layer={layerKey}
      entities={entities}
      tripleCount={tripleCount}
      layerTriples={layerTriples}
      contextGraphId={contextGraphId}
      memory={memory}
      activeTab={activeTab}
      onTabChange={onTabChange}
      onSelectEntity={onSelectEntity}
      onNodeClick={onNodeClick}
      footer={
        <div className="v10-layer-expand-footer">
          <button
            className="v10-layer-expand-footer-btn"
            onClick={e => {
              e.stopPropagation();
              onSwitchLayer();
            }}
          >
            View full layer →
          </button>
        </div>
      }
    />
  );
}

// ─── Generative Widget Components ─────────────────────────────

export function GenWidget({ title, agent, footnote, dismissed, onDismiss, children }: {
  title: string;
  agent?: string;
  footnote?: string;
  dismissed?: boolean;
  onDismiss?: () => void;
  children: React.ReactNode;
}) {
  return (
    <div className={`v10-gen-widget ${dismissed ? 'dissolved' : ''}`}>
      <div className="v10-gen-widget-header">
        <span className="v10-gen-widget-title">{title}</span>
        <div className="v10-gen-widget-right">
          {agent && (
            <span className="v10-gen-widget-agent">
              <span className="v10-gen-widget-agent-dot" />
              {agent}
            </span>
          )}
          {onDismiss && (
            <button className="v10-gen-widget-dismiss" onClick={onDismiss}>✕</button>
          )}
        </div>
      </div>
      <div className="v10-gen-widget-body">{children}</div>
      {footnote && <div className="v10-gen-widget-footnote">{footnote}</div>}
    </div>
  );
}

export function TypeBreakdownWidget({ entities }: { entities: MemoryEntity[] }) {
  const profile = useProjectProfileContext();
  const breakdown = useMemo(() => {
    const counts = new Map<string, { icon: string; count: number }>();
    for (const e of entities) {
      const { icon, type } = entityMeta(e, profile);
      const existing = counts.get(type);
      if (existing) existing.count++;
      else counts.set(type, { icon, count: 1 });
    }
    return [...counts.entries()].sort((a, b) => b[1].count - a[1].count);
  }, [entities, profile]);

  if (breakdown.length === 0) return null;

  return (
    <GenWidget title="Entity Types">
      <StatStrip
        compact
        items={breakdown.map(([type, { icon, count }]) => ({
          id: type,
          label: `${icon} ${type}`,
          value: count,
        }))}
      />
    </GenWidget>
  );
}

export function LayerStatsWidget({ entities, entityCount, triples, layer }: {
  entities: MemoryEntity[];
  entityCount: number;
  triples: number;
  layer: 'wm' | 'swm' | 'vm';
}) {
  const docCount = useMemo(
    () => entities.filter(e => e.properties.has('http://dkg.io/ontology/sourceContentType')).length,
    [entities]
  );
  const totalConns = useMemo(
    () => entities.reduce((sum, e) => sum + e.connections.length, 0),
    [entities]
  );
  const avgConns = entities.length > 0 ? (totalConns / entities.length).toFixed(1) : '0';
  return (
    <GenWidget title="Layer Stats">
      <StatStrip
        compact
        layer={layer}
        items={[
          { id: 'entities', label: layerNoun(layer, entityCount), value: entityCount },
          { id: 'triples', label: 'Triples', value: triples },
          { id: 'connections', label: 'Connections', value: totalConns },
          { id: 'avg', label: 'Avg. connections / entity', value: avgConns },
          ...(docCount > 0 ? [{ id: 'documents', label: 'Documents', value: docCount }] : []),
        ]}
      />
    </GenWidget>
  );
}

export function LayerActionsWidget({ layer, count, contextGraphId, onComplete }: {
  layer: 'wm' | 'swm';
  count: number;
  contextGraphId: string;
  onComplete: () => void;
}) {
  const [busy, setBusy] = useState(false);
  const [result, setResult] = useState<string | null>(null);
  const [error, setError] = useState<string | null>(null);
  const isWm = layer === 'wm';

  const handleAction = useCallback(async () => {
    setBusy(true);
    setError(null);
    setResult(null);
    try {
      if (isWm) {
        const assertions = await listAssertions(contextGraphId, 'wm');
        let promoted = 0;
        for (const a of assertions) {
          const res = await promoteAssertion(contextGraphId, a.name);
          promoted += res.promotedCount;
        }
        setResult(`Promoted ${promoted} triple${promoted !== 1 ? 's' : ''} to Shared Memory`);
      } else {
        await publishSharedMemory(contextGraphId);
        setResult('Published to Verifiable Memory');
      }
      onComplete?.();
    } catch (err: any) {
      setError(err.message ?? 'Action failed');
    } finally {
      setBusy(false);
    }
  }, [isWm, contextGraphId, onComplete]);

  if (count === 0) return null;
  const color = isWm ? '#f59e0b' : '#22c55e';
  const target = isWm ? 'Shared Working Memory' : 'Verifiable Memory';
  const noun = layerNoun(layer, count).toLowerCase();

  return (
    <GenWidget title={isWm ? 'Promote' : 'Publish'} footnote={`Moves ${noun} from this layer to ${target}.`}>
      <div className="v10-decision-context" style={{ marginBottom: 10 }}>
        {count} {noun} in this layer can be {isWm ? 'promoted to Shared Working Memory for collaborative review' : 'published to Verifiable Memory on-chain'}.
      </div>
      {result && <div style={{ fontSize: 11, color: 'var(--text-success)', marginBottom: 8 }}>✓ {result}</div>}
      {error && <div style={{ fontSize: 11, color: 'var(--text-danger)', marginBottom: 8 }}>✕ {error}</div>}
      <div className="v10-decision-actions">
        <button
          className={isWm ? 'v10-decision-btn approve' : 'v10-decision-btn primary-cta publish-vm'}
          style={isWm
            ? { borderColor: `${color}50`, color: 'var(--text-warning)', background: `${color}15`, opacity: busy ? 0.5 : 1 }
            : { opacity: busy ? 0.5 : 1 }}
          disabled={busy}
          onClick={handleAction}
        >
          {busy ? '...' : (isWm ? '✓ Promote All → Shared' : '◉ Publish to Verifiable Memory')}
        </button>
      </div>
    </GenWidget>
  );
}

// ─── Horizontal widget strip (stats + types + CTA) for the Entities tab ──

export function LayerWidgetStrip({ layer, entities, entityCount, tripleCount, contextGraphId, onComplete }: {
  layer: 'wm' | 'swm' | 'vm';
  entities: MemoryEntity[];
  entityCount: number;
  tripleCount: number;
  contextGraphId?: string;
  onComplete?: () => void;
}) {
  if (entityCount === 0) {
    return (
      <div className="v10-layer-widgets-strip empty">
        <EmptyState
          compact
          tone={toneForLayer(layer)}
          icon={LAYER_CONFIG[layer].icon}
          title={`No ${layerNoun(layer, 2).toLowerCase()} yet`}
          description={
            layer === 'wm'
              ? 'Import data or chat with agents to populate Working Memory.'
              : layer === 'swm'
                ? 'Promote entities from Working Memory to share them with the team.'
                : 'Publish entities from Shared Working Memory to verify them on-chain.'
          }
        />
      </div>
    );
  }
  return (
    <div className="v10-layer-widgets-strip">
      <div className="v10-layer-widgets-strip-stats">
        <LayerStatsWidget entities={entities} entityCount={entityCount} triples={tripleCount} layer={layer} />
        <TypeBreakdownWidget entities={entities} />
      </div>
      {(layer === 'wm' || layer === 'swm') && (
        <div className="v10-layer-widgets-strip-action">
          <LayerActionsWidget layer={layer} count={entityCount} contextGraphId={contextGraphId} onComplete={onComplete} />
        </div>
      )}
    </div>
  );
}

// ─── Enhanced Entity list (sorted by triple count, with type pill) ──────

export function EntityList({
  entities,
  layerKey,
  layerIcon,
  onSelectEntity,
  onOpenAgent,
  externallySorted = false,
  sortLabel,
  headerExtra,
  timestampPredicate,
}: {
  entities: MemoryEntity[];
  layerKey: 'wm' | 'swm' | 'vm';
  layerIcon: string;
  onSelectEntity: (uri: string) => void;
  onOpenAgent?: (uri: string) => void;
  /** Skip the internal triple-count sort — caller already ordered `entities`. */
  externallySorted?: boolean;
  /** Hint text shown next to the count (e.g. "newest first"). Defaults to
   *  "sorted by triples" when the list does its own sort. */
  sortLabel?: string;
  /** Optional control element rendered to the right of the count
   *  (e.g. a `<select>` for sort mode). */
  headerExtra?: ReactNode;
  /** Predicate (e.g. dcterms:created) whose object value should be rendered
   *  as a relative timestamp on each entity card. Pass `binding.timelinePredicate`
   *  for sub-graphs that have one. */
  timestampPredicate?: string;
}) {
  const profile = useProjectProfileContext();
  const agents = useAgentsContext();
  const noun = layerNoun(layerKey, entities.length).toLowerCase();
  const sorted = useMemo(() => {
    if (externallySorted) return entities;
    const copy = [...entities];
    copy.sort((a, b) => {
      const aCount = a.connections.length + a.properties.size;
      const bCount = b.connections.length + b.properties.size;
      return bCount - aCount;
    });
    return copy;
  }, [entities, externallySorted]);

  const trustLabel = layerKey === 'vm' ? 'Verified' : layerKey === 'swm' ? 'Shared' : 'Working';

  if (entities.length === 0) {
    return (
      <div className="v10-entity-list empty">
        <EmptyState
          compact
          tone={toneForLayer(layerKey)}
          icon={layerIcon}
          title={`No ${layerNoun(layerKey, 2).toLowerCase()} yet`}
          description={`This view will fill when matching ${layerNoun(layerKey, 2).toLowerCase()} are available.`}
        />
      </div>
    );
  }

  const hint = sortLabel ?? (externallySorted ? 'click to open' : 'sorted by triples · click to open');

  return (
    <div className="v10-entity-list">
      <div className="v10-entity-list-header">
        <span className="v10-entity-list-count">{sorted.length} {noun}</span>
        <span className="v10-entity-list-hint">{hint}</span>
        {headerExtra && <span className="v10-entity-list-extra">{headerExtra}</span>}
      </div>
      {sorted.map(e => {
        const { icon, type } = entityMeta(e, profile);
        const tripleCount = e.connections.length + e.properties.size;
        const authorUri = entityAuthorUri(e);
        const author = authorUri ? agents?.get(authorUri) : null;
        const ts = timestampPredicate ? entityTimestamp(e, timestampPredicate) : null;
        return (
          <div
            key={e.uri}
            className="v10-entity-card"
            onClick={(ev) => { ev.stopPropagation(); onSelectEntity(e.uri); }}
          >
            <span className="v10-entity-card-icon">{icon}</span>
            <div className="v10-entity-card-main">
              <div className="v10-entity-card-title">{e.label}</div>
              <div className="v10-entity-card-meta">
                {(author || authorUri) && (
                  <AgentChip
                    agent={author ?? undefined}
                    fallbackUri={authorUri ?? undefined}
                    size="sm"
                    onOpenAgent={onOpenAgent}
                  />
                )}
                {type && type !== 'Entity' && (
                  <span className="v10-entity-type-pill">{icon} {type}</span>
                )}
                {ts != null && (
                  <span
                    className="v10-entity-card-timestamp"
                    title={new Date(ts).toLocaleString()}
                  >
                    {formatRelativeTime(ts)}
                  </span>
                )}
                <span className="v10-entity-card-triples">{tripleCount} triples</span>
              </div>
            </div>
            <span className={`v10-trust-badge ${layerKey}`}>
              {layerIcon} {trustLabel}
            </span>
          </div>
        );
      })}
    </div>
  );
}

// ─── Shared LayerContent (tabs + bodies, used by both inline strip and full page) ──
// (LayerContentTab is declared at the top of the file.)

export function LayerContent({
  layer,
  entities,
  tripleCount,
  layerTriples,
  contextGraphId,
  memory,
  activeTab,
  onTabChange,
  onSelectEntity,
  onNodeClick,
  footer,
}: {
  layer: 'wm' | 'swm' | 'vm';
  entities: MemoryEntity[];
  tripleCount: number;
  layerTriples: Triple[];
  contextGraphId: string;
  memory: ReturnType<typeof useMemoryEntities>;
  activeTab: LayerContentTab;
  onTabChange: (tab: LayerContentTab) => void;
  onSelectEntity: (uri: string) => void;
  onNodeClick?: (node: any) => void;
  footer?: React.ReactNode;
}) {
  const config = LAYER_CONFIG[layer];
  const itemsLabel = layerNoun(layer, 2);
  const vmLayerStatus = memory.layerStatus?.vm ?? (memory.loading ? 'loading' : memory.error ? 'error' : 'ok');
  const isInitialVerifiedMemoryLoad = layer === 'vm' && vmLayerStatus === 'loading' && entities.length === 0;
  const isVerifiedMemoryUnavailable = layer === 'vm' && vmLayerStatus === 'error' && entities.length === 0;
  const isEmptyVerifiedMemory = layer === 'vm' && vmLayerStatus === 'ok' && entities.length === 0;
  const entityCount = memory.counts[layer];

  const handleTab = (tab: LayerContentTab) => (e: React.MouseEvent) => {
    e.stopPropagation();
    onTabChange(tab);
  };

  return (
    <>
      <div className="v10-layer-expand-tabs">
        <button
          className={`v10-layer-expand-tab ${activeTab === 'items' ? 'active' : ''}`}
          onClick={handleTab('items')}
        >{itemsLabel}</button>
        {layer !== 'vm' && (
          <button
            className={`v10-layer-expand-tab ${activeTab === 'assertions' ? 'active' : ''}`}
            onClick={handleTab('assertions')}
          >Assertions</button>
        )}
        <button
          className={`v10-layer-expand-tab ${activeTab === 'graph' ? 'active' : ''}`}
          onClick={handleTab('graph')}
        >Graph</button>
        <button
          className={`v10-layer-expand-tab ${activeTab === 'docs' ? 'active' : ''}`}
          onClick={handleTab('docs')}
        >Documents</button>
      </div>

      {activeTab === 'items' && (
        <div className="v10-layer-expand-body entities-tab" data-cg-scroll-key={`layer:${layer}:items`}>
          {layer === 'vm' && !isInitialVerifiedMemoryLoad && !isVerifiedMemoryUnavailable && (
            <VerifiedMemoryHeroBanner
              entities={entities}
              tripleCount={tripleCount}
              contextGraphId={contextGraphId}
            />
          )}
          {isInitialVerifiedMemoryLoad ? (
            <div className="v10-layer-widgets-strip empty">
              <EmptyState
                compact
                tone="vm"
                icon={LAYER_CONFIG.vm.icon}
                title="Loading Verifiable Memory..."
                description="Knowledge Assets are being read from the verified layer."
              />
            </div>
          ) : isVerifiedMemoryUnavailable ? (
            <div className="v10-layer-widgets-strip empty">
              <EmptyState
                compact
                tone="vm"
                icon={LAYER_CONFIG.vm.icon}
                title="Verifiable Memory status unavailable."
                description="This node could not read the verified layer right now."
              />
            </div>
          ) : !isEmptyVerifiedMemory && (
            <>
              <LayerWidgetStrip
                layer={layer}
                entities={entities}
                entityCount={entityCount}
                tripleCount={tripleCount}
                contextGraphId={contextGraphId}
                onComplete={memory.refresh}
              />
              <EntityList
                entities={entities}
                layerKey={layer}
                layerIcon={config.icon}
                onSelectEntity={onSelectEntity}
              />
            </>
          )}
          {footer}
        </div>
      )}

      {activeTab === 'assertions' && layer !== 'vm' && (
        <div className="v10-layer-expand-body full-width">
          <AssertionsList
            contextGraphId={contextGraphId}
            layer={layer}
            onComplete={memory.refresh}
            scrollKey={`layer:${layer}:assertions`}
          />
        </div>
      )}

      {activeTab === 'graph' && (
        <div className="v10-layer-expand-body full-width" data-cg-scroll-key={`layer:${layer}:graph`}>
          <LayerGraphPanel
            layer={layer}
            triples={layerTriples}
            onNodeClick={onNodeClick}
            contextGraphId={contextGraphId}
          />
        </div>
      )}

      {activeTab === 'docs' && (
        <div className="v10-layer-expand-body full-width">
          <DocumentsList
            entities={entities}
            contextGraphId={contextGraphId}
            scrollKey={`layer:${layer}:docs`}
          />
        </div>
      )}
    </>
  );
}

// ─── Verifiable Memory Hero Banner ──────────────────────────────
// Sits at the top of the VM tab's "Knowledge Assets" view. Pulls together
// the DKG "secret sauce" into a compact visual: anchoring, consensus,
// agent identity — the verifiability elements that justify the VM's cost.
export function VerifiedMemoryHeroBanner({ entities, tripleCount, contextGraphId }: {
  entities: MemoryEntity[];
  tripleCount: number;
  contextGraphId: string;
}) {
  const totalAssets = entities.length;
  const typeSet = new Set<string>();
  for (const e of entities) for (const t of e.types) typeSet.add(t);
  const typeCount = typeSet.size;

  if (totalAssets === 0) {
    return (
      <div className="v10-vm-hero v10-vm-hero-empty">
        <div className="v10-vm-hero-title">
          <span className="v10-vm-hero-badge">◉ Verifiable Memory</span>
          <div className="v10-vm-hero-heading">
            <span className="v10-vm-hero-headline">Nothing published yet</span>
            <span className="v10-vm-hero-context" title={contextGraphId}>
              Context Graph: {contextGraphId}
            </span>
          </div>
        </div>
        <EmptyState
          compact
          className="v10-vm-empty-state"
          tone="vm"
          icon={LAYER_CONFIG.vm.icon}
          title="No Knowledge Assets yet."
          description="Publish entities from Shared Working Memory to verify them on-chain."
        />
      </div>
    );
  }

  return (
    <div className="v10-vm-hero">
      <div className="v10-vm-hero-title">
        <span className="v10-vm-hero-badge">◉ Verifiable Memory</span>
        <div className="v10-vm-hero-heading">
          <span className="v10-vm-hero-headline">On-chain anchored · cryptographically signed</span>
          <span className="v10-vm-hero-context" title={contextGraphId}>
            Context Graph: {contextGraphId}
          </span>
        </div>
      </div>
      <StatStrip
        className="v10-vm-hero-stats"
        layer="vm"
        items={[
          { id: 'assets', value: totalAssets, label: 'Knowledge Assets' },
          { id: 'triples', value: tripleCount.toLocaleString(), label: 'Verified Triples' },
          { id: 'types', value: typeCount, label: 'Entity Types' },
        ]}
      />
      <div className="v10-vm-hero-strip">
        <div className="v10-vm-hero-chip" title="Multi-agent endorsement">
          <span className="v10-vm-hero-chip-dot" style={{ background: '#22c55e' }} />
          Consensus
        </div>
        <div className="v10-vm-hero-chip" title="Published to the DKG blockchain anchor">
          <span className="v10-vm-hero-chip-dot" style={{ background: '#3b82f6' }} />
          On-chain
        </div>
        <div className="v10-vm-hero-chip" title="Each contribution bound to a DID">
          <span className="v10-vm-hero-chip-dot" style={{ background: '#a855f7' }} />
          Agent Identity
        </div>
        <div className="v10-vm-hero-chip" title="Tamper-evident via content hashing">
          <span className="v10-vm-hero-chip-dot" style={{ background: '#f59e0b' }} />
          Content Hash
        </div>
      </div>
    </div>
  );
}

function contextGraphQueryTemplate(contextGraphId: string): string {
  return `SELECT ?g ?s ?p ?o WHERE {
  GRAPH ?g { ?s ?p ?o }
  ${contextGraphQueryFilter(contextGraphId)}
}
LIMIT 1000`;
}

const CONTEXT_GRAPH_QUERY_SUBGRAPH = '__context_graph';
const USER_QUERY_CATALOG_SLUG = 'ui-saved-queries';
const USER_QUERY_CATALOG_NAME = 'Saved queries';
const USER_QUERY_CATALOG_DESCRIPTION = 'Queries saved from the Query tab.';
const PROFILE_NS = 'http://dkg.io/ontology/profile/';
const SCHEMA_NS = 'http://schema.org/';
const RDF_TYPE = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type';
const XSD_INTEGER = 'http://www.w3.org/2001/XMLSchema#integer';
type SavedCatalogQuery = QueryCatalog['queries'][number];

function sparqlString(value: string): string {
  return JSON.stringify(value);
}

function contextGraphQueryFilter(contextGraphId: string): string {
  const cgUri = `did:dkg:context-graph:${contextGraphId}`;
  const cgPrefix = `${cgUri}/`;
  return `FILTER(
    (
      STR(?g) = ${sparqlString(cgUri)} ||
      STRSTARTS(STR(?g), ${sparqlString(cgPrefix)})
    ) &&
    !CONTAINS(STR(?g), "/_private")
  )`;
}

function contextGraphBuiltInCatalog(contextGraphId: string): QueryCatalog {
  const catalogSlug = 'whole-context-graph';
  const catalogName = 'Whole graph';
  const catalogRank = -100;
  const subGraph = CONTEXT_GRAPH_QUERY_SUBGRAPH;
  const withQueryDefaults = (query: {
    slug: string;
    name: string;
    description: string;
    sparql: string;
    resultColumn?: string;
    rank: number;
  }) => ({
    subGraph,
    catalogSlug,
    catalogName,
    catalogDescription: 'Built-in queries for the full context graph.',
    catalogRank,
    resultColumn: '',
    ...query,
  });

  return {
    slug: catalogSlug,
    subGraph,
    name: catalogName,
    description: 'Built-in queries for the full context graph.',
    rank: catalogRank,
    queries: [
      withQueryDefaults({
        slug: 'all-triples',
        name: 'All triples',
        description: 'Show triples across every public graph in this context graph.',
        sparql: contextGraphQueryTemplate(contextGraphId),
        resultColumn: 'o',
        rank: 1,
      }),
      withQueryDefaults({
        slug: 'graphs',
        name: 'Graphs',
        description: 'List public named graphs and triple counts.',
        sparql: `SELECT ?g (COUNT(*) AS ?triples) WHERE {
  GRAPH ?g { ?s ?p ?o }
  ${contextGraphQueryFilter(contextGraphId)}
}
GROUP BY ?g
ORDER BY DESC(?triples)
LIMIT 100`,
        resultColumn: 'g',
        rank: 2,
      }),
      withQueryDefaults({
        slug: 'types',
        name: 'Types',
        description: 'Count entities by RDF type across the context graph.',
        sparql: `SELECT ?type (COUNT(DISTINCT ?s) AS ?entities) WHERE {
  GRAPH ?g { ?s a ?type }
  ${contextGraphQueryFilter(contextGraphId)}
}
GROUP BY ?type
ORDER BY DESC(?entities)
LIMIT 100`,
        resultColumn: 'type',
        rank: 3,
      }),
    ],
  };
}

function querySlug(value: string): string {
  const slug = value
    .toLowerCase()
    .normalize('NFKD')
    .replace(/[\u0300-\u036f]/g, '')
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-+|-+$/g, '')
    .slice(0, 48);
  return slug || 'saved-query';
}

function profileUri(contextGraphId: string, kind: 'catalog' | 'query', slug: string): string {
  return `urn:dkg:profile:${encodeURIComponent(contextGraphId)}:${kind}:${encodeURIComponent(slug)}`;
}

function literal(value: string): string {
  return JSON.stringify(value);
}

function intLiteral(value: number): string {
  return `"${value}"^^<${XSD_INTEGER}>`;
}

function buildSavedQueryWrite(contextGraphId: string, name: string, description: string, sparql: string): {
  query: SavedCatalogQuery;
  quads: Array<{ subject: string; predicate: string; object: string; graph: string }>;
} {
  const rank = Date.now();
  const slug = `${querySlug(name)}-${rank.toString(36)}`;
  const catalogUri = profileUri(contextGraphId, 'catalog', USER_QUERY_CATALOG_SLUG);
  const queryUri = profileUri(contextGraphId, 'query', slug);
  const query: SavedCatalogQuery = {
    slug,
    subGraph: CONTEXT_GRAPH_QUERY_SUBGRAPH,
    catalogSlug: USER_QUERY_CATALOG_SLUG,
    catalogName: USER_QUERY_CATALOG_NAME,
    catalogDescription: USER_QUERY_CATALOG_DESCRIPTION,
    catalogRank: 50,
    name,
    description: description || undefined,
    sparql,
    resultColumn: '',
    rank,
  };
  const quads = [
    { subject: catalogUri, predicate: RDF_TYPE, object: `${PROFILE_NS}QueryCatalog`, graph: '' },
    { subject: catalogUri, predicate: `${PROFILE_NS}forSubGraph`, object: literal(CONTEXT_GRAPH_QUERY_SUBGRAPH), graph: '' },
    { subject: catalogUri, predicate: `${PROFILE_NS}displayName`, object: literal(USER_QUERY_CATALOG_NAME), graph: '' },
    { subject: catalogUri, predicate: `${SCHEMA_NS}description`, object: literal(USER_QUERY_CATALOG_DESCRIPTION), graph: '' },
    { subject: catalogUri, predicate: `${PROFILE_NS}rank`, object: intLiteral(50), graph: '' },
    { subject: queryUri, predicate: RDF_TYPE, object: `${PROFILE_NS}SavedQuery`, graph: '' },
    { subject: queryUri, predicate: `${PROFILE_NS}forSubGraph`, object: literal(CONTEXT_GRAPH_QUERY_SUBGRAPH), graph: '' },
    { subject: queryUri, predicate: `${PROFILE_NS}inCatalog`, object: catalogUri, graph: '' },
    { subject: queryUri, predicate: `${PROFILE_NS}displayName`, object: literal(name), graph: '' },
    { subject: queryUri, predicate: `${PROFILE_NS}sparqlQuery`, object: literal(sparql), graph: '' },
    { subject: queryUri, predicate: `${PROFILE_NS}rank`, object: intLiteral(rank), graph: '' },
  ];
  if (description) {
    quads.push({ subject: queryUri, predicate: `${SCHEMA_NS}description`, object: literal(description), graph: '' });
  }
  return { query, quads };
}

function appendSavedQueryCatalog(catalogs: QueryCatalog[], query: SavedCatalogQuery): QueryCatalog[] {
  const key = `${CONTEXT_GRAPH_QUERY_SUBGRAPH}|${USER_QUERY_CATALOG_SLUG}`;
  const next = catalogs.map(catalog => ({
    ...catalog,
    queries: [...catalog.queries],
  }));
  const existing = next.find(catalog => `${catalog.subGraph}|${catalog.slug}` === key);
  if (existing) {
    existing.queries = [...existing.queries, query].sort((a, b) => a.rank - b.rank || a.name.localeCompare(b.name));
    return next;
  }
  return [
    ...next,
    {
      slug: USER_QUERY_CATALOG_SLUG,
      subGraph: CONTEXT_GRAPH_QUERY_SUBGRAPH,
      name: USER_QUERY_CATALOG_NAME,
      description: USER_QUERY_CATALOG_DESCRIPTION,
      rank: 50,
      queries: [query],
    },
  ];
}

function bindingValue(v: unknown): string {
  if (v == null) return '';
  if (typeof v === 'string') return v;
  if (typeof v === 'object' && 'value' in (v as any)) return String((v as any).value);
  return String(v);
}

function shortenBindingValue(value: string): string {
  if (!value) return '—';
  if (value.length <= 140) return value;
  return `${value.slice(0, 110)}...${value.slice(-24)}`;
}

export function ContextGraphQueryView({ contextGraphId }: { contextGraphId: string }) {
  const profile = useProjectProfileContext();
  const defaultQuery = useMemo(() => contextGraphQueryTemplate(contextGraphId), [contextGraphId]);
  const [draftQuery, setDraftQuery] = useState(defaultQuery);
  const [activeQuery, setActiveQuery] = useState(defaultQuery);
  const [activeCatalogQueryKey, setActiveCatalogQueryKey] = useState<string | null>(null);
  const [localSavedCatalogs, setLocalSavedCatalogs] = useState<QueryCatalog[]>([]);
  const [saveOpen, setSaveOpen] = useState(false);
  const [saveName, setSaveName] = useState('');
  const [saveDescription, setSaveDescription] = useState('');
  const [saving, setSaving] = useState(false);
  const [saveError, setSaveError] = useState<string | null>(null);
  const [saveMessage, setSaveMessage] = useState<string | null>(null);
  const builtInCatalog = useMemo(() => contextGraphBuiltInCatalog(contextGraphId), [contextGraphId]);
  const queryCatalogs = useMemo(
    () => [builtInCatalog, ...localSavedCatalogs, ...(profile?.queryCatalogs ?? [])],
    [builtInCatalog, localSavedCatalogs, profile?.queryCatalogs],
  );

  useEffect(() => {
    setDraftQuery(defaultQuery);
    setActiveQuery(defaultQuery);
    setActiveCatalogQueryKey(null);
    setLocalSavedCatalogs([]);
    setSaveOpen(false);
    setSaveName('');
    setSaveDescription('');
    setSaveError(null);
    setSaveMessage(null);
  }, [defaultQuery]);

  const { data, loading, error, refresh } = useFetch(
    () => executeQuery(activeQuery, contextGraphId),
    [activeQuery, contextGraphId],
    0,
  );

  const rows = useMemo(
    () => (data as any)?.result?.bindings ?? (data as any)?.results?.bindings ?? [],
    [data],
  );

  const columns = useMemo(() => {
    const out: string[] = [];
    for (const row of rows) {
      for (const key of Object.keys(row)) {
        if (!out.includes(key)) out.push(key);
      }
    }
    return out;
  }, [rows]);

  const runQuery = useCallback(() => {
    const next = draftQuery.trim();
    if (!next) return;
    setActiveCatalogQueryKey(null);
    if (next === activeQuery) {
      refresh();
      return;
    }
    setActiveQuery(next);
  }, [activeQuery, draftQuery, refresh]);

  const resetQuery = useCallback(() => {
    setDraftQuery(defaultQuery);
    setActiveCatalogQueryKey(null);
    if (activeQuery === defaultQuery) refresh();
    else setActiveQuery(defaultQuery);
  }, [activeQuery, defaultQuery, refresh]);

  const runCatalogQuery = useCallback((key: string, sparql: string) => {
    const next = sparql.trim();
    if (!next) return;
    setActiveCatalogQueryKey(key);
    setDraftQuery(next);
    if (activeQuery === next) {
      refresh();
      return;
    }
    setActiveQuery(next);
  }, [activeQuery, refresh]);

  const openSaveForm = useCallback(() => {
    const firstLine = draftQuery.trim().split('\n').find(line => line.trim());
    setSaveName(firstLine ? firstLine.replace(/\s+/g, ' ').slice(0, 60) : '');
    setSaveDescription('');
    setSaveError(null);
    setSaveMessage(null);
    setSaveOpen(true);
  }, [draftQuery]);

  const saveQuery = useCallback(async (event: React.FormEvent) => {
    event.preventDefault();
    const name = saveName.trim();
    const description = saveDescription.trim();
    const sparql = draftQuery.trim();
    if (!name) {
      setSaveError('Name is required.');
      return;
    }
    if (!sparql) {
      setSaveError('Query is empty.');
      return;
    }

    setSaving(true);
    setSaveError(null);
    setSaveMessage(null);
    try {
      const { query, quads } = buildSavedQueryWrite(contextGraphId, name, description, sparql);
      await writeProfileQueryCatalog(contextGraphId, quads);
      setLocalSavedCatalogs(prev => appendSavedQueryCatalog(prev, query));
      const key = `${query.subGraph}|${query.catalogSlug}|${query.slug}`;
      setActiveCatalogQueryKey(key);
      setDraftQuery(query.sparql);
      if (activeQuery === query.sparql) refresh();
      else setActiveQuery(query.sparql);
      setSaveName('');
      setSaveDescription('');
      setSaveOpen(false);
      setSaveMessage('Saved to catalog.');
    } catch (err: any) {
      setSaveError(err?.message ?? 'Failed to save query.');
    } finally {
      setSaving(false);
    }
  }, [activeQuery, contextGraphId, draftQuery, refresh, saveDescription, saveName]);

  return (
    <div className="v10-memory-layer-view v10-cg-query-view">
      <div className="v10-mlv-header">
        <span className="v10-mlv-icon">⟐</span>
        <div>
          <h2 className="v10-mlv-title">Context Graph Query</h2>
          <p className="v10-mlv-desc">Run SPARQL against the full context graph, across sub-graphs and memory layers.</p>
        </div>
      </div>

      <div className="v10-cg-query-catalog">
        <span className="v10-subgraph-savedqueries-label">Query catalog</span>
        {queryCatalogs.map((catalog) => {
          const isBuiltIn = catalog.subGraph === CONTEXT_GRAPH_QUERY_SUBGRAPH;
          const binding = isBuiltIn ? undefined : profile?.forSubGraph(catalog.subGraph);
          const color = binding?.color ?? '#38bdf8';
          const label = isBuiltIn ? catalog.name : `${binding?.displayName ?? catalog.subGraph}: ${catalog.name}`;
          return (
            <React.Fragment key={`${catalog.subGraph}|${catalog.slug}`}>
              <span
                className="v10-subgraph-savedqueries-label"
                title={catalog.description || label}
                style={{ marginLeft: 8, opacity: 0.8 }}
              >
                {label}
              </span>
              {catalog.queries.map((q) => {
                const key = `${q.subGraph}|${q.catalogSlug}|${q.slug}`;
                const isActive = activeCatalogQueryKey === key && activeQuery === q.sparql;
                return (
                  <button
                    key={key}
                    type="button"
                    className={`v10-subgraph-savedquery${isActive ? ' active' : ''}`}
                    onClick={() => runCatalogQuery(key, q.sparql)}
                    title={q.description || q.name}
                    style={{ '--sg-color': color } as React.CSSProperties}
                  >
                    <span className="v10-subgraph-savedquery-glyph">{isActive ? '✓' : '◎'}</span>
                    {q.name}
                  </button>
                );
              })}
            </React.Fragment>
          );
        })}
      </div>

      <div className="v10-cg-query-editor">
        <textarea
          className="v10-cg-query-textarea"
          value={draftQuery}
          onChange={(e) => {
            setDraftQuery(e.target.value);
            setActiveCatalogQueryKey(null);
          }}
          spellCheck={false}
        />
        <div className="v10-cg-query-actions">
          <button className="v10-mlv-run-btn" type="button" onClick={runQuery}>Run</button>
          <button className="v10-mlv-save-btn" type="button" onClick={openSaveForm}>Save</button>
          <button className="v10-mlv-clear-btn" type="button" onClick={resetQuery}>Reset</button>
        </div>
      </div>

      {saveOpen && (
        <form className="v10-cg-query-save-panel" onSubmit={saveQuery}>
          <label className="v10-cg-query-save-field">
            <span>Name</span>
            <input
              type="text"
              value={saveName}
              onChange={(e) => setSaveName(e.target.value)}
              placeholder="Query name"
              maxLength={80}
            />
          </label>
          <label className="v10-cg-query-save-field">
            <span>Description</span>
            <input
              type="text"
              value={saveDescription}
              onChange={(e) => setSaveDescription(e.target.value)}
              placeholder="Optional"
              maxLength={180}
            />
          </label>
          <div className="v10-cg-query-save-actions">
            <button type="button" className="v10-mlv-clear-btn" onClick={() => setSaveOpen(false)} disabled={saving}>Cancel</button>
            <button type="submit" className="v10-mlv-run-btn" disabled={saving}>{saving ? 'Saving...' : 'Save query'}</button>
          </div>
        </form>
      )}

      {saveMessage && <p className="v10-mlv-status" style={{ color: 'var(--text-success)' }}>{saveMessage}</p>}
      {saveError && <p className="v10-mlv-status" style={{ color: 'var(--text-danger)' }}>{saveError}</p>}

      <div className="v10-cg-query-results">
        {loading && (
          <EmptyState
            compact
            tone="query"
            icon="?"
            title="Loading query results..."
          />
        )}
        {error && (
          <EmptyState
            compact
            tone="danger"
            icon="!"
            title="Query failed"
            description={error}
          />
        )}

        {!loading && !error && rows.length === 0 && (
          <EmptyState
            compact
            tone="query"
            icon="?"
            title="No results for this query."
            description="Adjust the query or run a saved query against this Context Graph."
          />
        )}

        {!loading && !error && rows.length > 0 && (
          <div className="v10-mlv-table-wrap">
            <div className="v10-mlv-result-count">{rows.length} result{rows.length === 1 ? '' : 's'}</div>
            <table className="v10-mlv-table">
              <thead>
                <tr>
                  {columns.map((column) => (
                    <th key={column}>{column}</th>
                  ))}
                </tr>
              </thead>
              <tbody>
                {rows.map((row: Record<string, unknown>, index: number) => (
                  <tr key={index}>
                    {columns.map((column) => {
                      const value = bindingValue(row[column]);
                      return (
                        <td key={column} className="v10-mlv-cell" title={value}>
                          {shortenBindingValue(value)}
                        </td>
                      );
                    })}
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
        )}
      </div>
    </div>
  );
}

// Small helper: compute unique triples for a given layer slice of memory.

export function AssertionsList({ contextGraphId, layer, onComplete, scrollKey }: {
  contextGraphId: string;
  layer: 'wm' | 'swm';
  onComplete: () => void;
  scrollKey?: string;
}) {
  const { data: assertions, loading, refresh } = useFetch(
    () => listAssertions(contextGraphId, layer),
    [contextGraphId, layer],
    0
  );
  const [busy, setBusy] = useState<string | null>(null);
  const [result, setResult] = useState<string | null>(null);
  const [error, setError] = useState<string | null>(null);

  const handlePromote = useCallback(async (name: string) => {
    setBusy(name);
    setResult(null);
    setError(null);
    try {
      if (layer === 'wm') {
        const res = await promoteAssertion(contextGraphId, name);
        setResult(`Promoted ${res.promotedCount} triples to Shared Memory`);
      } else {
        await publishSharedMemory(contextGraphId);
        setResult('Published to Verifiable Memory');
      }
      refresh();
      onComplete();
    } catch (err: any) {
      setError(err.message ?? 'Action failed');
    } finally {
      setBusy(null);
    }
  }, [contextGraphId, layer, refresh, onComplete]);

  const handlePromoteAll = useCallback(async () => {
    if (!assertions?.length) return;
    setBusy('__all__');
    setResult(null);
    setError(null);
    try {
      if (layer === 'wm') {
        let total = 0;
        for (const a of assertions) {
          const res = await promoteAssertion(contextGraphId, a.name);
          total += res.promotedCount;
        }
        setResult(`Promoted ${total} triples across ${assertions.length} assertion${assertions.length !== 1 ? 's' : ''}`);
      } else {
        await publishSharedMemory(contextGraphId);
        setResult('Published all to Verifiable Memory');
      }
      refresh();
      onComplete();
    } catch (err: any) {
      setError(err.message ?? 'Action failed');
    } finally {
      setBusy(null);
    }
  }, [assertions, contextGraphId, layer, refresh, onComplete]);

  const scrollRootStyle = { flex: 1, overflow: 'auto' } as const;

  if (loading) {
    return (
      <div className="v10-layer-empty-shell" style={scrollRootStyle} data-cg-scroll-key={scrollKey}>
        <EmptyState
          compact
          tone={toneForLayer(layer)}
          icon={LAYER_CONFIG[layer].icon}
          title="Loading assertions..."
        />
      </div>
    );
  }

  if (!assertions?.length) {
    return (
      <div className="v10-layer-empty-shell" style={scrollRootStyle} data-cg-scroll-key={scrollKey}>
        <EmptyState
          tone={toneForLayer(layer)}
          icon={LAYER_CONFIG[layer].icon}
          title={layer === 'swm'
            ? 'No Shared Working Memory assertions listed yet.'
            : 'No Working Memory assertions yet.'}
          description={layer === 'swm'
            ? 'Promoted assertion contents are available as Shared Working Memory entities. The assertion list will populate once promoted assertions are exposed by the node.'
            : 'Create or import data to stage assertions in Working Memory.'}
        />
      </div>
    );
  }

  const actionLabel = layer === 'wm' ? 'Promote → Shared' : 'Publish to VM';
  const actionAllLabel = layer === 'wm' ? 'Promote All → Shared' : 'Publish all to Verifiable Memory';

  return (
    <div style={scrollRootStyle} data-cg-scroll-key={scrollKey}>
      <div style={{ padding: '8px 16px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', borderBottom: '1px solid var(--border-subtle)' }}>
        <span style={{ fontSize: 11, color: 'var(--text-tertiary)' }}>{assertions.length} assertion{assertions.length !== 1 ? 's' : ''}</span>
        <button
          className={`v10-layer-expand-footer-btn ${layer === 'wm' ? 'promote' : 'publish'}`}
          disabled={busy !== null}
          onClick={handlePromoteAll}
          style={{ opacity: busy === '__all__' ? 0.5 : 1 }}
        >
          {busy === '__all__' ? '...' : actionAllLabel}
        </button>
      </div>
      {result && <div style={{ padding: '6px 16px', fontSize: 11, color: 'var(--text-success)' }}>✓ {result}</div>}
      {error && <div style={{ padding: '6px 16px', fontSize: 11, color: 'var(--text-danger)' }}>✕ {error}</div>}
      {assertions.map(a => (
        <div key={a.name} className="v10-item-row">
          <span className="v10-item-icon">▤</span>
          <div className="v10-item-info">
            <div className="v10-item-name" style={{ fontFamily: 'var(--font-mono)', fontSize: 11 }}>{a.name}</div>
            <div className="v10-item-meta-row">
              {a.tripleCount != null && <span className="v10-item-count">{a.tripleCount} triples</span>}
            </div>
          </div>
          <button
            className={`v10-layer-expand-footer-btn ${layer === 'wm' ? 'promote' : 'publish'}`}
            disabled={busy !== null}
            onClick={ev => { ev.stopPropagation(); handlePromote(a.name); }}
            style={{ opacity: busy === a.name ? 0.5 : 1, flexShrink: 0 }}
          >
            {busy === a.name ? '...' : actionLabel}
          </button>
        </div>
      ))}
    </div>
  );
}

// ─── Full Layer Detail View (WM / SWM / VM) ─────────────────

export function LayerDetailView({
  layer,
  memory,
  onNodeClick,
  onSelectEntity,
  contextGraphId,
  activeTab,
  onTabChange,
}: {
  layer: 'wm' | 'swm' | 'vm';
  memory: ReturnType<typeof useMemoryEntities>;
  onNodeClick: (node: any) => void;
  onSelectEntity: (uri: string) => void;
  contextGraphId: string;
  activeTab: LayerContentTab;
  onTabChange: (tab: LayerContentTab) => void;
}) {
  const config = LAYER_CONFIG[layer];

  const entities = useMemo(
    () => memory.entityList.filter(e => e.trustLevel === config.trustLevel),
    [memory.entityList, config.trustLevel],
  );
  const layerTriples = useLayerTriples(memory, layer);

  return (
    <div className="v10-layer-detail">
      <div className="v10-layer-detail-header">
        <span className="v10-layer-detail-icon" style={{ color: config.color }}>{config.icon}</span>
        <div>
          <div className="v10-layer-detail-title">{config.title}</div>
          <div className="v10-layer-detail-desc">{config.desc}</div>
        </div>
        <div className="v10-layer-detail-actions" />
      </div>
      <div className="v10-layer-detail-body">
        <LayerContent
          layer={layer}
          entities={entities}
          tripleCount={layerTriples.length}
          layerTriples={layerTriples}
          contextGraphId={contextGraphId}
          memory={memory}
          activeTab={activeTab}
          onTabChange={onTabChange}
          onSelectEntity={onSelectEntity}
          onNodeClick={onNodeClick}
        />
      </div>
    </div>
  );
}

// ─── Documents List ──────────────────────────────────────────


export function DocumentsList({
  entities,
  contextGraphId,
  scrollKey,
}: {
  entities: MemoryEntity[];
  contextGraphId?: string;
  scrollKey?: string;
}) {
  const openTab = useTabsStore(s => s.openTab);

  const docs = useMemo(() => {
    return entities.filter(e => e.properties.has(SOURCE_CONTENT_TYPE));
  }, [entities]);

  const handleOpenDoc = (e: MemoryEntity) => {
    // Tab id shape: `doc:<contextGraphId>|<fileRef>|<contentType>`. The `|`
    // delimiter mirrors the `agent:` tab convention; it cannot appear in a
    // `urn:dkg:file:keccak256:<hex>` ref, a context-graph id, or a MIME type,
    // so the decoder can split unambiguously. We keep the FULL file ref
    // (algorithm prefix intact) — stripping `keccak256:` would make the
    // daemon misread the digest as sha256 and 404. When no source file is
    // linked we encode the entity uri; the viewer detects the missing
    // `urn:dkg:file:` prefix and shows a friendly empty state.
    //
    // The content-type hint must describe the *chosen* ref, not the original
    // upload: for a converter-backed import (e.g. PDF → markdown intermediate)
    // the markdown-form ref holds markdown bytes while `sourceContentType` is
    // `application/pdf`. `resolveDocRef` returns `text/markdown` for the
    // markdown form and only forwards `sourceContentType` for the raw source.
    const markdownFormRef = e.connections.find(c => c.predicate === MARKDOWN_FORM)?.targetUri;
    const sourceFileRef = e.connections.find(c => c.predicate === SOURCE_FILE)?.targetUri;
    const sourceContentType = e.properties.get(SOURCE_CONTENT_TYPE)?.[0] ?? '';
    const { ref, contentType } = resolveDocRef(markdownFormRef, sourceFileRef, sourceContentType);
    openTab({
      id: encodeDocTabId(contextGraphId ?? '', ref ?? e.uri, contentType),
      label: e.label,
      closable: true,
      icon: '📄',
    });
  };

  if (docs.length === 0) {
    return (
      <div className="v10-docs-placeholder v10-layer-empty-shell" style={{ flex: 1 }} data-cg-scroll-key={scrollKey}>
        <EmptyState
          compact
          title="No documents in this layer yet."
          description="Import a file to add document-backed entities."
        />
      </div>
    );
  }

  return (
    <div className="v10-layer-detail-content" data-cg-scroll-key={scrollKey}>
      <div className="v10-items-list">
        {docs.map(e => {
          const contentType = e.properties.get(SOURCE_CONTENT_TYPE)?.[0] ?? '';
          const fileRef = e.connections.find(c => c.predicate === MARKDOWN_FORM || c.predicate === SOURCE_FILE)?.targetUri;
          const fileEntity = fileRef ? entities.find(f => f.uri === fileRef) : undefined;
          const size = fileEntity?.properties.get(DKG_SIZE)?.[0] ?? e.properties.get(DKG_SIZE)?.[0];
          return (
            <div key={e.uri} className="v10-item-row" onClick={() => handleOpenDoc(e)}>
              <span className="v10-item-icon">📄</span>
              <div className="v10-item-info">
                <div className="v10-item-name">{e.label}</div>
                <div className="v10-item-meta-row">
                  {contentType && <span className="v10-item-type">{contentType}</span>}
                  {size && <span className="v10-item-count">· {Math.round(parseInt(size) / 1024)}KB</span>}
                </div>
              </div>
              <button className="v10-item-promote-btn" onClick={ev => { ev.stopPropagation(); handleOpenDoc(e); }}>Open →</button>
            </div>
          );
        })}
      </div>
    </div>
  );
}

// ─── Provenance Bar ──────────────────────────────────────────

export function ProvenanceBar({ memory }: { memory: ReturnType<typeof useMemoryEntities> }) {
  const latestEvent = useMemo(() => {
    if (memory.counts.vm > 0) return `${memory.counts.vm} ${layerNoun('vm', memory.counts.vm).toLowerCase()} verified on-chain`;
    if (memory.counts.swm > 0) return `${memory.counts.swm} ${layerNoun('swm', memory.counts.swm).toLowerCase()} in shared working memory`;
    if (memory.counts.wm > 0) return `${memory.counts.wm} ${layerNoun('wm', memory.counts.wm).toLowerCase()} in working memory`;
    return 'No activity yet';
  }, [memory.counts]);

  return (
    <div className="v10-provenance-bar">
      <span className="v10-provenance-bar-dot" />
      <span>{latestEvent}</span>
    </div>
  );
}

// ─── KA Detail View (split-pane: content+triples+graph | provenance) ─────


// Small sub-graph badge rendered next to cross-references so the user
// sees "oh, this link takes me to the github sub-graph" before clicking.
export function SubGraphBadge({
  entity,
  profile,
}: {
  entity: MemoryEntity;
  profile: ReturnType<typeof useProjectProfileContext>;
}) {
  // Pick the first non-meta sub-graph the entity has triples in. Most
  // entities only live in one sub-graph; when they span more, the
  // primary (lowest rank) binding wins.
  const slug = useMemo(() => {
    for (const s of entity.subGraphs) {
      if (s !== 'meta') return s;
    }
    return null;
  }, [entity.subGraphs]);
  if (!slug || !profile) return null;
  const binding = profile.forSubGraph(slug);
  const color = binding?.color ?? '#64748b';
  const icon = binding?.icon ?? '•';
  const label = binding?.displayName ?? slug;
  return (
    <span
      className="v10-subgraph-badge"
      style={{ '--sg-color': color } as React.CSSProperties}
      title={`In sub-graph: ${label}`}
    >
      <span className="v10-subgraph-badge-icon" style={{ color }}>{icon}</span>
      <span className="v10-subgraph-badge-label">{label}</span>
    </span>
  );
}

// ─── Verify on DKG CTA ───────────────────────────────────────
// Two-step progression driven by the profile:
//   WM  -> SWM  : promoteAssertion(sourceAssertion, [uri])  ("Propose…")
//   SWM -> VM   : publishSharedMemory([uri])                ("Ratify…")
// Labels, hints and the promote-path assertion name all come from the
// profile ontology (EntityTypeBinding + SubGraphBinding). A book-research
// project that imports into "character-sheet" / "topic-index" assertions
// and declares "Submit for editorial review" / "Publish as canon" on its
// character binding gets the exact same button with the right copy — no
// UI code changes.
//
// Returns null when no binding declares a promoteLabel / publishLabel
// for the entity's type (correct for derived artifacts like code:File
// or github:Commit that shouldn't be manually progressed).
export function VerifyOnDkgButton({
  entity,
  contextGraphId,
  onVerified,
}: {
  entity: MemoryEntity;
  contextGraphId: string;
  onVerified: () => void;
}) {
  const profile = useProjectProfileContext();
  const [busy, setBusy] = useState(false);
  const [result, setResult] = useState<PublishResult | { promotedCount: number } | null>(null);
  const [resultKind, setResultKind] = useState<'promote' | 'publish' | null>(null);
  const [error, setError] = useState<string | null>(null);

  const layer = entity.trustLevel;

  // Resolve the first profile binding whose rdf:type matches the entity
  // AND that declares the copy for this layer's transition. If nothing
  // matches, the CTA is suppressed entirely.
  const binding = useMemo(() => {
    if (!profile) return null;
    for (const t of entity.types) {
      const b = profile.forType(t);
      if (!b) continue;
      if (layer === 'working'  && (b.promoteLabel || b.promoteHint)) return b;
      if (layer === 'shared'   && (b.publishLabel || b.publishHint)) return b;
    }
    return null;
  }, [entity.types, profile, layer]);

  const sgBinding = useMemo(() => {
    if (!profile) return null;
    for (const s of entity.subGraphs) {
      if (s === 'meta') continue;
      const b = profile.forSubGraph(s);
      if (b?.sourceAssertion) return b;
    }
    return null;
  }, [entity.subGraphs, profile]);

  if (layer === 'verified') return null;
  if (!binding) return null;

  const action = layer === 'working'
    ? {
        kind: 'promote' as const,
        label:    binding.promoteLabel ?? 'Promote to Shared Memory',
        hint:     binding.promoteHint  ?? 'Shares this entity with the team.',
        busyCopy: 'Sharing…',
        disabled: !sgBinding?.sourceAssertion,
        disabledReason: !sgBinding?.sourceAssertion
          ? `No sourceAssertion declared on the sub-graph profile — add profile:sourceAssertion to the SubGraphBinding for "${[...entity.subGraphs].filter(s => s !== 'meta')[0] ?? '?'}".`
          : null,
      }
    : {
        kind: 'publish' as const,
        label:    binding.publishLabel ?? 'Verify on DKG',
        hint:     binding.publishHint  ?? 'Anchors this entity on-chain.',
        busyCopy: 'Anchoring…',
        disabled: false,
        disabledReason: null,
      };

  const handle = async () => {
    if (action.disabled) return;
    setBusy(true);
    setError(null);
    setResult(null);
    setResultKind(action.kind);
    try {
      if (action.kind === 'promote') {
        const r = await promoteAssertion(
          contextGraphId,
          sgBinding!.sourceAssertion!,
          [entity.uri],
        );
        setResult(r);
      } else {
        const r = await publishSharedMemory(contextGraphId, [entity.uri]);
        setResult(r);
      }
      onVerified();
    } catch (err: any) {
      setError(err?.message ?? 'Action failed');
    } finally {
      setBusy(false);
    }
  };

  const isPublishResult = (r: typeof result): r is PublishResult => !!r && 'status' in r;

  return (
    <div className={`v10-ka-verify v10-ka-verify-${action.kind}`}>
      <div className="v10-ka-verify-head">
        <span className="v10-ka-verify-arrow">
          {action.kind === 'promote' ? '◈' : '◉'}
        </span>
        <span className="v10-ka-verify-title">{action.label}</span>
      </div>
      <div className="v10-ka-verify-hint">{action.hint}</div>
      {action.disabledReason && (
        <div className="v10-ka-verify-err">! {action.disabledReason}</div>
      )}
      {!result && (
        <button
          className={`v10-ka-verify-btn ${action.kind}`}
          onClick={handle}
          disabled={busy || action.disabled}
        >
          {busy ? action.busyCopy : action.label}
        </button>
      )}
      {error && <div className="v10-ka-verify-err">✕ {error}</div>}
      {result && resultKind === 'promote' && !isPublishResult(result) && (
        <div className="v10-ka-verify-ok">
          <div className="v10-ka-verify-ok-row">
            <span className="v10-ka-verify-ok-lbl">Promoted</span>
            <span className="v10-ka-verify-ok-val">
              ✓ {result.promotedCount} triple{result.promotedCount === 1 ? '' : 's'} now in Shared Memory
            </span>
          </div>
          <div className="v10-ka-verify-hint" style={{ marginTop: 6 }}>
            Refresh the entity to see the next step appear.
          </div>
        </div>
      )}
      {result && resultKind === 'publish' && isPublishResult(result) && (
        <div className="v10-ka-verify-ok">
          <div className="v10-ka-verify-ok-row">
            <span className="v10-ka-verify-ok-lbl">Status</span>
            <span className="v10-ka-verify-ok-val">✓ {result.status}</span>
          </div>
          {result.txHash && (
            <div className="v10-ka-verify-ok-row">
              <span className="v10-ka-verify-ok-lbl">TX hash</span>
              <span className="v10-ka-verify-ok-val mono" title={result.txHash}>
                {result.txHash.slice(0, 10)}…{result.txHash.slice(-6)}
              </span>
            </div>
          )}
          {result.blockNumber != null && (
            <div className="v10-ka-verify-ok-row">
              <span className="v10-ka-verify-ok-lbl">Block</span>
              <span className="v10-ka-verify-ok-val mono">#{result.blockNumber}</span>
            </div>
          )}
          {result.kas?.[0]?.tokenId && (
            <div className="v10-ka-verify-ok-row">
              <span className="v10-ka-verify-ok-lbl">Token</span>
              <span className="v10-ka-verify-ok-val mono">#{result.kas[0].tokenId}</span>
            </div>
          )}
        </div>
      )}
    </div>
  );
}

export function KADetailView({ entity, allEntities, allTriples, onNavigate, onClose, contextGraphId, onRefresh, onOpenAgent }: {
  entity: MemoryEntity;
  allEntities: Map<string, MemoryEntity>;
  allTriples: Triple[];
  onNavigate: (uri: string) => void;
  onClose: () => void;
  contextGraphId: string;
  onRefresh: () => void;
  onOpenAgent?: (uri: string) => void;
}) {
  const [pane, setPane] = useState<KAPane>('content');
  const profile = useProjectProfileContext();
  const agents = useAgentsContext();
  const { icon, type } = entityMeta(entity, profile);
  const desc = getDescription(entity);
  const authorUri = entityAuthorUri(entity);
  const author = authorUri ? agents?.get(authorUri) ?? null : null;
  const layerBadge = entity.trustLevel === 'verified' ? 'vm' : entity.trustLevel === 'shared' ? 'swm' : 'wm';
  const layerLabel = entity.trustLevel === 'verified' ? 'Verifiable Memory' : entity.trustLevel === 'shared' ? 'Shared Working Memory' : 'Working Memory';
  const detailNoun = layerNoun(entity.trustLevel, 1);

  const incoming = useMemo(() => {
    const result: Array<{ pred: string; entity: MemoryEntity }> = [];
    for (const [, other] of allEntities) {
      for (const conn of other.connections) {
        if (conn.targetUri === entity.uri) {
          result.push({ pred: shortPred(conn.predicate), entity: other });
        }
      }
    }
    return result;
  }, [entity.uri, allEntities]);

  const entityTriples = useMemo(
    () => allTriples.filter(t => t.subject === entity.uri || t.object === entity.uri),
    [entity.uri, allTriples]
  );

  // 1-hop neighborhood is the sweet spot for the entity-detail graph:
  // 2-hop quickly explodes (a function pulls in its file, the file's
  // package, every other declaration in the file, etc.) and drowns the
  // visual signal of "what does THIS entity connect to directly".
  const hoodTriples = useMemo(
    () => neighborhoodTriples(entity.uri, allTriples, 1),
    [entity.uri, allTriples]
  );

  const graphOptions = useMemo(() => ({
    labelMode: 'humanized' as const,
    renderer: '2d' as const,
    labels: memoryGraphLabels({ minZoomForLabels: 0.2 }),
    style: {
      defaultNodeColor: TRUST_COLORS[entity.trustLevel],
      defaultEdgeColor: '#475569',
      edgeWidth: 1.0,
      fontSize: 11,
    },
    hexagon: { baseSize: 7, minSize: 4, maxSize: 10, scaleWithDegree: true },
    focus: { maxNodes: 500, hops: 999 },
  }), [entity.trustLevel]);

  // ViewConfig makes the opened entity visually focal (bigger hexagon)
  // and drives the `<CenterOnEntity>` child to pan the camera to it
  // once force-graph has settled. Identity keyed on the URI so React
  // re-applies the view when we switch entities without unmounting
  // the whole RdfGraph.
  const entityViewConfig = useMemo(() => ({
    name: `entity-${entity.uri}`,
    focal: { uri: entity.uri, sizeMultiplier: 2.4 },
  }), [entity.uri]);

  const tripleCount = entity.connections.length + entity.properties.size;

  return (
    <div className="v10-ka-detail">
      <div className="v10-ka-header">
        <button className="v10-ka-back" onClick={onClose}>← Back to Context Graph</button>
        <div className="v10-ka-header-left">
          <div className="v10-ka-label">{detailNoun}</div>
          <div className="v10-ka-name">
            {icon} {entity.label}
            <span className={`v10-trust-badge ${layerBadge}`}>{layerLabel}</span>
          </div>
          <div className="v10-ka-ual">{entity.uri} · {type} · {tripleCount} triples</div>
        </div>
        {(author || authorUri) && (
          <div className="v10-ka-header-author">
            <div className="v10-ka-header-author-label">Proposed by</div>
            <AgentChip
              agent={author ?? undefined}
              fallbackUri={authorUri ?? undefined}
              size="lg"
              showOperator
              onOpenAgent={onOpenAgent}
            />
          </div>
        )}
      </div>

      <div className="v10-ka-split">
        {/* Left pane: Content / Triples / Graph */}
        <div className="v10-ka-left">
          <div className="v10-content-tabs" style={{ margin: '0 -20px', padding: '0 20px', background: 'transparent' }}>
            <button className={`v10-content-tab ${pane === 'content' ? 'active' : ''}`} onClick={() => setPane('content')}>Content</button>
            <button className={`v10-content-tab ${pane === 'triples' ? 'active' : ''}`} onClick={() => setPane('triples')}>Triples</button>
            <button className={`v10-content-tab ${pane === 'graph' ? 'active' : ''}`} onClick={() => setPane('graph')}>Graph</button>
          </div>

          {pane === 'content' && (
            <>
              {/* Profile-driven Generative UI — streamed from the DKG daemon's
                  LLM-backed /api/genui/render for any rdf:type that declares a
                  detailHint. Falls back to the generic detail below if the
                  profile has no binding for this type. */}
              {(() => {
                const binding = entity.types.map(t => profile?.forType(t)).find(b => b?.detailHint);
                if (!binding || !profile?.contextGraphId) return null;
                return (
                  <div className="v10-ka-section">
                    <GenUIEntityPanel
                      contextGraphId={profile.contextGraphId}
                      entityUri={entity.uri}
                    />
                  </div>
                );
              })()}
              {desc && (
                <div className="v10-ka-section">
                  <div className="v10-ka-desc"><p>{desc}</p></div>
                </div>
              )}

              {entity.properties.size > 0 && (
                <div className="v10-ka-section">
                  <div className="v10-ka-section-title">Properties</div>
                  {[...entity.properties].map(([pred, vals]) => (
                    <div key={pred} className="v10-ka-prop">
                      <span className="v10-ka-prop-key">{shortPred(pred)}</span>
                      <span className="v10-ka-prop-val">{vals.join(', ')}</span>
                    </div>
                  ))}
                </div>
              )}

              {entity.connections.length > 0 && (
                <div className="v10-ka-section">
                  <div className="v10-ka-section-title">References ({entity.connections.length})</div>
                  {entity.connections.map((conn, i) => {
                    const target = allEntities.get(conn.targetUri);
                    return (
                      <button key={i} className="v10-ka-conn" onClick={() => onNavigate(conn.targetUri)}>
                        <span className="v10-ka-conn-pred">{shortPred(conn.predicate)}</span>
                        <span className="v10-ka-conn-arrow">→</span>
                        <span className="v10-ka-conn-target">{conn.targetLabel}</span>
                        {target && <SubGraphBadge entity={target} profile={profile} />}
                      </button>
                    );
                  })}
                </div>
              )}

              {incoming.length > 0 && (
                <div className="v10-ka-section">
                  <div className="v10-ka-section-title">Referenced by ({incoming.length})</div>
                  {incoming.map((inc, i) => (
                    <button key={i} className="v10-ka-conn" onClick={() => onNavigate(inc.entity.uri)}>
                      <span className="v10-ka-conn-target">{inc.entity.label}</span>
                      <SubGraphBadge entity={inc.entity} profile={profile} />
                      <span className="v10-ka-conn-arrow">→</span>
                      <span className="v10-ka-conn-pred">{inc.pred}</span>
                    </button>
                  ))}
                </div>
              )}

              <div className="v10-ka-meta">
                <span className="v10-ka-meta-item">{icon} {type}</span>
                <span className="v10-ka-meta-item">{tripleCount} triples</span>
                <span className="v10-ka-meta-item">{entity.connections.length} links</span>
              </div>
            </>
          )}

          {pane === 'triples' && (
            <div style={{ marginTop: 8, overflowX: 'auto', border: '1px solid var(--border-default)', borderRadius: 6 }}>
              <table className="v10-ka-triples-table">
                <thead>
                  <tr><th>Subject</th><th>Predicate</th><th>Object</th></tr>
                </thead>
                <tbody>
                  {entityTriples.slice(0, 50).map((t, i) => (
                    <tr key={i}>
                      <td title={t.subject}>{shortPred(t.subject)}</td>
                      <td title={t.predicate}>{shortPred(t.predicate)}</td>
                      <td title={t.object}>{t.object.startsWith('"') ? t.object.replace(/^"|"$/g, '').slice(0, 60) : shortPred(t.object)}</td>
                    </tr>
                  ))}
                </tbody>
              </table>
              <div style={{ fontSize: 10, color: 'var(--text-tertiary)', fontFamily: 'var(--font-mono)', padding: '6px 8px' }}>
                {Math.min(entityTriples.length, 50)} of {entityTriples.length} triples shown
              </div>
            </div>
          )}

          {pane === 'graph' && (
            <div style={{ height: 300, position: 'relative', marginTop: 8, borderRadius: 6, overflow: 'hidden', border: '1px solid var(--border-default)' }}>
              {hoodTriples.length > 0 ? (
                <Suspense fallback={<span className="v10-graph-placeholder">Loading graph...</span>}>
                  <RdfGraph
                    data={hoodTriples}
                    format="triples"
                    options={graphOptions}
                    viewConfig={entityViewConfig}
                    style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }}
                    onNodeClick={(n: any) => n?.id && n.id !== entity.uri && onNavigate(n.id)}
                    initialFit
                    initialFocus={entity.uri}
                  />
                </Suspense>
              ) : (
                <div className="v10-graph-placeholder">No neighborhood data</div>
              )}
            </div>
          )}
        </div>

        {/* Right pane: Provenance Trail */}
        <div className="v10-ka-right">
          {/* On-chain identity banner — only for VM entities; the hook
              skips its SPARQL when `enabled` is false so there's no
              cost for WM/SWM entities. */}
          <VerifiedIdentityBanner
            contextGraphId={contextGraphId}
            entityUri={entity.uri}
            enabled={entity.trustLevel === 'verified'}
          />
          <div className="v10-ka-section-title">Provenance Trail</div>
          <ProvenanceTrail entity={entity} />

          {/* Verify on DKG — prominent CTA for WM/SWM entities */}
          <VerifyOnDkgButton
            entity={entity}
            contextGraphId={contextGraphId}
            onVerified={onRefresh}
          />

          {/* Trust Summary */}
          <div style={{ marginTop: 16, padding: '10px 12px', borderRadius: 6, background: 'var(--bg-surface)', border: '1px solid var(--border-subtle)' }}>
            <div className="v10-ka-section-title" style={{ marginBottom: 6 }}>Entity Summary</div>
            <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 4, fontSize: 10 }}>
              <div style={{ display: 'flex', justifyContent: 'space-between' }}>
                <span style={{ color: 'var(--text-tertiary)' }}>Type</span>
                <span style={{ color: 'var(--text-primary)', fontWeight: 600, fontFamily: 'var(--font-mono)' }}>{type}</span>
              </div>
              <div style={{ display: 'flex', justifyContent: 'space-between' }}>
                <span style={{ color: 'var(--text-tertiary)' }}>Triples</span>
                <span style={{ color: 'var(--text-primary)', fontWeight: 600, fontFamily: 'var(--font-mono)' }}>{tripleCount}</span>
              </div>
              <div style={{ display: 'flex', justifyContent: 'space-between' }}>
                <span style={{ color: 'var(--text-tertiary)' }}>Links</span>
                <span style={{ color: 'var(--text-primary)', fontWeight: 600, fontFamily: 'var(--font-mono)' }}>{entity.connections.length}</span>
              </div>
              <div style={{ display: 'flex', justifyContent: 'space-between' }}>
                <span style={{ color: 'var(--text-tertiary)' }}>Layer</span>
                <span style={{ color: 'var(--text-primary)', fontWeight: 600, fontFamily: 'var(--font-mono)' }}>{layerLabel.split(' ')[0]}</span>
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}

// ─── Provenance Trail ───────────────────────────────────────
// Layer-by-layer history with per-step agent attribution. Each step
// shows which agent fired the transition plus (when present) the
// timestamp. In the absence of per-transition predicates the step
// falls back to the entity's `prov:wasAttributedTo` so the trail
// always names *someone* — the demo data relies on this fallback
// because our seed scripts promote / publish in bulk without writing
// per-step attribution. When live agents start calling the write
// path they'll emit agent:promotedBy / agent:publishedBy and the
// fallback stops firing.
export function ProvenanceTrail({ entity }: { entity: MemoryEntity }) {
  const agents = useAgentsContext();

  const step = (kind: 'created' | 'promoted' | 'published') => {
    const uri = transitionAgentUri(entity, kind);
    const at  = transitionAtISO(entity, kind);
    const agent = uri ? agents?.get(uri) : null;
    return { uri, at, agent };
  };

  const created   = step('created');
  const promoted  = step('promoted');
  const published = step('published');

  return (
    <div className="v10-ka-timeline">
      {entity.trustLevel === 'verified' && (
        <TrailEvent
          toneClass="verified"
          title="Published to Verifiable Memory"
          actionWord="Published"
          agent={published.agent}
          agentUri={published.uri}
          at={published.at}
        />
      )}
      {(entity.trustLevel === 'shared' || entity.trustLevel === 'verified') && (
        <TrailEvent
          toneClass="shared"
          title="Promoted to Shared Working Memory"
          actionWord="Promoted"
          agent={promoted.agent}
          agentUri={promoted.uri}
          at={promoted.at}
        />
      )}
      <TrailEvent
        toneClass="created"
        title="Created in Working Memory"
        actionWord="Created"
        agent={created.agent}
        agentUri={created.uri}
        at={created.at}
      />
    </div>
  );
}

export function TrailEvent({
  toneClass,
  title,
  actionWord,
  agent,
  agentUri,
  at,
}: {
  toneClass: 'verified' | 'shared' | 'created';
  title: string;
  actionWord: string;
  agent: AgentSummary | null | undefined;
  agentUri: string | null;
  at: string | null;
}) {
  const when = at ? formatTrailTimestamp(at) : null;
  return (
    <div className="v10-ka-event">
      <div className={`v10-ka-event-dot ${toneClass}`} />
      <div className="v10-ka-event-header">
        <span className="v10-ka-event-title">{title}</span>
        {when && <time className="v10-ka-event-time" dateTime={at ?? undefined}>{when}</time>}
      </div>
      {(agent || agentUri) && (
        <div className="v10-ka-event-attribution">
          <span className="v10-ka-event-attribution-prefix">{actionWord} by</span>
          <AgentChip agent={agent ?? undefined} fallbackUri={agentUri ?? undefined} size="sm" />
        </div>
      )}
    </div>
  );
}


export function SubGraphOverviewGrid({
  contextGraphId,
  memory,
  onNodeClick,
  onSelectSubGraph,
}: {
  contextGraphId: string;
  memory: ReturnType<typeof useMemoryEntities>;
  onNodeClick?: (node: any) => void;
  onSelectSubGraph: (slug: string) => void;
}) {
  const profile = useProjectProfileContext();
  const [subGraphs, setSubGraphs] = useState<SubGraphInfo[]>([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    let cancelled = false;
    setLoading(true);
    fetchSubGraphs(contextGraphId)
      .then(r => { if (!cancelled) setSubGraphs(r.subGraphs ?? []); })
      .catch(() => { /* silent */ })
      .finally(() => { if (!cancelled) setLoading(false); });
    return () => { cancelled = true; };
  }, [contextGraphId]);

  // Bucket every triple by its origin sub-graph so each mini-graph renders
  // just its slice. We dedupe on (s,p,o) and cap per-bucket to keep the
  // mini-graph canvases snappy. Without the cap, sub-graphs like `code`
  // (~25k triples) can lock up the tab while force-graph runs its layout.
  //
  // Sampling strategy: when a sub-graph exceeds MAX_PER_CARD, we keep
  // every triple for the N heaviest root entities (highest degree) so
  // the user sees a representative, connected slice rather than a
  // random first-N truncation that breaks clusters apart.
  const MAX_PER_CARD = 2500;
  const triplesBySubGraph = useMemo(() => {
    const bySg = new Map<string, Triple[]>();
    const seen = new Map<string, Set<string>>();
    for (const t of memory.allTriples) {
      if (!t.subGraph) continue;
      const key = `${t.subject}|${t.predicate}|${t.object}`;
      let s = seen.get(t.subGraph);
      if (!s) { s = new Set(); seen.set(t.subGraph, s); }
      if (s.has(key)) continue;
      s.add(key);
      let arr = bySg.get(t.subGraph);
      if (!arr) { arr = []; bySg.set(t.subGraph, arr); }
      arr.push({ subject: t.subject, predicate: t.predicate, object: t.object });
    }
    // If a bucket is over the cap, fall back to sampling the heaviest
    // subjects and dropping the long tail. This preserves cluster
    // topology far better than truncation.
    for (const [sg, triples] of bySg) {
      if (triples.length <= MAX_PER_CARD) continue;
      const degree = new Map<string, number>();
      for (const t of triples) degree.set(t.subject, (degree.get(t.subject) ?? 0) + 1);
      const order = [...degree.entries()]
        .sort((a, b) => b[1] - a[1])
        .map(([uri]) => uri);
      const keep = new Set<string>();
      let kept = 0;
      for (const uri of order) {
        if (kept >= MAX_PER_CARD) break;
        keep.add(uri);
        kept += degree.get(uri)!;
      }
      bySg.set(sg, triples.filter(t => keep.has(t.subject)));
    }
    return bySg;
  }, [memory.allTriples]);

  // Per-sub-graph layer counts — drives the mini pyramid on each card so
  // you can see at a glance which sub-graphs are mostly verified vs still
  // in flight. Computed from rawMemory.entityList intersected with the
  // entity's subGraphs bag.
  const layerCountsBySubGraph = useMemo(() => {
    const out = new Map<string, { wm: number; swm: number; vm: number }>();
    for (const e of memory.entityList) {
      for (const sg of e.subGraphs) {
        let counts = out.get(sg);
        if (!counts) { counts = { wm: 0, swm: 0, vm: 0 }; out.set(sg, counts); }
        if (e.layers.has('working'))  counts.wm++;
        if (e.layers.has('shared'))   counts.swm++;
        if (e.layers.has('verified')) counts.vm++;
      }
    }
    return out;
  }, [memory.entityList]);

  // Merge registered sub-graphs (minus `meta`) with profile bindings so
  // icon/color/label/rank all flow from the single source of truth.
  const cards = useMemo(() => {
    return subGraphs
      .filter(sg => sg.name !== 'meta')
      .map(sg => {
        const binding = profile?.forSubGraph(sg.name) ?? {};
        return {
          slug: sg.name,
          icon: binding.icon ?? '•',
          color: binding.color ?? '#64748b',
          displayName: binding.displayName ?? sg.name,
          description: binding.description,
          rank: binding.rank ?? 99,
          entityCount: sg.entityCount,
          tripleCount: sg.tripleCount,
          triples: triplesBySubGraph.get(sg.name) ?? [],
          layerCounts: layerCountsBySubGraph.get(sg.name) ?? { wm: 0, swm: 0, vm: 0 },
        };
      })
      .sort((a, b) => a.rank - b.rank);
  }, [subGraphs, profile, triplesBySubGraph, layerCountsBySubGraph]);

  if (loading && cards.length === 0) {
    return (
      <EmptyState
        compact
        icon="#"
        title="Loading sub-graphs..."
        className="v10-sgov-state"
      />
    );
  }
  if (cards.length === 0) {
    return (
      <EmptyState
        icon="#"
        title="No sub-graphs registered yet."
        description="This Context Graph currently only has the root memory view."
        className="v10-sgov-state"
      />
    );
  }

  return (
    <div className="v10-sgov">
      <div className="v10-sgov-header">
        <div className="v10-sgov-title">Sub-graph Overview</div>
        <div className="v10-sgov-sub">
          {cards.length} sub-graphs · {cards.reduce((a, b) => a + b.entityCount, 0)} entities · {cards.reduce((a, b) => a + b.tripleCount, 0)} triples
        </div>
      </div>
      <div className="v10-sgov-grid">
        {cards.map(card => (
          <SubGraphMiniCard
            key={card.slug}
            card={card}
            onNodeClick={onNodeClick}
            onOpen={() => onSelectSubGraph(card.slug)}
          />
        ))}
      </div>
    </div>
  );
}

export function SubGraphMiniCard({
  card,
  onNodeClick,
  onOpen,
}: {
  card: {
    slug: string; icon: string; color: string; displayName: string;
    description?: string; entityCount: number; tripleCount: number;
    triples: Triple[];
    layerCounts: { wm: number; swm: number; vm: number };
  };
  onNodeClick?: (node: any) => void;
  onOpen: () => void;
}) {
  // A compact-mode graph options block — pared-down labels, smaller nodes,
  // brighter default color (driven by the sub-graph's profile color) so each
  // card reads as a distinct "island" at a glance.
  const graphOptions = useMemo(() => ({
    labelMode: 'humanized' as const,
    renderer: '2d' as const,
    labels: memoryGraphLabels({ minZoomForLabels: 0.8 }), // Keep labels out of the way in the mini view.
    style: {
      classColors: CODE_CLASS_COLORS,
      predicateColors: CODE_PREDICATE_COLORS,
      defaultNodeColor: card.color,
      defaultEdgeColor: '#475569',
      edgeWidth: 1.0,
      fontSize: 12,
      gradient: true,
      gradientIntensity: 0.35,
    },
    hexagon: { baseSize: 6, minSize: 3, maxSize: 16, scaleWithDegree: true },
    focus: { maxNodes: 5000, hops: 999 },
  }), [card.color]);

  return (
    <div
      className="v10-sgov-card"
      style={{
        '--sg-color': card.color,
        borderColor: card.color + '55',
      } as React.CSSProperties}
    >
      <div className="v10-sgov-card-head">
        <span className="v10-sgov-card-icon" style={{ color: card.color }}>{card.icon}</span>
        <div className="v10-sgov-card-title-wrap">
          <div className="v10-sgov-card-title">{card.displayName}</div>
          {card.description && (
            <div className="v10-sgov-card-desc" title={card.description}>{card.description}</div>
          )}
        </div>
        <button type="button" className="v10-sgov-card-open" onClick={onOpen} title={`Focus on ${card.displayName}`}>
          ↗
        </button>
      </div>
      <div className="v10-sgov-card-stats">
        <span className="v10-sgov-card-stat"><b>{card.entityCount}</b> entities</span>
        <span className="v10-sgov-card-stat"><b>{card.tripleCount}</b> triples</span>
      </div>
      <div className="v10-sgov-card-pyramid">
        <MiniLayerPyramid counts={card.layerCounts} />
      </div>
      <div className="v10-sgov-card-graph">
        {card.triples.length === 0 ? (
          <div className="v10-sgov-card-empty">
            {card.entityCount > 0 ? 'No WM triples · promoted data only' : 'No data yet'}
          </div>
        ) : (
          <Suspense fallback={<div className="v10-sgov-card-empty">Loading…</div>}>
            <RdfGraph
              data={card.triples}
              format="triples"
              options={graphOptions}
              style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }}
              onNodeClick={onNodeClick}
              initialFit
            />
          </Suspense>
        )}
      </div>
    </div>
  );
}

// ─── MiniLayerPyramid ────────────────────────────────────────
// Three-segment WM/SWM/VM bar. Used as a header widget in the sub-graph
// page (clickable — toggles which layers contribute entities) and as a
// compact badge on SubGraphOverviewGrid cards (read-only).
export function MiniLayerPyramid({
  counts,
  activeLayers,
  onClickLayer,
  compact = false,
}: {
  counts: { wm: number; swm: number; vm: number };
  activeLayers?: Set<TrustLevel>;
  onClickLayer?: (layer: TrustLevel) => void;
  compact?: boolean;
}) {
  const total = counts.wm + counts.swm + counts.vm;
  if (total === 0) {
    return compact ? null : <div className="v10-minipyr v10-minipyr-empty">No data</div>;
  }
  const rows: Array<{ key: TrustLevel; short: string; count: number; color: string; label: string }> = [
    { key: 'verified', short: 'VM',  count: counts.vm,  color: '#22c55e', label: 'Verified' },
    { key: 'shared',   short: 'SWM', count: counts.swm, color: '#f59e0b', label: 'Shared' },
    { key: 'working',  short: 'WM',  count: counts.wm,  color: '#64748b', label: 'Working' },
  ];
  const interactive = !!onClickLayer;
  return (
    <div className={`v10-minipyr${compact ? ' compact' : ''}`}>
      {!compact && (
        <div className="v10-minipyr-bar">
          {rows.filter(r => r.count > 0).map(r => {
            const pct = (r.count / total) * 100;
            const active = activeLayers ? activeLayers.has(r.key) : true;
            return (
              <div
                key={r.key}
                className={`v10-minipyr-seg${active ? '' : ' dim'}`}
                style={{ width: `${pct}%`, background: r.color }}
                title={`${r.label}: ${r.count}`}
              />
            );
          })}
        </div>
      )}
      <div className="v10-minipyr-legend">
        {rows.map(r => {
          const active = activeLayers ? activeLayers.has(r.key) : true;
          return (
            <button
              key={r.key}
              type="button"
              className={`v10-minipyr-chip${active ? '' : ' dim'}${interactive ? ' interactive' : ''}`}
              onClick={interactive ? () => onClickLayer!(r.key) : undefined}
              disabled={!interactive}
              title={`${r.label} Memory — ${r.count} entities${interactive ? ' (click to toggle)' : ''}`}
            >
              <span className="v10-minipyr-dot" style={{ background: r.color }} />
              <span className="v10-minipyr-short">{r.short}</span>
              <span className="v10-minipyr-count">{r.count}</span>
            </button>
          );
        })}
      </div>
    </div>
  );
}

// ─── SubGraphTimeline ────────────────────────────────────────
// Horizontal ribbon of entities sorted by the sub-graph's declared
// `profile:timelinePredicate`. Grouped into year-month buckets so the
// ribbon has natural section headers.
export function SubGraphTimeline({
  items,
  color,
  onSelectEntity,
  scrollKey,
}: {
  items: Array<{ entity: MemoryEntity; date: Date }>;
  color: string;
  onSelectEntity: (uri: string) => void;
  scrollKey?: string;
}) {
  const profile = useProjectProfileContext();
  const agents = useAgentsContext();
  const grouped = useMemo(() => {
    const out = new Map<string, Array<{ entity: MemoryEntity; date: Date }>>();
    for (const it of items) {
      const key = `${it.date.getFullYear()}-${String(it.date.getMonth() + 1).padStart(2, '0')}`;
      const arr = out.get(key) ?? [];
      arr.push(it);
      out.set(key, arr);
    }
    return [...out.entries()].sort((a, b) => a[0].localeCompare(b[0]));
  }, [items]);

  if (items.length === 0) {
    return (
      <div className="v10-subgraph-timeline-empty" data-cg-scroll-key={scrollKey}>
        <EmptyState
          compact
          title="No timeline values yet."
          description="Entities in this sub-graph do not currently expose the configured timeline field."
        />
      </div>
    );
  }

  return (
    <div className="v10-subgraph-timeline" data-cg-scroll-key={scrollKey}>
      {grouped.map(([bucket, rows]) => (
        <div key={bucket} className="v10-subgraph-timeline-bucket">
          <div className="v10-subgraph-timeline-bucket-head">
            <span className="v10-subgraph-timeline-bucket-dot" style={{ background: color }} />
            <span className="v10-subgraph-timeline-bucket-label">{formatTimelineBucket(bucket)}</span>
            <span className="v10-subgraph-timeline-bucket-count">{rows.length}</span>
          </div>
          <div className="v10-subgraph-timeline-items">
            {rows.map(({ entity, date }) => {
              const { icon } = entityMeta(entity, profile);
              const authorUri = entityAuthorUri(entity);
              const author = authorUri ? agents?.get(authorUri) : null;
              return (
                <button
                  key={entity.uri}
                  className="v10-subgraph-timeline-item"
                  onClick={() => onSelectEntity(entity.uri)}
                  title={`${entity.label} · ${date.toISOString().slice(0, 10)}`}
                >
                  <span className="v10-subgraph-timeline-item-icon">{icon}</span>
                  <span className="v10-subgraph-timeline-item-label">{entity.label}</span>
                  {(author || authorUri) && (
                    <AgentChip
                      agent={author ?? undefined}
                      fallbackUri={authorUri ?? undefined}
                      size="sm"
                    />
                  )}
                  <span className="v10-subgraph-timeline-item-date">{date.toISOString().slice(0, 10)}</span>
                </button>
              );
            })}
          </div>
        </div>
      ))}
    </div>
  );
}


/** Sort modes for the Entities tab on a sub-graph page. `created-*` is
 *  only meaningful when the sub-graph profile defines a `timelinePredicate`
 *  (e.g. chat → dcterms:created); falls back gracefully otherwise. */

/** Best-effort timestamp parser shared between Timeline and Entities sort.
 *  Strips `"…"@en` / `"…"^^xsd:dateTime` wrappers and accepts plain ISO. */

export function SubGraphDetailView({
  slug,
  rawMemory,
  contextGraphId,
  onNodeClick,
  onSelectEntity,
  activeTab,
  onTabChange,
}: {
  slug: string;
  rawMemory: ReturnType<typeof useMemoryEntities>;
  contextGraphId: string;
  onNodeClick: (node: any) => void;
  onSelectEntity: (uri: string) => void;
  activeTab?: SubGraphTab;
  onTabChange?: (tab: SubGraphTab) => void;
}) {
  const profile = useProjectProfileContext();
  const binding = profile?.forSubGraph(slug);
  const chips = profile?.chipsFor(slug) ?? [];
  const queryCatalogs = profile?.savedQueryCatalogsFor(slug) ?? [];
  const timelinePredicate = binding?.timelinePredicate;

  const [localActiveTab, setLocalActiveTab] = useState<SubGraphTab>('items');
  const rawSelectedTab = activeTab ?? localActiveTab;
  const selectedTab = rawSelectedTab === 'timeline' && !timelinePredicate ? 'items' : rawSelectedTab;
  const setSelectedTab = onTabChange ?? setLocalActiveTab;
  // Default to newest-first for any sub-graph that defines a timeline
  // predicate (chat, github, tasks, decisions). Sub-graphs with no time
  // signal (code, meta) fall back to the legacy "richest entity first"
  // ordering so the list still feels organised.
  const [entitySort, setEntitySort] = useState<SubGraphEntitySort>(
    binding?.timelinePredicate ? 'created-desc' : 'triples',
  );
  // Reset sort when the user navigates between sub-graphs that have
  // different time signals — otherwise switching from chat → code
  // would leave us trying to sort by a predicate the sub-graph lacks.
  useEffect(() => {
    setEntitySort(binding?.timelinePredicate ? 'created-desc' : 'triples');
  }, [slug, binding?.timelinePredicate]);
  const [enabledLayers, setEnabledLayers] = useState<Set<TrustLevel>>(
    () => new Set<TrustLevel>(['working', 'shared', 'verified']),
  );
  const [chipState, setChipState] = useState<Map<string, Set<string>>>(new Map());
  const [activeQuerySlug, setActiveQuerySlug] = useState<string | null>(null);
  const [queryResults, setQueryResults] = useState<Set<string> | null>(null);
  const [queryLoading, setQueryLoading] = useState(false);
  const [queryError, setQueryError] = useState<string | null>(null);

  // Base slice: every entity that has at least one WM triple in this sub-graph.
  // SWM/VM triples don't carry sub-graph origin, so we additionally pull in
  // any SWM/VM entity whose URI matches a scoped one (preserving the promoted
  // slices of the same entity across layers).
  const scopedEntities = useMemo(() => {
    const scoped: MemoryEntity[] = [];
    for (const e of rawMemory.entityList) {
      if (e.subGraphs.has(slug)) scoped.push(e);
    }
    return scoped;
  }, [rawMemory.entityList, slug]);

  const scopedUris = useMemo(
    () => new Set(scopedEntities.map(e => e.uri)),
    [scopedEntities],
  );

  // Triples visible in the Graph tab: anything tagged with this sub-graph's
  // origin, plus any triple whose endpoints are both scoped entities (this
  // carries promoted SWM/VM edges whose origin was erased on promotion).
  const scopedTriples = useMemo(
    () => rawMemory.graphTriples.filter(t =>
      t.subGraph === slug || (scopedUris.has(t.subject) && scopedUris.has(t.object)),
    ),
    [rawMemory.graphTriples, scopedUris, slug],
  );

  // Layer counts for the pyramid header.
  const layerCounts = useMemo(() => {
    let wm = 0, swm = 0, vm = 0;
    for (const e of scopedEntities) {
      if (e.layers.has('working'))  wm++;
      if (e.layers.has('shared'))   swm++;
      if (e.layers.has('verified')) vm++;
    }
    return { wm, swm, vm, total: scopedEntities.length };
  }, [scopedEntities]);

  // Apply the three filter axes on top of the base scope.
  const filteredEntities = useMemo(() => {
    let out = scopedEntities;
    if (enabledLayers.size < 3) {
      out = out.filter(e => enabledLayers.has(e.trustLevel));
    }
    if (chipState.size > 0) {
      for (const chip of chips) {
        const selected = chipState.get(chip.slug);
        if (!selected || selected.size === 0) continue;
        out = out.filter(e => {
          const vals = e.properties.get(chip.predicate);
          if (!vals || vals.length === 0) return false;
          return vals.some(v => selected.has(v));
        });
      }
    }
    if (queryResults) {
      out = out.filter(e => queryResults.has(e.uri));
    }
    return out;
  }, [scopedEntities, enabledLayers, chipState, chips, queryResults]);

  const filteredUris = useMemo(
    () => new Set(filteredEntities.map(e => e.uri)),
    [filteredEntities],
  );

  const filteredTriples = useMemo(() => {
    if (filteredEntities.length === scopedEntities.length) return scopedTriples;
    return scopedTriples.filter(
      t => filteredUris.has(t.subject) || filteredUris.has(t.object),
    );
  }, [scopedTriples, scopedEntities, filteredEntities, filteredUris]);

  const timelineItems = useMemo(() => {
    if (!timelinePredicate) return [];
    const out: Array<{ entity: MemoryEntity; date: Date }> = [];
    for (const e of filteredEntities) {
      const t = entityTimestamp(e, timelinePredicate);
      if (t == null) continue;
      out.push({ entity: e, date: new Date(t) });
    }
    out.sort((a, b) => a.date.getTime() - b.date.getTime());
    return out;
  }, [filteredEntities, timelinePredicate]);

  // Apply the user's chosen sort to the Entities tab. Time-based modes
  // bucket undated entities at the bottom so the list stays predictable
  // when only some entities carry the timeline predicate.
  const sortedEntities = useMemo(() => {
    const copy = [...filteredEntities];
    if ((entitySort === 'created-desc' || entitySort === 'created-asc') && timelinePredicate) {
      const dir = entitySort === 'created-desc' ? -1 : 1;
      copy.sort((a, b) => {
        const ta = entityTimestamp(a, timelinePredicate);
        const tb = entityTimestamp(b, timelinePredicate);
        if (ta == null && tb == null) return 0;
        if (ta == null) return 1;
        if (tb == null) return -1;
        return (ta - tb) * dir;
      });
      return copy;
    }
    if (entitySort === 'label') {
      copy.sort((a, b) => (a.label ?? '').localeCompare(b.label ?? ''));
      return copy;
    }
    // 'triples' (default fallback)
    copy.sort((a, b) => {
      const aCount = a.connections.length + a.properties.size;
      const bCount = b.connections.length + b.properties.size;
      return bCount - aCount;
    });
    return copy;
  }, [filteredEntities, entitySort, timelinePredicate]);

  const sortLabel =
    entitySort === 'created-desc' ? 'newest first · click to open'
    : entitySort === 'created-asc' ? 'oldest first · click to open'
    : entitySort === 'label' ? 'A → Z · click to open'
    : 'sorted by triples · click to open';

  const toggleLayer = useCallback((layer: TrustLevel) => {
    setEnabledLayers(prev => {
      const next = new Set(prev);
      if (next.has(layer)) {
        // Refuse to turn off the last enabled layer — otherwise the list
        // empties with no obvious recovery affordance.
        if (next.size > 1) next.delete(layer);
      } else {
        next.add(layer);
      }
      return next;
    });
  }, []);

  const toggleChip = useCallback((chipSlug: string, value: string) => {
    setChipState(prev => {
      const next = new Map(prev);
      const curr = new Set(next.get(chipSlug) ?? []);
      if (curr.has(value)) curr.delete(value);
      else curr.add(value);
      if (curr.size === 0) next.delete(chipSlug);
      else next.set(chipSlug, curr);
      return next;
    });
  }, []);

  const clearQuery = useCallback(() => {
    setActiveQuerySlug(null);
    setQueryResults(null);
    setQueryError(null);
  }, []);

  const runQuery = useCallback(async (q: { slug: string; sparql: string; resultColumn: string; name: string }) => {
    setQueryLoading(true);
    setQueryError(null);
    setActiveQuerySlug(q.slug);
    try {
      const r = await executeQuery(q.sparql, contextGraphId);
      const bindings = (r as any)?.result?.bindings ?? [];
      const col = q.resultColumn || 'uri';
      const ids = new Set<string>();
      for (const row of bindings) {
        const raw = (row as any)[col];
        if (!raw) continue;
        const s = typeof raw === 'string' ? raw : String(raw);
        const iri = s.startsWith('<') && s.endsWith('>') ? s.slice(1, -1) : s;
        ids.add(iri);
      }
      setQueryResults(ids);
    } catch (err: any) {
      setQueryError(err?.message ?? String(err));
      setQueryResults(null);
      setActiveQuerySlug(null);
    } finally {
      setQueryLoading(false);
    }
  }, [contextGraphId]);

  const color = binding?.color ?? '#64748b';
  const icon = binding?.icon ?? '•';
  const title = binding?.displayName ?? slug;
  const desc = binding?.description;

  // Reset filters when the sub-graph changes — otherwise chips from
  // `tasks` would linger when the user jumps to `decisions` and silently
  // zero out the list.
  useEffect(() => {
    setEnabledLayers(new Set<TrustLevel>(['working', 'shared', 'verified']));
    setChipState(new Map());
    clearQuery();
  }, [slug, clearQuery]);

  useEffect(() => {
    if (!activeTab) setLocalActiveTab('items');
  }, [slug, activeTab]);

  useEffect(() => {
    if (rawSelectedTab !== selectedTab) setSelectedTab(selectedTab);
  }, [rawSelectedTab, selectedTab, setSelectedTab]);

  const hasAnyFilter = enabledLayers.size < 3 || chipState.size > 0 || !!queryResults;
  const resetFilters = () => {
    setEnabledLayers(new Set<TrustLevel>(['working', 'shared', 'verified']));
    setChipState(new Map());
    clearQuery();
  };

  return (
    <div
      className="v10-layer-detail v10-subgraph-detail"
      style={{ '--sg-color': color } as React.CSSProperties}
    >
      <div className="v10-subgraph-detail-header">
        <span className="v10-subgraph-detail-icon" style={{ color }}>{icon}</span>
        <div className="v10-subgraph-detail-title-wrap">
          <div className="v10-subgraph-detail-title">{title}</div>
          {desc && <div className="v10-subgraph-detail-desc">{desc}</div>}
        </div>
        <MiniLayerPyramid
          counts={{ wm: layerCounts.wm, swm: layerCounts.swm, vm: layerCounts.vm }}
          activeLayers={enabledLayers}
          onClickLayer={toggleLayer}
        />
      </div>

      {queryCatalogs.length > 0 && (
        <div className="v10-subgraph-savedqueries">
          <span className="v10-subgraph-savedqueries-label">Query catalog</span>
          {queryCatalogs.map(catalog => (
            <React.Fragment key={catalog.slug}>
              <span
                className="v10-subgraph-savedqueries-label"
                title={catalog.description || catalog.name}
                style={{ marginLeft: 8, opacity: 0.8 }}
              >
                {catalog.name}
              </span>
              {catalog.queries.map(q => {
                const isActive = activeQuerySlug === q.slug;
                return (
                  <button
                    key={q.slug}
                    type="button"
                    className={`v10-subgraph-savedquery${isActive ? ' active' : ''}`}
                    onClick={() => isActive ? clearQuery() : runQuery(q)}
                    title={q.description || q.name}
                    disabled={queryLoading && !isActive}
                  >
                    <span className="v10-subgraph-savedquery-glyph">
                      {queryLoading && isActive ? '…' : isActive ? '✓' : '◎'}
                    </span>
                    {q.name}
                  </button>
                );
              })}
            </React.Fragment>
          ))}
          {queryError && (
            <span className="v10-subgraph-savedquery-error" title={queryError}>✕ query failed</span>
          )}
          {queryResults && activeQuerySlug && (
            <span className="v10-subgraph-savedquery-count">
              {queryResults.size} match{queryResults.size === 1 ? '' : 'es'}
            </span>
          )}
        </div>
      )}

      {chips.length > 0 && (
        <div className="v10-subgraph-filters">
          {chips.map(chip => {
            const selected = chipState.get(chip.slug) ?? new Set<string>();
            return (
              <div key={chip.slug} className="v10-subgraph-filter-row">
                <span className="v10-subgraph-filter-label">{chip.label}</span>
                <div className="v10-subgraph-filter-chips">
                  {chip.values.map(v => {
                    const on = selected.has(v);
                    return (
                      <button
                        key={v}
                        type="button"
                        className={`v10-subgraph-filter-chip${on ? ' active' : ''}`}
                        onClick={() => toggleChip(chip.slug, v)}
                      >
                        {v}
                      </button>
                    );
                  })}
                </div>
              </div>
            );
          })}
          {hasAnyFilter && (
            <button type="button" className="v10-subgraph-filter-reset" onClick={resetFilters}>
              Reset filters
            </button>
          )}
        </div>
      )}

      <div className="v10-layer-detail-body">
        <div className="v10-layer-expand-tabs">
          <button
            className={`v10-layer-expand-tab ${selectedTab === 'items' ? 'active' : ''}`}
            onClick={() => setSelectedTab('items')}
          >
            Entities ({filteredEntities.length}{filteredEntities.length !== scopedEntities.length ? ` / ${scopedEntities.length}` : ''})
          </button>
          <button
            className={`v10-layer-expand-tab ${selectedTab === 'graph' ? 'active' : ''}`}
            onClick={() => setSelectedTab('graph')}
          >
            Graph
          </button>
          {timelinePredicate && (
            <button
              className={`v10-layer-expand-tab ${selectedTab === 'timeline' ? 'active' : ''}`}
              onClick={() => setSelectedTab('timeline')}
            >
              Timeline
            </button>
          )}
          <button
            className={`v10-layer-expand-tab ${selectedTab === 'docs' ? 'active' : ''}`}
            onClick={() => setSelectedTab('docs')}
          >
            Documents
          </button>
        </div>

        {selectedTab === 'items' && (
          <div className="v10-layer-expand-body entities-tab" data-cg-scroll-key={`subgraph:${slug}:items`}>
            <EntityList
              entities={sortedEntities}
              layerKey="wm"
              layerIcon={icon}
              onSelectEntity={onSelectEntity}
              externallySorted
              sortLabel={sortLabel}
              timestampPredicate={timelinePredicate}
              headerExtra={
                <label className="v10-entity-list-sort">
                  <span className="v10-entity-list-sort-label">Sort</span>
                  <select
                    className="v10-entity-list-sort-select"
                    value={entitySort}
                    onChange={(e) => setEntitySort(e.target.value as SubGraphEntitySort)}
                    aria-label="Sort entities"
                  >
                    {timelinePredicate && (
                      <>
                        <option value="created-desc">Newest first</option>
                        <option value="created-asc">Oldest first</option>
                      </>
                    )}
                    <option value="triples">Most triples</option>
                    <option value="label">Label (A→Z)</option>
                  </select>
                </label>
              }
            />
          </div>
        )}

        {selectedTab === 'graph' && (
          <div className="v10-layer-expand-body full-width" data-cg-scroll-key={`subgraph:${slug}:graph`}>
            <LayerGraphPanel
              layer="wm"
              triples={filteredTriples}
              onNodeClick={onNodeClick}
              contextGraphId={contextGraphId}
            />
          </div>
        )}

        {selectedTab === 'timeline' && timelinePredicate && (
          <div className="v10-layer-expand-body full-width">
            <SubGraphTimeline
              items={timelineItems}
              color={color}
              onSelectEntity={onSelectEntity}
              scrollKey={`subgraph:${slug}:timeline`}
            />
          </div>
        )}

        {selectedTab === 'docs' && (
          <div className="v10-layer-expand-body full-width">
            <DocumentsList
              entities={filteredEntities}
              contextGraphId={contextGraphId}
              scrollKey={`subgraph:${slug}:docs`}
            />
          </div>
        )}
      </div>
    </div>
  );
}
