Merge pull request #713 from AssetOverflow/feat/wb-r1-design-mastery
feat(workbench): design mastery pass — chain rail, typography, doctrine-as-tests (Wave R R1)
This commit is contained in:
commit
41755c001f
26 changed files with 783 additions and 47 deletions
49
scripts/dump-schemas.py
Normal file
49
scripts/dump-schemas.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
"""Dump workbench API dataclass field names for UI schema-drift tests.
|
||||
|
||||
Read-only helper: parses workbench/schemas.py with Python AST and writes
|
||||
JSON to stdout — {class_name: [own_field, ...]}. Inherited fields belong
|
||||
to the parent class entry (the TS mirrors use `extends` the same way).
|
||||
Same pattern as scripts/dump-enums.py (ADR-0162 enum coverage).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCHEMAS = ROOT / "workbench" / "schemas.py"
|
||||
|
||||
|
||||
def is_dataclass_decorated(node: ast.ClassDef) -> bool:
|
||||
for dec in node.decorator_list:
|
||||
target = dec.func if isinstance(dec, ast.Call) else dec
|
||||
if isinstance(target, ast.Name) and target.id == "dataclass":
|
||||
return True
|
||||
if isinstance(target, ast.Attribute) and target.attr == "dataclass":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def own_fields(node: ast.ClassDef) -> list[str]:
|
||||
fields: list[str] = []
|
||||
for statement in node.body:
|
||||
if isinstance(statement, ast.AnnAssign) and isinstance(statement.target, ast.Name):
|
||||
fields.append(statement.target.id)
|
||||
return fields
|
||||
|
||||
|
||||
def main() -> None:
|
||||
module = ast.parse(SCHEMAS.read_text(encoding="utf-8"))
|
||||
out: dict[str, list[str]] = {}
|
||||
for node in module.body:
|
||||
if isinstance(node, ast.ClassDef) and is_dataclass_decorated(node):
|
||||
out[node.name] = own_fields(node)
|
||||
if not out:
|
||||
raise SystemExit("no dataclasses found in workbench/schemas.py")
|
||||
print(json.dumps(out, indent=2, sort_keys=True))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -12,7 +12,8 @@
|
|||
"test:e2e": "playwright test",
|
||||
"test:enum-coverage": "pnpm enum:snapshot && vitest run src/design/components/badges/enumCoverage.test.ts",
|
||||
"generate:tokens": "tsx scripts/generate-tokens.ts",
|
||||
"enum:snapshot": "cd .. && uv run python scripts/dump-enums.py > workbench-ui/enum-snapshot.json"
|
||||
"enum:snapshot": "cd .. && uv run python scripts/dump-enums.py > workbench-ui/enum-snapshot.json",
|
||||
"schema:snapshot": "cd .. && uv run python scripts/dump-schemas.py > workbench-ui/schema-snapshot.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "1.1.15",
|
||||
|
|
|
|||
223
workbench-ui/schema-snapshot.json
Normal file
223
workbench-ui/schema-snapshot.json
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
{
|
||||
"ArtifactDetail": [
|
||||
"content_type",
|
||||
"content"
|
||||
],
|
||||
"ArtifactRef": [
|
||||
"artifact_id",
|
||||
"kind",
|
||||
"path",
|
||||
"digest",
|
||||
"created_at"
|
||||
],
|
||||
"AuditEvent": [
|
||||
"event_id",
|
||||
"source",
|
||||
"source_path",
|
||||
"timestamp",
|
||||
"event_type",
|
||||
"mutation_boundary",
|
||||
"summary",
|
||||
"ref_id",
|
||||
"payload_digest",
|
||||
"payload"
|
||||
],
|
||||
"ChatTurnResult": [
|
||||
"prompt",
|
||||
"surface",
|
||||
"articulation_surface",
|
||||
"walk_surface",
|
||||
"grounding_source",
|
||||
"epistemic_state",
|
||||
"normative_clearance",
|
||||
"normative_detail",
|
||||
"trace_hash",
|
||||
"refusal_emitted",
|
||||
"hedge_injected",
|
||||
"mutation_mode",
|
||||
"identity_verdict",
|
||||
"safety_verdict",
|
||||
"ethics_verdict",
|
||||
"proposal_candidates",
|
||||
"turn_cost_ms",
|
||||
"checkpoint_emitted",
|
||||
"turn_id"
|
||||
],
|
||||
"EvalLaneSummary": [
|
||||
"lane",
|
||||
"versions",
|
||||
"read_only",
|
||||
"description"
|
||||
],
|
||||
"EvalRunResult": [
|
||||
"lane",
|
||||
"version",
|
||||
"split",
|
||||
"passed",
|
||||
"metrics",
|
||||
"cases",
|
||||
"source_digest"
|
||||
],
|
||||
"MathProposalDetail": [
|
||||
"wrong_zero_assertion",
|
||||
"proposed_change_payload",
|
||||
"reasoning_trace_id",
|
||||
"reasoning_trace_steps",
|
||||
"evidence_hashes",
|
||||
"handler_name",
|
||||
"suggested_ratify_cli"
|
||||
],
|
||||
"MathProposalSummary": [
|
||||
"proposal_id",
|
||||
"domain",
|
||||
"shape_category",
|
||||
"proposed_change_kind",
|
||||
"structural_commonality",
|
||||
"evidence_count",
|
||||
"replay_equivalence_hash"
|
||||
],
|
||||
"MathRatifyResult": [
|
||||
"proposal_id",
|
||||
"change_kind",
|
||||
"handler_name",
|
||||
"routing_status",
|
||||
"message",
|
||||
"suggested_cli",
|
||||
"applied",
|
||||
"target_path",
|
||||
"evidence_hash"
|
||||
],
|
||||
"MathReasoningStep": [
|
||||
"step_index",
|
||||
"step_kind",
|
||||
"claim",
|
||||
"justification",
|
||||
"input_pointers",
|
||||
"output_payload"
|
||||
],
|
||||
"PackDetail": [
|
||||
"manifest_digest",
|
||||
"manifest"
|
||||
],
|
||||
"PackSummary": [
|
||||
"pack_id",
|
||||
"source",
|
||||
"manifest_path",
|
||||
"version",
|
||||
"language",
|
||||
"modality",
|
||||
"determinism_class",
|
||||
"checksum",
|
||||
"checksums"
|
||||
],
|
||||
"ProposalDetail": [
|
||||
"proposed_chain",
|
||||
"replay_evidence",
|
||||
"source",
|
||||
"evidence",
|
||||
"artifact_refs",
|
||||
"suggested_cli"
|
||||
],
|
||||
"ProposalRef": [
|
||||
"candidate_id",
|
||||
"source_kind"
|
||||
],
|
||||
"ProposalSummary": [
|
||||
"proposal_id",
|
||||
"state",
|
||||
"source_kind",
|
||||
"replay_equivalent",
|
||||
"created_at",
|
||||
"downstream_effect"
|
||||
],
|
||||
"ReplayComparison": [
|
||||
"artifact_id",
|
||||
"original_hash",
|
||||
"replay_hash",
|
||||
"equivalent",
|
||||
"divergences"
|
||||
],
|
||||
"ReplayDivergence": [
|
||||
"path",
|
||||
"original",
|
||||
"replay",
|
||||
"severity"
|
||||
],
|
||||
"RunDetail": [
|
||||
"turns",
|
||||
"manifest"
|
||||
],
|
||||
"RunSummary": [
|
||||
"session_id",
|
||||
"source",
|
||||
"turn_count",
|
||||
"started_at",
|
||||
"updated_at",
|
||||
"checkpoint_present",
|
||||
"checkpoint_revision",
|
||||
"artifact_refs",
|
||||
"evidence_gap"
|
||||
],
|
||||
"RunTurnRef": [
|
||||
"turn_id",
|
||||
"trace_hash",
|
||||
"timestamp",
|
||||
"trace_path",
|
||||
"surface_excerpt"
|
||||
],
|
||||
"RuntimeStatus": [
|
||||
"backend",
|
||||
"git_revision",
|
||||
"engine_state_present",
|
||||
"checkpoint_revision",
|
||||
"revision_warning",
|
||||
"active_session_id",
|
||||
"mutation_mode"
|
||||
],
|
||||
"TurnJournalEntrySchema": [
|
||||
"turn_id",
|
||||
"timestamp",
|
||||
"trace_hash",
|
||||
"prompt",
|
||||
"surface",
|
||||
"articulation_surface",
|
||||
"walk_surface",
|
||||
"grounding_source",
|
||||
"epistemic_state",
|
||||
"normative_clearance",
|
||||
"verdicts",
|
||||
"refusal_emitted",
|
||||
"hedge_injected",
|
||||
"proposal_candidates",
|
||||
"turn_cost_ms",
|
||||
"checkpoint_emitted",
|
||||
"journal_digest"
|
||||
],
|
||||
"TurnJournalSummarySchema": [
|
||||
"turn_id",
|
||||
"timestamp",
|
||||
"prompt_excerpt",
|
||||
"surface_excerpt",
|
||||
"trace_hash",
|
||||
"grounding_source"
|
||||
],
|
||||
"TurnVerdict": [
|
||||
"outcome",
|
||||
"runtime_detail"
|
||||
],
|
||||
"VaultEntry": [
|
||||
"entry_index",
|
||||
"epistemic_status",
|
||||
"epistemic_state",
|
||||
"metadata",
|
||||
"versor_digest"
|
||||
],
|
||||
"VaultSummary": [
|
||||
"source_path",
|
||||
"entry_count",
|
||||
"store_count",
|
||||
"reproject_interval",
|
||||
"max_entries",
|
||||
"persisted"
|
||||
]
|
||||
}
|
||||
107
workbench-ui/src/app/EvidenceChainRail.test.tsx
Normal file
107
workbench-ui/src/app/EvidenceChainRail.test.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { EvidenceChainRail, deriveStages } from "./EvidenceChainRail";
|
||||
import type { EvidenceSubject } from "./evidenceContext";
|
||||
import type { ChatTurnResult, ProposalDetail } from "../types/api";
|
||||
|
||||
const FULL_TURN: ChatTurnResult = {
|
||||
prompt: "What is alpha?",
|
||||
surface: "alpha causes beta",
|
||||
articulation_surface: "alpha causes beta",
|
||||
walk_surface: "alpha -> beta",
|
||||
grounding_source: "teaching",
|
||||
epistemic_state: "decoded",
|
||||
normative_clearance: "cleared",
|
||||
normative_detail: "",
|
||||
trace_hash: "sha256:abc123",
|
||||
refusal_emitted: false,
|
||||
hedge_injected: false,
|
||||
mutation_mode: "runtime_turn",
|
||||
identity_verdict: null,
|
||||
safety_verdict: null,
|
||||
ethics_verdict: null,
|
||||
proposal_candidates: [],
|
||||
turn_cost_ms: 17,
|
||||
checkpoint_emitted: true,
|
||||
};
|
||||
|
||||
function turnSubject(data: ChatTurnResult | undefined): EvidenceSubject {
|
||||
return { kind: "turn", turnId: 1, data } as EvidenceSubject;
|
||||
}
|
||||
|
||||
function statusOf(subject: EvidenceSubject, stageId: string): string {
|
||||
const stages = deriveStages(subject);
|
||||
const found = stages?.find((s) => s.id === stageId);
|
||||
if (!found) throw new Error(`stage not derived: ${stageId}`);
|
||||
return found.status;
|
||||
}
|
||||
|
||||
describe("EvidenceChainRail honesty contract", () => {
|
||||
it("a fully-evidenced turn lights exactly the applicable stages", () => {
|
||||
const subject = turnSubject(FULL_TURN);
|
||||
expect(statusOf(subject, "intent")).toBe("lit");
|
||||
expect(statusOf(subject, "subject")).toBe("lit");
|
||||
expect(statusOf(subject, "provenance")).toBe("lit");
|
||||
expect(statusOf(subject, "admissibility")).toBe("lit");
|
||||
expect(statusOf(subject, "replay")).toBe("lit");
|
||||
expect(statusOf(subject, "authority")).toBe("lit");
|
||||
expect(statusOf(subject, "action")).toBe("dim");
|
||||
});
|
||||
|
||||
it("MEANINGFULLY FAILS: removing a single field hollows exactly its stage", () => {
|
||||
// This is the test that breaks if anyone makes the rail infer status
|
||||
// instead of deriving it from the named field.
|
||||
const noHash = turnSubject({ ...FULL_TURN, trace_hash: "" });
|
||||
expect(statusOf(noHash, "replay")).toBe("hollow");
|
||||
expect(statusOf(noHash, "provenance")).toBe("lit");
|
||||
|
||||
const noGrounding = turnSubject({ ...FULL_TURN, grounding_source: "" as ChatTurnResult["grounding_source"] });
|
||||
expect(statusOf(noGrounding, "provenance")).toBe("hollow");
|
||||
expect(statusOf(noGrounding, "replay")).toBe("lit");
|
||||
});
|
||||
|
||||
it("identity-only subject (detail not loaded): applicable stages are hollow, never lit", () => {
|
||||
const subject = turnSubject(undefined);
|
||||
for (const id of ["intent", "provenance", "admissibility", "replay", "authority"]) {
|
||||
expect(statusOf(subject, id)).toBe("hollow");
|
||||
}
|
||||
expect(statusOf(subject, "subject")).toBe("lit");
|
||||
});
|
||||
|
||||
it("proposal: replay_equivalent=false is still recorded evidence (lit), null is hollow", () => {
|
||||
const base: ProposalDetail = {
|
||||
proposal_id: "p-1",
|
||||
state: "pending",
|
||||
source_kind: "contemplation",
|
||||
replay_equivalent: false,
|
||||
created_at: null,
|
||||
downstream_effect: "unknown",
|
||||
proposed_chain: [],
|
||||
provenance: null,
|
||||
suggested_cli: null,
|
||||
} as unknown as ProposalDetail;
|
||||
|
||||
const recorded: EvidenceSubject = { kind: "proposal", proposalId: "p-1", data: base } as EvidenceSubject;
|
||||
expect(statusOf(recorded, "replay")).toBe("lit");
|
||||
|
||||
const unrecorded: EvidenceSubject = {
|
||||
kind: "proposal",
|
||||
proposalId: "p-1",
|
||||
data: { ...base, replay_equivalent: null },
|
||||
} as EvidenceSubject;
|
||||
expect(statusOf(unrecorded, "replay")).toBe("hollow");
|
||||
});
|
||||
|
||||
it("renders no rail for kind=none", () => {
|
||||
const { container } = render(<EvidenceChainRail subject={{ kind: "none" }} />);
|
||||
expect(container.querySelector('[data-testid="evidence-chain-rail"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("renders all seven stages with status data attributes", () => {
|
||||
const { container } = render(<EvidenceChainRail subject={turnSubject(FULL_TURN)} />);
|
||||
const items = container.querySelectorAll("[data-stage]");
|
||||
expect(items.length).toBe(7);
|
||||
expect(container.querySelectorAll('[data-status="lit"]').length).toBe(6);
|
||||
expect(container.querySelectorAll('[data-status="dim"]').length).toBe(1);
|
||||
});
|
||||
});
|
||||
186
workbench-ui/src/app/EvidenceChainRail.tsx
Normal file
186
workbench-ui/src/app/EvidenceChainRail.tsx
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import type { CSSProperties } from "react";
|
||||
import type { EvidenceSubject } from "./evidenceContext";
|
||||
|
||||
/**
|
||||
* EvidenceChainRail — the spine's seven stages rendered for the selected
|
||||
* subject (Wave R brief R1):
|
||||
*
|
||||
* intent -> subject -> provenance -> admissibility -> replay -> authority -> action
|
||||
*
|
||||
* HONESTY CONTRACT: a stage's status derives ONLY from fields the subject
|
||||
* actually carries. `lit` = the named field holds evidence. `hollow` = the
|
||||
* stage applies to this subject kind but no evidence is recorded/loaded.
|
||||
* `dim` = the stage does not apply to this kind. Nothing is ever inferred:
|
||||
* a recorded trace hash lights "replay" as "trace hash recorded" — it does
|
||||
* NOT claim replay was verified.
|
||||
*/
|
||||
|
||||
export type StageStatus = "lit" | "hollow" | "dim";
|
||||
|
||||
export interface RailStage {
|
||||
id: string;
|
||||
label: string;
|
||||
status: StageStatus;
|
||||
/** What the status derives from — shown in the tooltip, audit-honest. */
|
||||
derivation: string;
|
||||
}
|
||||
|
||||
const STAGE_IDS = [
|
||||
"intent",
|
||||
"subject",
|
||||
"provenance",
|
||||
"admissibility",
|
||||
"replay",
|
||||
"authority",
|
||||
"action",
|
||||
] as const;
|
||||
|
||||
function stage(
|
||||
id: (typeof STAGE_IDS)[number],
|
||||
status: StageStatus,
|
||||
derivation: string,
|
||||
): RailStage {
|
||||
return { id, label: id, status, derivation };
|
||||
}
|
||||
|
||||
function evidenceOf(value: unknown): "lit" | "hollow" {
|
||||
if (value === null || value === undefined || value === "") return "hollow";
|
||||
return "lit";
|
||||
}
|
||||
|
||||
/** Pure derivation — exported for the meaningfully-fail tests. */
|
||||
export function deriveStages(subject: EvidenceSubject): RailStage[] | null {
|
||||
switch (subject.kind) {
|
||||
case "none":
|
||||
return null;
|
||||
case "turn": {
|
||||
const d = subject.data;
|
||||
return [
|
||||
stage("intent", d ? evidenceOf(d.prompt) : "hollow", "prompt"),
|
||||
stage("subject", "lit", "selected turn"),
|
||||
stage("provenance", d ? evidenceOf(d.grounding_source) : "hollow", "grounding_source"),
|
||||
stage(
|
||||
"admissibility",
|
||||
d ? evidenceOf(d.epistemic_state) : "hollow",
|
||||
"epistemic_state + normative_clearance",
|
||||
),
|
||||
stage("replay", d ? evidenceOf(d.trace_hash) : "hollow", "trace_hash recorded (not a verification claim)"),
|
||||
stage("authority", d ? evidenceOf(d.mutation_mode) : "hollow", "mutation_mode"),
|
||||
stage("action", "dim", "not applicable to a completed turn"),
|
||||
];
|
||||
}
|
||||
case "proposal": {
|
||||
const d = subject.data;
|
||||
return [
|
||||
stage("intent", "dim", "not applicable — proposals originate in contemplation"),
|
||||
stage("subject", "lit", "selected proposal"),
|
||||
stage("provenance", d ? evidenceOf(d.source_kind) : "hollow", "source_kind"),
|
||||
stage(
|
||||
"admissibility",
|
||||
d ? (d.replay_equivalent === null ? "hollow" : "lit") : "hollow",
|
||||
"replay_equivalent recorded",
|
||||
),
|
||||
stage(
|
||||
"replay",
|
||||
d ? (d.replay_equivalent === null ? "hollow" : "lit") : "hollow",
|
||||
"replay_equivalent value (true and false are both evidence)",
|
||||
),
|
||||
stage("authority", d ? evidenceOf(d.state) : "hollow", "review state (ADR-0057 machine)"),
|
||||
stage("action", d ? evidenceOf(d.suggested_cli) : "hollow", "suggested_cli"),
|
||||
];
|
||||
}
|
||||
case "artifact": {
|
||||
const d = subject.data;
|
||||
return [
|
||||
stage("intent", "dim", "not applicable to stored artifacts"),
|
||||
stage("subject", "lit", "selected artifact"),
|
||||
stage("provenance", d ? evidenceOf(d.path) : "hollow", "path"),
|
||||
stage("admissibility", "dim", "not applicable to stored artifacts"),
|
||||
stage("replay", d ? evidenceOf(d.digest) : "hollow", "digest"),
|
||||
stage("authority", "dim", "not applicable to stored artifacts"),
|
||||
stage("action", "dim", "not applicable to stored artifacts"),
|
||||
];
|
||||
}
|
||||
case "eval_result": {
|
||||
const d = subject.data;
|
||||
return [
|
||||
stage("intent", "dim", "not applicable to eval runs"),
|
||||
stage("subject", "lit", "selected eval result"),
|
||||
stage("provenance", d ? evidenceOf(d.lane) : "hollow", "lane + version"),
|
||||
stage("admissibility", d ? evidenceOf(d.split) : "hollow", "split (lane discipline)"),
|
||||
stage("replay", d ? evidenceOf(d.source_digest) : "hollow", "source_digest"),
|
||||
stage("authority", "dim", "not applicable to read-only lanes"),
|
||||
stage("action", d ? (d.passed === null ? "hollow" : "lit") : "hollow", "pass/fail recorded"),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const STATUS_STYLE: Record<StageStatus, { dot: CSSProperties; text: string }> = {
|
||||
lit: {
|
||||
dot: {
|
||||
background: "var(--color-state-evidenced)",
|
||||
border: "1px solid var(--color-state-evidenced)",
|
||||
},
|
||||
text: "text-[var(--color-text-secondary)]",
|
||||
},
|
||||
hollow: {
|
||||
dot: {
|
||||
background: "transparent",
|
||||
border: "1px solid var(--color-border-strong)",
|
||||
},
|
||||
text: "text-[var(--color-text-muted)]",
|
||||
},
|
||||
dim: {
|
||||
dot: {
|
||||
background: "var(--color-border-subtle)",
|
||||
border: "1px solid var(--color-border-subtle)",
|
||||
opacity: 0.5,
|
||||
},
|
||||
text: "text-[var(--color-text-muted)] opacity-60",
|
||||
},
|
||||
};
|
||||
|
||||
const STATUS_WORD: Record<StageStatus, string> = {
|
||||
lit: "evidence present",
|
||||
hollow: "not recorded",
|
||||
dim: "not applicable",
|
||||
};
|
||||
|
||||
export function EvidenceChainRail({ subject }: { subject: EvidenceSubject }) {
|
||||
const stages = deriveStages(subject);
|
||||
if (!stages) return null;
|
||||
|
||||
return (
|
||||
<ol
|
||||
className="m-0 flex list-none flex-wrap items-center gap-x-1 gap-y-1 border-b p-0 pb-2"
|
||||
style={{ borderColor: "var(--color-border-subtle)" }}
|
||||
aria-label="Evidence chain"
|
||||
data-testid="evidence-chain-rail"
|
||||
>
|
||||
{stages.map((s, i) => (
|
||||
<li
|
||||
key={s.id}
|
||||
className="flex items-center gap-1"
|
||||
title={`${s.label}: ${STATUS_WORD[s.status]} — ${s.derivation}`}
|
||||
data-stage={s.id}
|
||||
data-status={s.status}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-block h-2 w-2 shrink-0 rounded-full"
|
||||
style={STATUS_STYLE[s.status].dot}
|
||||
/>
|
||||
<span className={`text-[10px] ${STATUS_STYLE[s.status].text}`}>
|
||||
{s.label}
|
||||
</span>
|
||||
{i < stages.length - 1 && (
|
||||
<span aria-hidden className="text-[10px] text-[var(--color-text-muted)]">
|
||||
→
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useEvidenceSubject, type EvidenceSubject } from "./evidenceContext";
|
||||
import { EvidenceChainRail } from "./EvidenceChainRail";
|
||||
import { MetadataTable } from "../design/components/MetadataTable/MetadataTable";
|
||||
import { DigestBadge } from "../design/components/DigestBadge/DigestBadge";
|
||||
import { Timestamp } from "../design/components/Timestamp/Timestamp";
|
||||
|
|
@ -161,6 +162,15 @@ function NoneInspector() {
|
|||
function InspectorContent() {
|
||||
const { subject } = useEvidenceSubject();
|
||||
|
||||
return (
|
||||
<div className="grid content-start gap-2">
|
||||
<EvidenceChainRail subject={subject} />
|
||||
<InspectorProjection subject={subject} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InspectorProjection({ subject }: { subject: EvidenceSubject }) {
|
||||
switch (subject.kind) {
|
||||
case "turn":
|
||||
return <TurnInspector subject={subject} />;
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
import { Copy } from "lucide-react";
|
||||
import { copyText } from "../../design/lib";
|
||||
|
||||
export function CopyableHash({ value, label = "trace_hash" }: { value: string; label?: string }) {
|
||||
const short = value.length > 18 ? `${value.slice(0, 18)}...` : value;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyText(value)}
|
||||
className="inline-flex items-center gap-1 rounded border border-[var(--color-border-subtle)] bg-[var(--color-surface-inset)] px-2 py-1 font-mono text-xs text-[var(--color-text-secondary)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)]"
|
||||
aria-label={`${label}: ${value}`}
|
||||
>
|
||||
<Copy size={12} aria-hidden />
|
||||
{label}:{short}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -10,7 +10,7 @@ import {
|
|||
} from "../../design/components/badges";
|
||||
import type { KeyboardEvent, ReactNode } from "react";
|
||||
import type { ChatTurnResult } from "../../types/api";
|
||||
import { CopyableHash } from "./CopyableHash";
|
||||
import { DigestBadge } from "../../design/components/DigestBadge/DigestBadge";
|
||||
|
||||
export type TraceFocus =
|
||||
| "metadata"
|
||||
|
|
@ -98,7 +98,7 @@ export function EvidenceStrip({
|
|||
) : null}
|
||||
{result.trace_hash ? (
|
||||
<span onClick={() => onOpen("trace")}>
|
||||
<CopyableHash value={result.trace_hash} />
|
||||
<DigestBadge digest={result.trace_hash.replace("sha256:", "")} />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { Button } from "../../design/components/primitives/Button";
|
|||
import { StableJsonViewer } from "../../design/components/StableJsonViewer";
|
||||
import { copyText } from "../../design/lib";
|
||||
import type { ChatTurnResult, TurnVerdict } from "../../types/api";
|
||||
import { CopyableHash } from "./CopyableHash";
|
||||
import { DigestBadge } from "../../design/components/DigestBadge/DigestBadge";
|
||||
import type { TraceFocus } from "./EvidenceStrip";
|
||||
|
||||
function Panel({
|
||||
|
|
@ -159,7 +159,7 @@ export function TraceDrawer({
|
|||
)}
|
||||
</Panel>
|
||||
<Panel id="trace" title="Trace hash + replay">
|
||||
{result.trace_hash ? <CopyableHash value={result.trace_hash} /> : <p className="m-0 text-sm">No trace hash recorded.</p>}
|
||||
{result.trace_hash ? <DigestBadge digest={result.trace_hash.replace("sha256:", "")} /> : <p className="m-0 text-sm">No trace hash recorded.</p>}
|
||||
<button
|
||||
type="button"
|
||||
className="mt-2 rounded border border-[var(--color-border-subtle)] px-2 py-1 font-mono text-xs text-[var(--color-text-secondary)]"
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ export function EvalLaneCard({
|
|||
onClick={onSelect}
|
||||
className={`w-full text-left p-3 rounded-lg border transition-all duration-150 focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)] ${
|
||||
isSelected
|
||||
? "border-[var(--color-border-strong)] bg-[var(--color-surface-raised)] shadow-[var(--shadow-panel)]"
|
||||
? "border-[var(--color-selected-border)] bg-[var(--color-selected-bg)] shadow-[var(--shadow-panel)]"
|
||||
: "border-[var(--color-border-subtle)] bg-[var(--color-surface-base)] hover:bg-[var(--color-surface-raised)]"
|
||||
}`}
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ export function EvalMetricGrid({
|
|||
<div className="text-[10px] font-semibold text-[var(--color-text-muted)] uppercase tracking-wider truncate" title={key}>
|
||||
{key.replaceAll("_", " ")}
|
||||
</div>
|
||||
<div className={`mt-1 font-mono text-sm font-semibold text-[var(--color-text-primary)] ${isObj ? "whitespace-pre overflow-x-auto text-[10px] bg-[var(--color-surface-inset)] p-1.5 rounded" : ""}`}>
|
||||
<div className={`mt-1 font-mono tabular-nums text-sm font-semibold text-[var(--color-text-primary)] ${isObj ? "whitespace-pre overflow-x-auto text-[10px] bg-[var(--color-surface-inset)] p-1.5 rounded" : ""}`}>
|
||||
{formatted}
|
||||
{unit && <span className="ml-1 text-xs text-[var(--color-text-muted)] font-sans font-normal">{unit}</span>}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { useCommandRegistry } from "../commandRegistry";
|
|||
import { subjectToUrl } from "../evidenceAddress";
|
||||
import { useEvalLanes, useEvalRun } from "../../api/queries";
|
||||
import { EvalLaneCard } from "./EvalLaneCard";
|
||||
import { Panel } from "../../design/components/Panel/Panel";
|
||||
import { EvalRunButton } from "./EvalRunButton";
|
||||
import { EvalMetricGrid } from "./EvalMetricGrid";
|
||||
import { EvalFailureViewer } from "./EvalFailureViewer";
|
||||
|
|
@ -118,8 +119,7 @@ export function EvalsRoute() {
|
|||
return (
|
||||
<div className="grid h-full grid-cols-1 gap-4 md:grid-cols-[18rem_1fr]" data-testid="evals-route">
|
||||
{/* Left Pane: Lane List */}
|
||||
<div className="flex flex-col gap-3 border-r border-[var(--color-border-subtle)] pr-4 overflow-y-auto">
|
||||
<h2 className="text-md font-semibold text-[var(--color-text-primary)]">Eval Lanes</h2>
|
||||
<Panel title="Eval Lanes">
|
||||
<div className="flex flex-col gap-2">
|
||||
{lanes && lanes.length > 0 ? (
|
||||
lanes.map((lane) => (
|
||||
|
|
@ -137,7 +137,7 @@ export function EvalsRoute() {
|
|||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{/* Right Pane: Results / Form */}
|
||||
<div className="flex flex-col gap-4 overflow-y-auto pl-2">
|
||||
|
|
|
|||
|
|
@ -53,11 +53,13 @@ export function ProposalTable({
|
|||
role="button"
|
||||
tabIndex={0}
|
||||
className={`grid w-full grid-cols-[minmax(7rem,1fr)_auto_auto_minmax(7rem,1fr)_minmax(9rem,1fr)] items-center gap-3 border-b border-[var(--color-border-subtle)] px-3 py-2 text-left text-sm text-[var(--color-text-primary)] hover:bg-[var(--color-surface-inset)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-inset focus-visible:outline-[var(--color-focus-ring)] transition-all ${
|
||||
selected ? "bg-[var(--color-surface-inset)]" : ""
|
||||
selected ? "bg-[var(--color-selected-bg)]" : ""
|
||||
} ${
|
||||
focused
|
||||
? "bg-[var(--color-surface-inset)] border-l-2 border-[var(--color-focus-ring)] pl-[10px]"
|
||||
: "border-l-2 border-transparent pl-[10px]"
|
||||
selected
|
||||
? "border-l-2 border-l-[var(--color-selected-border)] pl-[10px]"
|
||||
: focused
|
||||
? "border-l-2 border-l-[var(--color-focus-ring)] pl-[10px]"
|
||||
: "border-l-2 border-l-transparent pl-[10px]"
|
||||
}`}
|
||||
key={proposal.proposal_id}
|
||||
onClick={() => onSelect(proposal.proposal_id)}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { ProposalChainViewer } from "./ProposalChainViewer";
|
|||
import { ProposalProvenanceViewer } from "./ProposalProvenanceViewer";
|
||||
import { ProposalSummaryCard } from "./ProposalSummaryCard";
|
||||
import { ProposalTable } from "./ProposalTable";
|
||||
import { Panel } from "../../design/components/Panel/Panel";
|
||||
import { ReplayEvidenceCard } from "./ReplayEvidenceCard";
|
||||
import { RatificationCommandPanel } from "./RatificationCommandPanel";
|
||||
|
||||
|
|
@ -244,12 +245,10 @@ export function ProposalsRoute() {
|
|||
|
||||
return (
|
||||
<div className="grid h-full min-h-0 gap-4 xl:grid-cols-[minmax(34rem,0.95fr)_minmax(32rem,1.05fr)]">
|
||||
<section className="grid min-h-0 content-start gap-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="m-0 text-base font-semibold text-[var(--color-text-primary)]">Proposal Queue</h1>
|
||||
{/* Domain Selector Tabs */}
|
||||
<div className="flex bg-[var(--color-surface-inset)] p-0.5 rounded border border-[var(--color-border-subtle)]">
|
||||
<Panel
|
||||
title="Proposal Queue"
|
||||
toolbar={
|
||||
<div className="flex bg-[var(--color-surface-inset)] p-0.5 rounded border border-[var(--color-border-subtle)]">
|
||||
<button
|
||||
onClick={() => changeDomain("math")}
|
||||
className={`px-3 py-1 rounded text-xs font-semibold transition-all ${
|
||||
|
|
@ -270,9 +269,10 @@ export function ProposalsRoute() {
|
|||
>
|
||||
Cognition Queue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
}
|
||||
>
|
||||
<div className="grid content-start gap-3">
|
||||
{domain === "cognition" && (
|
||||
<div className="flex flex-wrap gap-2" role="group" aria-label="Proposal state filter">
|
||||
{filters.map((state) => (
|
||||
|
|
@ -288,7 +288,6 @@ export function ProposalsRoute() {
|
|||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SearchInput
|
||||
placeholder="Filter by proposal id or source kind"
|
||||
|
|
@ -313,7 +312,8 @@ export function ProposalsRoute() {
|
|||
onSelect={selectProposal}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<section className="min-h-0 overflow-y-auto pr-1">
|
||||
{!selectedProposalId ? (
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ export function ArtifactList({ artifacts, selectedId, onSelect }: ArtifactListPr
|
|||
className={cn(
|
||||
"w-full rounded px-2 py-1.5 text-left focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)]",
|
||||
isSelected
|
||||
? "bg-[var(--color-surface-raised)] text-[var(--color-text-primary)] border-l-2 border-[var(--color-focus-ring)] pl-1.5"
|
||||
? "bg-[var(--color-selected-bg)] text-[var(--color-text-primary)] border-l-2 border-[var(--color-selected-border)] pl-1.5"
|
||||
: "text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text-primary)]"
|
||||
)}
|
||||
aria-current={isSelected ? "true" : undefined}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ describe("DigestBadge", () => {
|
|||
it("renders truncated digest with algorithm prefix", () => {
|
||||
render(<DigestBadge digest={HASH} />);
|
||||
const badge = screen.getByTestId("digest-badge");
|
||||
expect(badge).toHaveTextContent("sha256:4f80f7e12c7e8ca1...");
|
||||
expect(badge).toHaveTextContent("sha256:4f80f7e12c7e...");
|
||||
});
|
||||
|
||||
it("uses custom algorithm prefix", () => {
|
||||
|
|
|
|||
|
|
@ -37,7 +37,8 @@ export function DigestBadge({
|
|||
digest,
|
||||
algorithm = "sha256",
|
||||
verified,
|
||||
truncate = 16,
|
||||
// Wave R hash display standard: 12 visible chars + copy, everywhere.
|
||||
truncate = 12,
|
||||
}: DigestBadgeProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const scheduleReset = useManagedTimeout();
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ export function MetadataTable({ rows }: MetadataTableProps) {
|
|||
{row.key}
|
||||
</dt>
|
||||
<dd
|
||||
className="m-0 flex items-center text-sm text-[var(--color-text-primary)]"
|
||||
className="m-0 flex items-center text-sm text-[var(--color-text-primary)] tabular-nums"
|
||||
style={{
|
||||
fontSize: "var(--text-sm)",
|
||||
fontFamily: row.mono ? "var(--font-mono)" : undefined,
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ export function Panel({ title, toolbar, children }: PanelProps) {
|
|||
style={{ borderColor: "var(--color-border-subtle)" }}
|
||||
>
|
||||
<h2
|
||||
className="m-0 text-sm font-semibold"
|
||||
className="m-0 text-sm font-semibold [text-wrap:balance]"
|
||||
style={{ color: "var(--color-text-primary)" }}
|
||||
>
|
||||
{title}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,20 @@ export function EmptyState({
|
|||
|
||||
return (
|
||||
<section className="rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-surface-raised)] p-4">
|
||||
<p className="m-0 text-sm text-[var(--color-text-primary)]">{statement}</p>
|
||||
{/* Deterministic monochrome glyph: an empty evidence slot. Static
|
||||
inline SVG — same bytes every render, no asset fetch. */}
|
||||
<svg
|
||||
aria-hidden
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
className="mb-2 text-[var(--color-text-muted)]"
|
||||
>
|
||||
<rect x="3" y="3" width="14" height="14" rx="3" stroke="currentColor" strokeDasharray="3 3" />
|
||||
<circle cx="10" cy="10" r="1.5" fill="currentColor" />
|
||||
</svg>
|
||||
<p className="m-0 text-sm text-[var(--color-text-primary)] [text-wrap:balance]">{statement}</p>
|
||||
{typeof nextAction === "string" ? (
|
||||
<Button className="mt-3" variant="quiet" type="button">
|
||||
{nextAction}
|
||||
|
|
|
|||
55
workbench-ui/src/design/doctrine/hexScan.test.ts
Normal file
55
workbench-ui/src/design/doctrine/hexScan.test.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
// @vitest-environment node
|
||||
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* Doctrine-as-test (ADR-0162 §1): token names are semantic; palette values
|
||||
* live ONLY in tokens.css (and its generated TS mirror). A hex or rgb()/
|
||||
* hsl() literal anywhere else is palette leakage and fails here.
|
||||
*/
|
||||
|
||||
const SRC = join(__dirname, "..", "..");
|
||||
const ALLOWED = new Set([
|
||||
"design/tokens/tokens.css",
|
||||
"design/tokens/tokens.ts", // generated mirror of tokens.css
|
||||
// The scanner's own pattern definitions and the drift gate's PR-number
|
||||
// comments ("#712" parses as 3-digit hex) are not palette usage:
|
||||
"design/doctrine/hexScan.test.ts",
|
||||
"design/doctrine/schemaDrift.test.ts",
|
||||
]);
|
||||
|
||||
// Hex colors only: 3/4/6/8 hex digits after '#', not longer (so content
|
||||
// hashes like "4f80f7e12c7e" without '#' never match).
|
||||
const HEX_COLOR = /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{4}|[0-9a-fA-F]{3})\b/g;
|
||||
const FUNC_COLOR = /\b(?:rgb|rgba|hsl|hsla)\(/g;
|
||||
|
||||
function walk(dir: string, out: string[] = []): string[] {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) {
|
||||
walk(full, out);
|
||||
} else if (/\.(tsx?|css)$/.test(entry)) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("doctrine: no palette literals outside tokens", () => {
|
||||
it("src/** carries no hex or rgb()/hsl() color literals", () => {
|
||||
const violations: string[] = [];
|
||||
for (const file of walk(SRC)) {
|
||||
const rel = relative(SRC, file).replaceAll("\\", "/");
|
||||
if (ALLOWED.has(rel)) continue;
|
||||
const text = readFileSync(file, "utf-8");
|
||||
for (const match of text.matchAll(HEX_COLOR)) {
|
||||
violations.push(`${rel}: ${match[0]}`);
|
||||
}
|
||||
for (const match of text.matchAll(FUNC_COLOR)) {
|
||||
violations.push(`${rel}: ${match[0]}…)`);
|
||||
}
|
||||
}
|
||||
expect(violations, violations.join("\n")).toEqual([]);
|
||||
});
|
||||
});
|
||||
89
workbench-ui/src/design/doctrine/schemaDrift.test.ts
Normal file
89
workbench-ui/src/design/doctrine/schemaDrift.test.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
// @vitest-environment node
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* Doctrine-as-test (Wave R brief R1): the UI cannot silently diverge from
|
||||
* the engine's API schemas. `scripts/dump-schemas.py` AST-walks
|
||||
* workbench/schemas.py into schema-snapshot.json (committed, same pattern
|
||||
* as enum-snapshot.json); this test asserts every dataclass field appears
|
||||
* in the matching TS interface in src/types/api.ts.
|
||||
*
|
||||
* Python class -> TS interface mapping strips a trailing "Schema"
|
||||
* (TurnJournalEntrySchema -> TurnJournalEntry).
|
||||
*
|
||||
* NOT_YET_MIRRORED is the explicit debt list: backend schemas shipped ahead
|
||||
* of their routes (R2-B, PR #712). Each R2 route brief shrinks it. A class
|
||||
* that gains a TS interface while still listed here FAILS — the list can
|
||||
* only shrink.
|
||||
*/
|
||||
|
||||
const NOT_YET_MIRRORED = new Set([
|
||||
// Turn journal (R0 brief 1) — TS mirrors land with the R2 Trace route:
|
||||
"TurnJournalEntrySchema",
|
||||
"TurnJournalSummarySchema",
|
||||
// R2-B backend read substrate (#712) — TS mirrors land with each R2 route:
|
||||
"PackSummary",
|
||||
"PackDetail",
|
||||
"AuditEvent",
|
||||
"RunSummary",
|
||||
"RunTurnRef",
|
||||
"RunDetail",
|
||||
"VaultSummary",
|
||||
"VaultEntry",
|
||||
]);
|
||||
|
||||
const UI_ROOT = join(__dirname, "..", "..", "..");
|
||||
const snapshot: Record<string, string[]> = JSON.parse(
|
||||
readFileSync(join(UI_ROOT, "schema-snapshot.json"), "utf-8"),
|
||||
);
|
||||
const apiTs = readFileSync(join(UI_ROOT, "src", "types", "api.ts"), "utf-8");
|
||||
|
||||
function interfaceBlock(name: string): string | null {
|
||||
const start = apiTs.search(
|
||||
new RegExp(`export interface ${name}\\b[^{]*\\{`),
|
||||
);
|
||||
if (start === -1) return null;
|
||||
const open = apiTs.indexOf("{", start);
|
||||
let depth = 0;
|
||||
for (let i = open; i < apiTs.length; i++) {
|
||||
if (apiTs[i] === "{") depth++;
|
||||
else if (apiTs[i] === "}") {
|
||||
depth--;
|
||||
if (depth === 0) return apiTs.slice(open, i + 1);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("doctrine: UI types cover engine schemas", () => {
|
||||
const classes = Object.keys(snapshot).sort();
|
||||
|
||||
it("snapshot is non-trivial", () => {
|
||||
expect(classes.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
for (const pyName of classes) {
|
||||
const tsName = pyName.replace(/Schema$/, "");
|
||||
const block = interfaceBlock(tsName);
|
||||
|
||||
if (NOT_YET_MIRRORED.has(pyName)) {
|
||||
it(`${pyName}: still unmirrored (allowlisted debt)`, () => {
|
||||
expect(
|
||||
block,
|
||||
`${tsName} now exists in api.ts — remove ${pyName} from NOT_YET_MIRRORED`,
|
||||
).toBeNull();
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
it(`${pyName} -> ${tsName}: every field is mirrored`, () => {
|
||||
expect(block, `no TS interface for ${tsName}`).not.toBeNull();
|
||||
const missing = snapshot[pyName].filter(
|
||||
(field) => !new RegExp(`\\b${field}\\??:`).test(block!),
|
||||
);
|
||||
expect(missing, `fields missing from ${tsName}: ${missing.join(", ")}`).toEqual([]);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -29,6 +29,10 @@
|
|||
--color-text-mono: #d8e2ef;
|
||||
--color-text-muted: #788395;
|
||||
--color-focus-ring: #7dd3fc;
|
||||
/* Selection is a persistent state; focus is a transient position.
|
||||
Distinct tokens keep the two visually separable in lists. */
|
||||
--color-selected-bg: #18222f;
|
||||
--color-selected-border: #4d9fd6;
|
||||
--color-link: #8bd3ff;
|
||||
|
||||
--color-state-decoded: #4fc3b4;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ export const tokens = {
|
|||
"color-text-mono": "#d8e2ef",
|
||||
"color-text-muted": "#788395",
|
||||
"color-focus-ring": "#7dd3fc",
|
||||
"color-selected-bg": "#18222f",
|
||||
"color-selected-border": "#4d9fd6",
|
||||
"color-link": "#8bd3ff",
|
||||
"color-state-decoded": "#4fc3b4",
|
||||
"color-state-decoded-muted": "#3d8f86",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,14 @@
|
|||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
// Node-environment test files (doctrine/*) have no global navigator on
|
||||
// Node 20 (Node >=21 added one — which is why this only fails in CI).
|
||||
if (typeof globalThis.navigator === "undefined") {
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
configurable: true,
|
||||
value: {},
|
||||
});
|
||||
}
|
||||
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: {
|
||||
|
|
|
|||
|
|
@ -55,6 +55,8 @@ export interface ProposalRef {
|
|||
|
||||
export interface ChatTurnResult {
|
||||
prompt: string;
|
||||
/** Journal id stamped by the workbench API; null if journaling failed. */
|
||||
turn_id?: number | null;
|
||||
surface: string;
|
||||
articulation_surface: string | null;
|
||||
walk_surface: string | null;
|
||||
|
|
|
|||
Loading…
Reference in a new issue