diff --git a/workbench-ui/src/app/EvidenceChainRail.tsx b/workbench-ui/src/app/EvidenceChainRail.tsx index 9b483458..096bc5db 100644 --- a/workbench-ui/src/app/EvidenceChainRail.tsx +++ b/workbench-ui/src/app/EvidenceChainRail.tsx @@ -247,6 +247,30 @@ export function deriveStages(subject: EvidenceSubject): RailStage[] | null { stage("action", "dim", "proposal mode none"), ]; } + case "logos_alignment_edge": { + const d = subject.data; + return [ + stage("intent", "dim", "not applicable to read-only Logos alignment inspection"), + stage("subject", "lit", "selected CORE-Logos alignment edge"), + stage( + "provenance", + d ? evidenceAny(...d.evidence_ids) : "hollow", + "evidence_ids (cross-language attestations)", + ), + stage( + "admissibility", + d ? (d.invalid_target ? "hollow" : evidenceOf(d.target_pack_id)) : "hollow", + "target resolves to a declared lexicon entry (invalid_target = dangling)", + ), + stage( + "replay", + d ? evidenceOf(d.relation) : "hollow", + "relation + weight (deterministic resonance edge)", + ), + stage("authority", "dim", "read-only Logos Studio has no mutation authority"), + stage("action", "dim", "proposal mode none"), + ]; + } case "vault_entry": { const d = subject.data; return [ diff --git a/workbench-ui/src/app/RightInspector.tsx b/workbench-ui/src/app/RightInspector.tsx index 954e92c0..3d908071 100644 --- a/workbench-ui/src/app/RightInspector.tsx +++ b/workbench-ui/src/app/RightInspector.tsx @@ -423,6 +423,48 @@ function LogosMorphologyInspector({ ); } +function LogosAlignmentEdgeInspector({ + subject, +}: { + subject: Extract; +}) { + const { data } = subject; + return ( +
+

+ CORE-Logos Alignment Edge +

+

+ {subject.packId} · {subject.edgeId} +

+ {data ? ( + 0 + ? [{ key: "evidence", value: data.evidence_ids.join(", "), mono: true }] + : []), + ]} + /> + ) : ( + + )} +
+ ); +} + function VaultEntryInspector({ subject }: { subject: Extract }) { const { data } = subject; return ( @@ -565,6 +607,8 @@ function InspectorProjection({ subject }: { subject: EvidenceSubject }) { return ; case "logos_morphology": return ; + case "logos_alignment_edge": + return ; case "vault_entry": return ; case "audit_event": diff --git a/workbench-ui/src/app/evidenceAddress.test.ts b/workbench-ui/src/app/evidenceAddress.test.ts index 53bde301..12f69724 100644 --- a/workbench-ui/src/app/evidenceAddress.test.ts +++ b/workbench-ui/src/app/evidenceAddress.test.ts @@ -299,6 +299,10 @@ describe("logos sub-entity addresses (LG-3)", () => { subject: { kind: "logos_morphology", packId: PACK, morphologyId: "he-morph-001" }, inspect: `logos_morphology:${PACK}/he-morph-001`, }, + { + subject: { kind: "logos_alignment_edge", packId: PACK, edgeId: "a1b2c3d4e5f60718" }, + inspect: `logos_alignment_edge:${PACK}/a1b2c3d4e5f60718`, + }, ]; it.each(subEntities)( diff --git a/workbench-ui/src/app/evidenceAddress.ts b/workbench-ui/src/app/evidenceAddress.ts index 260ff8c9..ef2e47ae 100644 --- a/workbench-ui/src/app/evidenceAddress.ts +++ b/workbench-ui/src/app/evidenceAddress.ts @@ -14,6 +14,7 @@ import type { EvidenceSubject } from "./evidenceContext"; // logos_entry -> /logos/?inspect=logos_entry:/ // logos_gloss -> /logos/?inspect=logos_gloss:/ // logos_morphology -> /logos/?inspect=logos_morphology:/ +// logos_alignment_edge -> /logos/?inspect=logos_alignment_edge:/ // vault_entry -> /vault?inspect=vault: // audit_event -> /audit?inspect=audit: // calibration_class -> /calibration?inspect=calibration: @@ -69,6 +70,12 @@ export function sameIdentity(a: EvidenceSubject, b: EvidenceSubject): boolean { b.packId === a.packId && b.morphologyId === a.morphologyId ); + case "logos_alignment_edge": + return ( + b.kind === "logos_alignment_edge" && + b.packId === a.packId && + b.edgeId === a.edgeId + ); case "vault_entry": return b.kind === "vault_entry" && b.entryIndex === a.entryIndex; case "audit_event": @@ -115,6 +122,7 @@ function subjectAddress(subject: AddressableSubject): SubjectAddress { case "logos_entry": case "logos_gloss": case "logos_morphology": + case "logos_alignment_edge": { const params = emptyParams(); params.set(INSPECT_PARAM, subjectToInspectValue(subject)); @@ -163,6 +171,8 @@ export function subjectToInspectValue(subject: AddressableSubject): string { return `logos_gloss:${subject.packId}/${subject.glossId}`; case "logos_morphology": return `logos_morphology:${subject.packId}/${subject.morphologyId}`; + case "logos_alignment_edge": + return `logos_alignment_edge:${subject.packId}/${subject.edgeId}`; case "vault_entry": return `vault:${subject.entryIndex}`; case "audit_event": @@ -249,6 +259,16 @@ export function inspectValueToSubject( morphologyId: parts.subId, }; } + case "logos_alignment_edge": { + const parts = splitPackScoped(id); + return parts === null + ? null + : { + kind: "logos_alignment_edge", + packId: parts.packId, + edgeId: parts.subId, + }; + } case "vault": { const entryIndex = parseTurnId(id); return entryIndex === null ? null : { kind: "vault_entry", entryIndex }; diff --git a/workbench-ui/src/app/evidenceContext.tsx b/workbench-ui/src/app/evidenceContext.tsx index f9c948c0..62aebdaf 100644 --- a/workbench-ui/src/app/evidenceContext.tsx +++ b/workbench-ui/src/app/evidenceContext.tsx @@ -18,6 +18,7 @@ import type { LogosLexiconRow, LogosGlossRow, LogosMorphologyRow, + LogosAlignmentRow, SafetyVerdict, } from "../types/api"; @@ -105,6 +106,12 @@ export type EvidenceSubject = morphologyId: string; data?: LogosMorphologyRow; } + | { + kind: "logos_alignment_edge"; + packId: string; + edgeId: string; + data?: LogosAlignmentRow; + } | { kind: "vault_entry"; entryIndex: number; data?: VaultEntrySubjectData } | { kind: "audit_event"; eventId: string; data?: AuditEventSubjectData } | { kind: "calibration_class"; className: string; data?: CalibrationClass } diff --git a/workbench-ui/src/app/logos/LogosAlignment.layout.golden.json b/workbench-ui/src/app/logos/LogosAlignment.layout.golden.json new file mode 100644 index 00000000..b293f0b5 --- /dev/null +++ b/workbench-ui/src/app/logos/LogosAlignment.layout.golden.json @@ -0,0 +1,73 @@ +{ + "nodes": [ + { + "id": "grc-023", + "layer": 0, + "row": 0, + "x": 24, + "y": 24 + }, + { + "id": "he-001", + "layer": 0, + "row": 1, + "x": 24, + "y": 86 + }, + { + "id": "en-collapse-breath", + "layer": 1, + "row": 0, + "x": 240, + "y": 24 + }, + { + "id": "grc-001", + "layer": 1, + "row": 1, + "x": 240, + "y": 86 + }, + { + "id": "en-024", + "layer": 2, + "row": 0, + "x": 456, + "y": 24 + } + ], + "edges": [ + { + "from": "grc-001", + "to": "en-024", + "points": { + "x1": 384, + "y1": 108, + "x2": 456, + "y2": 46 + } + }, + { + "from": "grc-023", + "to": "en-collapse-breath", + "points": { + "x1": 168, + "y1": 46, + "x2": 240, + "y2": 46 + } + }, + { + "from": "he-001", + "to": "grc-001", + "points": { + "x1": 168, + "y1": 108, + "x2": 240, + "y2": 108 + } + } + ], + "width": 624, + "height": 154 +} diff --git a/workbench-ui/src/app/logos/LogosAlignmentTab.test.tsx b/workbench-ui/src/app/logos/LogosAlignmentTab.test.tsx new file mode 100644 index 00000000..bed48c52 --- /dev/null +++ b/workbench-ui/src/app/logos/LogosAlignmentTab.test.tsx @@ -0,0 +1,114 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { layoutDag } from "../../design/components/Dag/layout"; +import type { LogosAlignmentRow } from "../../types/api"; +import { AlignmentTab, alignmentToDag } from "./LogosAlignmentTab"; +import golden from "./LogosAlignment.layout.golden.json"; + +// Fixture mirrors the golden generator exactly: he -> grc -> en resonance plus +// one undeclared en-collapse-* target (invalid). +const FIXTURE: LogosAlignmentRow[] = [ + { + edge_id: "edge-he-grc-1", + source_id: "he-001", + target_id: "grc-001", + relation: "cross_lang.logos.utterance", + weight: 0.95, + evidence_ids: ["John1:1", "Gen1:1"], + target_pack_id: "grc_logos_micro_v1", + target_resolved: true, + invalid_target: false, + }, + { + edge_id: "edge-grc-en-1", + source_id: "grc-001", + target_id: "en-024", + relation: "cross_lang.logos.utterance.en", + weight: 0.95, + evidence_ids: ["John1:1"], + target_pack_id: "en_core_cognition_v1", + target_resolved: true, + invalid_target: false, + }, + { + edge_id: "edge-grc-collapse-1", + source_id: "grc-023", + target_id: "en-collapse-breath", + relation: "cross_lang.no_english_collapse", + weight: 0.0, + evidence_ids: ["adr-0073c"], + target_pack_id: null, + target_resolved: false, + invalid_target: true, + }, +]; + +function compactLayout(rows: readonly LogosAlignmentRow[]) { + const { nodes, edges } = alignmentToDag(rows); + const layout = layoutDag(nodes, edges); + return { + nodes: layout.nodes.map(({ id, layer, row, x, y }) => ({ id, layer, row, x, y })), + edges: layout.edges.map(({ from, to, points }) => ({ from, to, points })), + width: layout.width, + height: layout.height, + }; +} + +describe("alignmentToDag", () => { + it("matches the committed golden deterministic layout", () => { + expect(compactLayout(FIXTURE)).toEqual(golden); + }); + + it("is order-independent in node placement (sorted) and re-runs identically", () => { + const reversed = [...FIXTURE].reverse(); + // Nodes are sorted, so node placement is invariant to edge order. + expect(compactLayout(reversed).nodes).toEqual(compactLayout(FIXTURE).nodes); + // And the projection itself is referentially deterministic. + expect(alignmentToDag(FIXTURE)).toEqual(alignmentToDag(FIXTURE)); + }); + + it("derives one node per distinct id and one edge per row", () => { + const { nodes, edges } = alignmentToDag(FIXTURE); + expect(nodes.map((n) => n.id)).toEqual([ + "en-024", + "en-collapse-breath", + "grc-001", + "grc-023", + "he-001", + ]); + expect(edges).toHaveLength(FIXTURE.length); + }); +}); + +describe("AlignmentTab", () => { + it("renders the resonance graph, edge cards, and an honest invalid-target warning", () => { + render( {}} />); + + expect(screen.getByLabelText("CORE-Logos cross-language alignment graph")).toBeInTheDocument(); + expect(screen.getByText("3 alignment edges")).toBeInTheDocument(); + // The undeclared collapse anchor surfaces as invalid — not smoothed over. + expect(screen.getByText("1 invalid target")).toBeInTheDocument(); + expect( + screen.getByText(/invalid target — en-collapse-breath resolves to no declared lexicon entry/), + ).toBeInTheDocument(); + expect(screen.getAllByText("John1:1").length).toBeGreaterThan(0); + expect(screen.getByText("Gen1:1")).toBeInTheDocument(); + }); + + it("invokes onSelect with the row when an edge card is activated", async () => { + const onSelect = vi.fn(); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText("cross_lang.logos.utterance")); + expect(onSelect).toHaveBeenCalledWith(FIXTURE[0]); + }); + + it("renders an honest empty state when the pack declares no edges", () => { + render( {}} />); + expect( + screen.getByText("This pack declares no cross-language alignment edges."), + ).toBeInTheDocument(); + }); +}); diff --git a/workbench-ui/src/app/logos/LogosAlignmentTab.tsx b/workbench-ui/src/app/logos/LogosAlignmentTab.tsx new file mode 100644 index 00000000..3ec54444 --- /dev/null +++ b/workbench-ui/src/app/logos/LogosAlignmentTab.tsx @@ -0,0 +1,168 @@ +import { useMemo } from "react"; +import { DagViewer } from "../../design/components/Dag/Dag"; +import type { DagEdgeInput, DagNodeInput } from "../../design/components/Dag/layout"; +import type { LogosAlignmentRow } from "../../types/api"; + +// Read-only CORE-Logos alignment tab (LG-4). The trilingual resonance graph +// (he -> grc -> en) renders through the deterministic Dag primitive; the edge +// list carries selection + honest invalid-target warnings. Nothing is +// recomputed — every value is a field from GET /logos/packs/{id}/alignment. + +/** + * Pure, deterministic projection of alignment rows into Dag inputs. Exported so + * the golden-file layout test can pin the geometry. Node order is sorted (not + * row order) so the layout is stable regardless of edge ordering; edges follow + * row order (the endpoint is already deterministic). + */ +export function alignmentToDag(rows: readonly LogosAlignmentRow[]): { + nodes: DagNodeInput[]; + edges: DagEdgeInput[]; +} { + const ids = new Set(); + for (const row of rows) { + ids.add(row.source_id); + ids.add(row.target_id); + } + const nodes: DagNodeInput[] = Array.from(ids) + .sort((a, b) => a.localeCompare(b)) + .map((id) => ({ id, label: id })); + const edges: DagEdgeInput[] = rows.map((row) => ({ + from: row.source_id, + to: row.target_id, + label: row.relation, + })); + return { nodes, edges }; +} + +function languageOf(id: string): string { + if (id.startsWith("he-")) return "Hebrew"; + if (id.startsWith("grc-")) return "Koine Greek"; + if (id.startsWith("en-")) return "English"; + return "—"; +} + +function EdgeCard({ + row, + selected, + onSelect, +}: { + row: LogosAlignmentRow; + selected: boolean; + onSelect: () => void; +}) { + return ( +
{ + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + onSelect(); + }} + className={`grid gap-1 rounded-md border px-3 py-2 text-left transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)] ${ + row.invalid_target + ? "border-[var(--color-state-warning-border)] bg-[var(--color-state-warning-bg)]" + : selected + ? "border-[var(--color-selected-border)] bg-[var(--color-selected-bg)]" + : "border-[var(--color-border-subtle)] hover:bg-[var(--color-surface-inset)]" + }`} + > +
+ + {row.source_id} {row.target_id} + + {row.weight.toFixed(2)} +
+
+ {row.relation} + · + + {languageOf(row.source_id)} → {languageOf(row.target_id)} + + {row.target_pack_id ? ( + <> + · + {row.target_pack_id} + + ) : null} +
+ {row.evidence_ids.length > 0 ? ( +
+ {row.evidence_ids.map((e) => ( + + {e} + + ))} +
+ ) : null} + {row.invalid_target ? ( +

+ invalid target — {row.target_id} resolves to no declared lexicon entry +

+ ) : null} +
+ ); +} + +export function AlignmentTab({ + rows, + selectedEdgeId, + onSelect, +}: { + rows: readonly LogosAlignmentRow[]; + selectedEdgeId: string | null; + onSelect: (row: LogosAlignmentRow) => void; +}) { + const { nodes, edges } = useMemo(() => alignmentToDag(rows), [rows]); + const invalidCount = useMemo( + () => rows.filter((r) => r.invalid_target).length, + [rows], + ); + + if (rows.length === 0) { + return ( +

+ This pack declares no cross-language alignment edges. +

+ ); + } + + return ( +
+
+

+ Resonance graph (deterministic) +

+ +
+
+
+ {rows.length} alignment edges + {invalidCount > 0 ? ( + + {invalidCount} invalid target{invalidCount === 1 ? "" : "s"} + + ) : null} +
+ {rows.map((row) => ( + onSelect(row)} + /> + ))} +
+
+ ); +} diff --git a/workbench-ui/src/app/logos/LogosRoute.test.tsx b/workbench-ui/src/app/logos/LogosRoute.test.tsx index 19ad057b..626d8901 100644 --- a/workbench-ui/src/app/logos/LogosRoute.test.tsx +++ b/workbench-ui/src/app/logos/LogosRoute.test.tsx @@ -5,6 +5,7 @@ import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createTestQueryClient } from "../../test/createTestQueryClient"; import type { + LogosAlignmentRow, LogosPackContents, LogosPackOverview, LogosPackSummary, @@ -174,6 +175,34 @@ function contentsFor(packId: string): LogosPackContents { }; } +function alignmentFor(packId: string): LogosAlignmentRow[] { + const he = packId.includes("he"); + return [ + { + edge_id: "edge-1", + source_id: he ? "he-001" : "grc-001", + target_id: he ? "grc-001" : "en-024", + relation: "cross_lang.logos.utterance", + weight: 0.95, + evidence_ids: ["John1:1", "Gen1:1"], + target_pack_id: he ? "grc_logos_micro_v1" : "en_core_cognition_v1", + target_resolved: true, + invalid_target: false, + }, + { + edge_id: "edge-collapse", + source_id: he ? "he-023" : "grc-023", + target_id: "en-collapse-breath", + relation: "cross_lang.no_english_collapse", + weight: 0.0, + evidence_ids: ["adr-0073c"], + target_pack_id: null, + target_resolved: false, + invalid_target: true, + }, + ]; +} + function okEnvelope(data: unknown) { return { ok: true, generated_at: "2026-06-14T00:00:00Z", data }; } @@ -202,6 +231,13 @@ function stubLogosFetch() { json: async () => okEnvelope(contentsFor(packId)), }); } + const alignmentMatch = path.match(/^\/logos\/packs\/([^/]+)\/alignment$/); + if (alignmentMatch) { + const packId = decodeURIComponent(alignmentMatch[1]); + return Promise.resolve({ + json: async () => okEnvelope({ items: alignmentFor(packId) }), + }); + } const overviewMatch = path.match(/^\/logos\/packs\/([^/]+)$/); if (overviewMatch) { const packId = decodeURIComponent(overviewMatch[1]); @@ -298,7 +334,7 @@ describe("LogosRoute", () => { expect(screen.queryByText(/Draft proposal/i)).not.toBeInTheDocument(); }); - it("selects a pack, renders Overview, projects logos_pack evidence, and defers only the alignment endpoint", async () => { + it("selects a pack, renders Overview, projects logos_pack evidence, and fetches contents + alignment", async () => { const { paths } = stubLogosFetch(); const user = userEvent.setup(); renderRoute(); @@ -316,9 +352,9 @@ describe("LogosRoute", () => { expect(screen.queryByText(/proof card/i)).not.toBeInTheDocument(); expect(screen.getByText("CORE-Logos Pack")).toBeInTheDocument(); expect(screen.getByText("0 / missing_evidence")).toBeInTheDocument(); - // LG-3 fetches contents eagerly on pack-select; /alignment stays deferred to LG-4. + // LG-3 + LG-4 fetch contents and alignment eagerly on pack-select. expect(paths).toContain("/logos/packs/he_logos_micro_v1/contents"); - expect(paths).not.toContain("/logos/packs/he_logos_micro_v1/alignment"); + expect(paths).toContain("/logos/packs/he_logos_micro_v1/alignment"); }); it("renders Identity passport fields and the raw live overview projection", async () => { @@ -411,4 +447,44 @@ describe("LogosRoute", () => { await user.click(screen.getByText("word, matter, or spoken thing")); expect(await screen.findByText("CORE-Logos Gloss")).toBeInTheDocument(); }); + + it("renders the Alignment tab, surfaces invalid targets, and projects a logos_alignment_edge subject", async () => { + stubLogosFetch(); + const user = userEvent.setup(); + renderRoute("/logos/he_logos_micro_v1"); + + await user.click(await screen.findByRole("tab", { name: "Alignment" })); + + // Real edges from /alignment, deterministic graph, honest invalid-target warning. + expect( + await screen.findByLabelText("CORE-Logos cross-language alignment graph"), + ).toBeInTheDocument(); + expect(screen.getByText("2 alignment edges")).toBeInTheDocument(); + expect(screen.getByText("1 invalid target")).toBeInTheDocument(); + expect( + screen.getByText(/invalid target — en-collapse-breath resolves to no declared lexicon entry/), + ).toBeInTheDocument(); + + await user.click(screen.getByText("cross_lang.logos.utterance")); + + expect(await screen.findByText("CORE-Logos Alignment Edge")).toBeInTheDocument(); + await waitFor(() => + expect(screen.getByTestId("location")).toHaveTextContent( + "inspect=logos_alignment_edge%3Ahe_logos_micro_v1%2Fedge-1", + ), + ); + }); + + it("never renders a holonomy tab or proof/success element, even with alignment present", async () => { + stubLogosFetch(); + const user = userEvent.setup(); + renderRoute("/logos/he_logos_micro_v1"); + + await user.click(await screen.findByRole("tab", { name: "Alignment" })); + await screen.findByLabelText("CORE-Logos cross-language alignment graph"); + + expect(screen.queryByRole("tab", { name: /holonomy/i })).not.toBeInTheDocument(); + expect(screen.queryByText(/proof card/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/holonomy proof/i)).not.toBeInTheDocument(); + }); }); diff --git a/workbench-ui/src/app/logos/LogosRoute.tsx b/workbench-ui/src/app/logos/LogosRoute.tsx index 1a51061b..bd76de7d 100644 --- a/workbench-ui/src/app/logos/LogosRoute.tsx +++ b/workbench-ui/src/app/logos/LogosRoute.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { WorkbenchApiError } from "../../api/client"; import { + useLogosPackAlignment, useLogosPackContents, useLogosPackOverview, useLogosPacks, @@ -21,6 +22,7 @@ import { SafetyVerdictBadge, } from "../../design/components/badges"; import type { + LogosAlignmentRow, LogosAlignmentTargetIssue, LogosGlossRow, LogosLexiconRow, @@ -36,6 +38,7 @@ import { pushRecentItem } from "../commandRegistry"; import { subjectToUrl } from "../evidenceAddress"; import { useEvidenceSubject, type EvidenceSubject } from "../evidenceContext"; import { GlossesTab, LexiconTab, MorphologyTab } from "./LogosContentsTabs"; +import { AlignmentTab } from "./LogosAlignmentTab"; const LOGOS_TABS: readonly Tab[] = [ { id: "overview", label: "Overview" }, @@ -43,6 +46,7 @@ const LOGOS_TABS: readonly Tab[] = [ { id: "lexicon", label: "Lexicon" }, { id: "glosses", label: "Glosses" }, { id: "morphology", label: "Morphology" }, + { id: "alignment", label: "Alignment" }, { id: "safety", label: "Safety" }, ]; @@ -586,10 +590,12 @@ interface ContentsSelection { entryId: string | null; glossId: string | null; morphologyId: string | null; + edgeId: string | null; danglingEntryIds: ReadonlySet; onSelectEntry: (row: LogosLexiconRow) => void; onSelectGloss: (row: LogosGlossRow) => void; onSelectMorphology: (row: LogosMorphologyRow) => void; + onSelectEdge: (row: LogosAlignmentRow) => void; } function ContentsGate({ @@ -658,6 +664,9 @@ function Workspace({ contents, contentsLoading, contentsError, + alignment, + alignmentLoading, + alignmentError, selection, }: { selectedPackId: string | null; @@ -670,6 +679,9 @@ function Workspace({ contents?: LogosPackContents; contentsLoading: boolean; contentsError: unknown; + alignment?: LogosAlignmentRow[]; + alignmentLoading: boolean; + alignmentError: unknown; selection: ContentsSelection; }) { const [activeTab, setActiveTab] = useState("overview"); @@ -721,6 +733,24 @@ function Workspace({ selection={selection} /> ) : null} + {activeTab === "alignment" ? ( + alignmentLoading ? ( + + ) : alignmentError ? ( + + ) : alignment ? ( + + ) : null + ) : null} {activeTab === "safety" ? ( safetyLoading ? ( @@ -750,6 +780,7 @@ export function LogosRoute() { const overviewQuery = useLogosPackOverview(selectedPackId); const safetyQuery = useLogosPackSafety(selectedPackId); const contentsQuery = useLogosPackContents(selectedPackId); + const alignmentQuery = useLogosPackAlignment(selectedPackId); useEffect(() => { if (selectedPackId === null) return; @@ -782,6 +813,7 @@ export function LogosRoute() { entryId: subject.kind === "logos_entry" ? subject.entryId : null, glossId: subject.kind === "logos_gloss" ? subject.glossId : null, morphologyId: subject.kind === "logos_morphology" ? subject.morphologyId : null, + edgeId: subject.kind === "logos_alignment_edge" ? subject.edgeId : null, danglingEntryIds: new Set( safetyQuery.data?.dangling_morphology_links.map((issue) => issue.entry_id) ?? [], ), @@ -808,6 +840,17 @@ export function LogosRoute() { data: row, }, ), + onSelectEdge: (row) => + selectSubEntity( + selectedPackId === null + ? { kind: "none" } + : { + kind: "logos_alignment_edge", + packId: selectedPackId, + edgeId: row.edge_id, + data: row, + }, + ), }; if (packsQuery.isLoading) { @@ -856,6 +899,9 @@ export function LogosRoute() { contents={contentsQuery.data} contentsLoading={contentsQuery.isLoading} contentsError={contentsQuery.isError ? contentsQuery.error : null} + alignment={alignmentQuery.data} + alignmentLoading={alignmentQuery.isLoading} + alignmentError={alignmentQuery.isError ? alignmentQuery.error : null} selection={selection} />