The dock now has a "propose" button that asks Socrates to suggest one
high-value structural change. He returns a structured payload (reasoning
+ ops + impactSummary) which renders as a ProposalCard inline in the
thread. Accept routes the ops through the same useApply() pipeline as
user-originated edits (optimistic local + persisted POST + server
reconciliation).
apps/web/lib/llm/prompts/socrates/propose.md
- Promoted verbatim from phase-0/src/prompts/.
apps/web/lib/llm/proposeChange.ts
- Server-side proposeChange() — calls the LLM with character + propose
prompts + trimmed model + active issues. JSON Schema uses oneOf per
op kind (Phase 0 lesson: small models need this to produce real op
shapes instead of cramming everything into the kind name).
- normalizeOps() converts the LLM-emitted op shapes into canonical
ModelOp[] (fills in property ids/multiplicity defaults, expands
satisfiedBy[] into RequirementRelation[], splits PropertyType union
per kind).
apps/web/lib/sysml/impact.ts
- Pure pre-apply impact analysis. Runs the ops through the same
applyOps() reducer locally, then diffs:
- structural: added / removed / changed elements (per kind)
- validation: issues created vs resolved (by canonical issue key)
- dep-graph blast radius (closure of touched element ids on the
post-apply graph)
Headline stats summarized as deltas (+1 block, −1 assoc, etc.) for
the proposal card.
apps/web/app/api/projects/[projectId]/socrates/propose
- POST: returns { reasoning, ops, impactSummary, meta }. Pure read of
the model — does not apply anything; client must POST /apply with the
same ops to commit.
apps/web/components/socrates/ProposalCard.tsx
- In-dock card: PROPOSAL tag + delta stats / reasoning / collapsible
ops list / Impact section (added/removed/changed) / Validation diff
(resolves ✓ / creates ⚠) / Accept + Reject. Disabled when impact
analysis flagged the apply as illegal.
apps/web/components/socrates/SocratesDock.tsx
- New "propose" button between the input field and send. Renders
ProposalCard for assistant turns of role "proposal". Accept calls
useApply(); on success the card collapses to a "✓ Applied" system
bubble. On apply failure the dock shows the structured error
messages.
- New "system" bubble role for apply confirmations + dismissals.
apps/web/components/diagram-canvas/DiagramCanvas.tsx
- Bug fix: new blocks/constraints arriving via the model→RF sync (e.g.
from accepted proposals) are now positioned to the right of the
existing layout instead of stacking at (0, 0) offscreen.
apps/web/lib/sync/ModelStore.tsx
- Bug fix / observability: background-POST failures and version
mismatches now log to the console with structured context instead of
being silently swallowed. The server's authoritative state still
replaces the optimistic local state on response, but the user can now
see why their accept appeared to do nothing (typically: page tab
was at version N but server had advanced to N+1).
390 lines
14 KiB
TypeScript
390 lines
14 KiB
TypeScript
// Active-thread dock with Sigil header, conversation bubbles, numbered options.
|
||
//
|
||
// M6: live thread loaded from /api/projects/[id]/socrates. The reply input
|
||
// POSTs each user turn and renders Socrates' response when it arrives.
|
||
// Numbered options pre-fill the input when clicked / pressed (1–3).
|
||
|
||
"use client";
|
||
|
||
import { useCallback, useEffect, useRef, useState } from "react";
|
||
import { Sigil } from "./Sigil";
|
||
import { ProposalCard, type ProposalPayload } from "./ProposalCard";
|
||
import { useApply } from "../../lib/sync/ModelStore";
|
||
|
||
export type SocratesPresence = "subtle" | "default" | "prominent";
|
||
export type Density = "comfortable" | "compact";
|
||
|
||
export interface DockMessage {
|
||
id: string;
|
||
role: "user" | "assistant" | "proposal" | "system";
|
||
text: string;
|
||
options?: Array<{ n: number; label: string; sub?: string }>;
|
||
pending?: boolean;
|
||
proposal?: ProposalPayload;
|
||
}
|
||
|
||
interface SocratesDockProps {
|
||
projectId: string;
|
||
presence: SocratesPresence;
|
||
density: Density;
|
||
}
|
||
|
||
interface ApiMessage {
|
||
id: string;
|
||
role: "user" | "assistant";
|
||
text: string;
|
||
options?: Array<{ n: number; label: string; sub?: string }>;
|
||
createdAt: string;
|
||
}
|
||
|
||
export function SocratesDock({ projectId, presence }: SocratesDockProps) {
|
||
const [messages, setMessages] = useState<DockMessage[]>([]);
|
||
const [input, setInput] = useState("");
|
||
const [sending, setSending] = useState(false);
|
||
const [proposing, setProposing] = useState(false);
|
||
const [loaded, setLoaded] = useState(false);
|
||
const [meta, setMeta] = useState<{ provider?: string; model?: string }>({});
|
||
const threadEndRef = useRef<HTMLDivElement | null>(null);
|
||
const apply = useApply();
|
||
|
||
// Load thread on mount and trigger an opening turn if the thread is empty.
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
(async () => {
|
||
try {
|
||
const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}/socrates`);
|
||
if (!res.ok) throw new Error(`load thread: ${res.status}`);
|
||
const data = (await res.json()) as { messages?: ApiMessage[] };
|
||
if (cancelled) return;
|
||
const initial: DockMessage[] = (data.messages ?? []).map(m => ({
|
||
id: m.id,
|
||
role: m.role,
|
||
text: m.text,
|
||
options: m.options,
|
||
}));
|
||
setMessages(initial);
|
||
setLoaded(true);
|
||
|
||
if (initial.length === 0 && !cancelled) {
|
||
// Trigger Socrates' opening turn
|
||
await sendImpl("", true);
|
||
}
|
||
} catch {
|
||
if (!cancelled) setLoaded(true);
|
||
}
|
||
})();
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [projectId]);
|
||
|
||
// Auto-scroll on new turns
|
||
useEffect(() => {
|
||
threadEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
||
}, [messages]);
|
||
|
||
const sendImpl = useCallback(
|
||
async (text: string, isOpening = false) => {
|
||
setSending(true);
|
||
try {
|
||
// Add the user bubble locally (optimistic) — unless this is the opening
|
||
if (!isOpening) {
|
||
setMessages(curr => [
|
||
...curr,
|
||
{ id: `tmp-u-${Date.now()}`, role: "user", text },
|
||
]);
|
||
}
|
||
// Show a pending Socrates bubble
|
||
const pendingId = `tmp-a-${Date.now()}`;
|
||
setMessages(curr => [
|
||
...curr,
|
||
{ id: pendingId, role: "assistant", text: "thinking…", pending: true },
|
||
]);
|
||
|
||
const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}/socrates`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ text }),
|
||
});
|
||
if (!res.ok) {
|
||
const err = await res.json().catch(() => ({}));
|
||
throw new Error(err.error ?? `${res.status}`);
|
||
}
|
||
const data = await res.json();
|
||
const a = data.assistant as { id: string; text: string; options?: Array<{ n: number; label: string; sub?: string }> };
|
||
if (data.meta) setMeta({ provider: data.meta.provider, model: data.meta.model });
|
||
|
||
// Replace the pending bubble with the real one
|
||
setMessages(curr => curr.map(m =>
|
||
m.id === pendingId
|
||
? { id: a.id, role: "assistant", text: a.text, options: a.options }
|
||
: m
|
||
));
|
||
} catch (err) {
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
setMessages(curr => curr.map(m =>
|
||
m.pending ? { ...m, text: `⚠ ${msg}`, pending: false } : m
|
||
));
|
||
} finally {
|
||
setSending(false);
|
||
}
|
||
},
|
||
[projectId]
|
||
);
|
||
|
||
const onSubmit = useCallback(async () => {
|
||
const text = input.trim();
|
||
if (!text || sending) return;
|
||
setInput("");
|
||
await sendImpl(text);
|
||
}, [input, sending, sendImpl]);
|
||
|
||
const pickOption = useCallback(async (option: { n: number; label: string; sub?: string }) => {
|
||
if (sending) return;
|
||
const text = `[${option.n}] ${option.label}${option.sub ? ` — ${option.sub}` : ""}`;
|
||
await sendImpl(text);
|
||
}, [sending, sendImpl]);
|
||
|
||
// Ask Socrates to propose a model change.
|
||
const requestProposal = useCallback(async () => {
|
||
if (proposing || sending) return;
|
||
setProposing(true);
|
||
const pendingId = `tmp-p-${Date.now()}`;
|
||
setMessages(curr => [
|
||
...curr,
|
||
{ id: pendingId, role: "proposal", text: "Drafting a proposal…", pending: true },
|
||
]);
|
||
try {
|
||
const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}/socrates/propose`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({}),
|
||
});
|
||
if (!res.ok) {
|
||
const err = await res.json().catch(() => ({}));
|
||
throw new Error(err.error ?? `${res.status}`);
|
||
}
|
||
const data = (await res.json()) as ProposalPayload;
|
||
setMessages(curr => curr.map(m =>
|
||
m.id === pendingId
|
||
? { id: pendingId, role: "proposal", text: data.reasoning, proposal: data }
|
||
: m
|
||
));
|
||
} catch (err) {
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
setMessages(curr => curr.map(m =>
|
||
m.id === pendingId
|
||
? { ...m, text: `⚠ propose failed: ${msg}`, pending: false }
|
||
: m
|
||
));
|
||
} finally {
|
||
setProposing(false);
|
||
}
|
||
}, [proposing, sending, projectId]);
|
||
|
||
const acceptProposal = useCallback(async (messageId: string, proposal: ProposalPayload) => {
|
||
const result = apply(proposal.ops);
|
||
if (!result.applied) {
|
||
setMessages(curr => [
|
||
...curr,
|
||
{
|
||
id: `tmp-sys-${Date.now()}`,
|
||
role: "system",
|
||
text: `⚠ Couldn't apply: ${result.errors.map(e => `${e.code}: ${e.message}`).join("; ")}`,
|
||
},
|
||
]);
|
||
return;
|
||
}
|
||
// Mark the proposal accepted (drop the live card; keep a summary line)
|
||
setMessages(curr => curr.map(m =>
|
||
m.id === messageId
|
||
? {
|
||
id: messageId,
|
||
role: "system",
|
||
text: `✓ Applied · ${proposal.ops.length} op${proposal.ops.length === 1 ? "" : "s"} · ${truncate(proposal.reasoning, 90)}`,
|
||
}
|
||
: m
|
||
));
|
||
}, [apply]);
|
||
|
||
const rejectProposal = useCallback((messageId: string) => {
|
||
setMessages(curr => curr.map(m =>
|
||
m.id === messageId
|
||
? { id: messageId, role: "system", text: "Proposal dismissed." }
|
||
: m
|
||
));
|
||
}, []);
|
||
|
||
// Number-key shortcuts on the most recent assistant turn with options
|
||
useEffect(() => {
|
||
const onKey = (e: KeyboardEvent) => {
|
||
// Don't hijack number keys when typing in any input/contenteditable
|
||
const target = e.target as HTMLElement | null;
|
||
if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)) return;
|
||
if (sending) return;
|
||
const last = [...messages].reverse().find(m => m.role === "assistant" && m.options?.length);
|
||
if (!last?.options) return;
|
||
const n = parseInt(e.key, 10);
|
||
if (Number.isNaN(n) || n < 1 || n > last.options.length) return;
|
||
e.preventDefault();
|
||
const opt = last.options.find(o => o.n === n);
|
||
if (opt) void pickOption(opt);
|
||
};
|
||
window.addEventListener("keydown", onKey);
|
||
return () => window.removeEventListener("keydown", onKey);
|
||
}, [messages, pickOption, sending]);
|
||
|
||
if (presence === "subtle") {
|
||
return (
|
||
<div className="dock dock-subtle" title={meta.model ? `Σ via ${meta.model}` : "Σ Socrates"}>
|
||
<Sigil size={36} />
|
||
{messages.filter(m => m.role === "assistant").length > 0 && (
|
||
<div className="dock-subtle-count">{messages.filter(m => m.role === "assistant").length}</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<aside className={`dock ${presence === "prominent" ? "dock-prominent" : "dock-default"}`}>
|
||
<header className="dock-header">
|
||
<Sigil size={28} />
|
||
<div className="dock-header-text">
|
||
<div className="dock-name">Socrates</div>
|
||
<div className="dock-status">
|
||
<span className="dock-dot" />
|
||
{meta.provider ? `${meta.provider}${meta.model ? ` · ${meta.model.split("/").pop()}` : ""}` : "ready"}
|
||
</div>
|
||
</div>
|
||
<button className="dock-header-action" title="New thread (coming soon)" type="button" disabled>
|
||
+
|
||
</button>
|
||
</header>
|
||
|
||
<section className="dock-thread-wrap">
|
||
<div className="dock-section-label dock-section-label-inline">Active thread</div>
|
||
<div className="dock-thread">
|
||
{!loaded && (
|
||
<div className="bubble bubble-assistant">
|
||
<span className="bubble-sigil">Σ</span>
|
||
<span className="bubble-body">
|
||
<span className="bubble-text" style={{ opacity: 0.6 }}>loading…</span>
|
||
</span>
|
||
</div>
|
||
)}
|
||
{messages.map(m => {
|
||
// Proposal card — render in place of a normal bubble
|
||
if (m.role === "proposal") {
|
||
if (m.proposal) {
|
||
return (
|
||
<ProposalCard
|
||
key={m.id}
|
||
proposal={m.proposal}
|
||
onAccept={() => acceptProposal(m.id, m.proposal!)}
|
||
onReject={() => rejectProposal(m.id)}
|
||
/>
|
||
);
|
||
}
|
||
// Pending or errored proposal — show a thin status line
|
||
return (
|
||
<div key={m.id} className="bubble bubble-socrates">
|
||
<span className="bubble-sigil">Σ</span>
|
||
<span className="bubble-body">
|
||
<span className="bubble-text" style={m.pending ? { opacity: 0.55, fontStyle: "italic" } : undefined}>
|
||
{m.text}
|
||
</span>
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
// System notes (apply confirmation, errors)
|
||
if (m.role === "system") {
|
||
return (
|
||
<div key={m.id} className="bubble bubble-system">
|
||
<span className="bubble-text">{m.text}</span>
|
||
</div>
|
||
);
|
||
}
|
||
// Normal user / assistant bubble
|
||
return (
|
||
<div key={m.id} className={`bubble bubble-${m.role === "assistant" ? "socrates" : "user"}`}>
|
||
{m.role === "assistant" && <span className="bubble-sigil">Σ</span>}
|
||
<span className="bubble-body">
|
||
<span className="bubble-text" style={m.pending ? { opacity: 0.55, fontStyle: "italic" } : undefined}>
|
||
{m.text}
|
||
</span>
|
||
{m.options && m.options.length > 0 && (
|
||
<div className="bubble-options">
|
||
{m.options.map(o => (
|
||
<button
|
||
key={o.n}
|
||
className="bubble-option"
|
||
type="button"
|
||
onClick={() => pickOption(o)}
|
||
disabled={sending}
|
||
>
|
||
<span className="bubble-option-num">{o.n}</span>
|
||
<span className="bubble-option-text">
|
||
<span className="bubble-option-label">{o.label}</span>
|
||
{o.sub && <span className="bubble-option-sub">{o.sub}</span>}
|
||
</span>
|
||
<span className="bubble-option-key">{o.n}</span>
|
||
</button>
|
||
))}
|
||
<div className="bubble-options-hint">
|
||
Press <kbd>1</kbd>–<kbd>{m.options.length}</kbd>, or type a reply
|
||
</div>
|
||
</div>
|
||
)}
|
||
</span>
|
||
</div>
|
||
);
|
||
})}
|
||
<div ref={threadEndRef} />
|
||
</div>
|
||
</section>
|
||
|
||
<footer className="dock-input-wrap">
|
||
<form
|
||
className="dock-input"
|
||
onSubmit={e => {
|
||
e.preventDefault();
|
||
void onSubmit();
|
||
}}
|
||
>
|
||
<span className="dock-input-prompt">›</span>
|
||
<input
|
||
className="dock-input-field"
|
||
placeholder={sending ? "Socrates is thinking…" : "Reply to Socrates…"}
|
||
value={input}
|
||
onChange={e => setInput(e.target.value)}
|
||
disabled={sending}
|
||
/>
|
||
<button
|
||
type="button"
|
||
className="dock-input-propose"
|
||
onClick={() => void requestProposal()}
|
||
disabled={proposing || sending}
|
||
title="Ask Socrates to propose a model change"
|
||
>
|
||
propose
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
className="dock-input-send"
|
||
disabled={sending || !input.trim()}
|
||
title="Send (⌘↵)"
|
||
>
|
||
⌘↵
|
||
</button>
|
||
</form>
|
||
</footer>
|
||
</aside>
|
||
);
|
||
}
|
||
|
||
function truncate(s: string, n: number): string {
|
||
return s.length > n ? s.slice(0, n - 1) + "…" : s;
|
||
}
|