Pivot to text-first column-stack workspace + merge-with-review across AI artifacts
Workspace - Pivot from "set of open panes" to a Finder-style miller column stack: TopBar / LeftSidebar / [section → entity → entity ...] / pinned text editor. openPanesStore is now an ordered Column[] with pushFrom / closeFrom / setStack; only one top-level section is rooted at a time. - New entity column panes: Term, Block, Association, Constraint, Requirement, Finding. Click-through navigation truncates deeper columns automatically. - LeftSidebar surfaces a pending-count chip per section (single-glance navigation cue) and spins its analyze ↻ via SVG Spinner whenever the LLM is working — including server-initiated runs caught by the runs poll, not just user-triggered ones. Analyze pipeline + persistence - Unified `concepts` pass (taxonomy + glossary in one LLM call) replaces the two-pass setup. Server still accepts ?section=taxonomy|glossary and normalizes them for back-compat. - model / requirements / detection (assumptions, risks, inconsistencies) + cross-layer validation rules (X1–X4: stale term link, unlinked formalism, undefined linked term, prose-only term). - Persistence: NarrativeDocument, ModelSnapshot, ChangelogEntry, TaxonomyTerm, RequirementEntry, Finding, AnalysisRun. Re-runs MERGE instead of replace: gentle update on existing items, suggested on new, deprecated on missing — same idiom for every artifact kind. User pins preserve "kept" decisions across re-analyses. - Migrations: pivot_text_first, add_requirement_linked_term, term_review_state, review_state_for_reqs_and_findings, add_term_definition_pinned. Concept ↔ ontology integration - linkedTermId on Block / Association / Constraint / Requirement. PromoteToolbar lets the user formalize a concept inline: + Block / + Association / + Constraint / + Requirement, all routed through applyOps so undo/redo and SSE work for free. - decideElement op for in-canvas keep/discard on review-pending model elements. User-authored definitions - TermColumn definition is click-to-edit. Save (Cmd-Enter / blur), Cancel (Esc), Reset to AI suggestion when pinned. - definitionPinned flag on TaxonomyTerm: future Analyze runs leave the user's text alone. setTermDefinition repo function + POST /api/projects/[id]/terms/[termId]/definition endpoint. - mergeTaxonomySuggestion + applyGlossaryDefinitions both pin-aware. UX/UI - StatusChip: single component for all state idioms (suggested, deprecated, accepted, dismissed, resolved, severity, validation code, confidence, warn). Replaces 5+ ad-hoc badge classes. - PaneControls (PaneViewTabs + PaneFilterChip): separates view-mode toggles from filter chips so toggling Pending no longer flips you off the current view. - PaneEmpty: unified empty-state with title + hint + action. - PaneDrawer: collapsible groups for Pending / Discarded review; cards group as Kept (top) → Pending (bottom drawer) → Discarded (Findings only, hidden when empty). Restore action recovers dismissed/resolved findings. - ConceptCard unifies Tree and A–Z views in Concepts; only Tree parents carry the chevron (no empty placeholder offset). - Type + spacing tokens (--text-xs..xl, --space-1..6, --lh-tight/ui/ prose, --radius-*) replace every ad-hoc value. - Buttons standardized to body sans 500 (was a mishmash of mono / display). - Card shells unified across Concepts / Requirements / Findings. Cleanup - Removed: LeftRail, FindingsPanel, IssuesPanel, SocratesDock, ProposalCard, SlashMenu, SlashExtension, slashSuggestion, CanvasHeader, TaxonomyPane, GlossaryPane, TermDetail (popover; now TermColumn). - Section ids in openPanesStore: dropped taxonomy/glossary, added concepts. localStorage migration runs on hydrate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
195
apps/web/components/socrates/ContextualSocratesThread.tsx
Normal file
195
apps/web/components/socrates/ContextualSocratesThread.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
// Inline mini-Socrates thread anchored to a single finding. Replaces the
|
||||
// global SocratesDock for the conversational surface.
|
||||
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Sigil } from "./Sigil";
|
||||
|
||||
interface ApiMessage {
|
||||
id: string;
|
||||
role: "user" | "assistant" | string;
|
||||
text: string;
|
||||
options?: Array<{ n: number; label: string; sub?: string }>;
|
||||
}
|
||||
|
||||
interface ContextualSocratesThreadProps {
|
||||
projectId: string;
|
||||
findingId: string;
|
||||
findingText: string;
|
||||
onResolved?: () => void;
|
||||
}
|
||||
|
||||
export function ContextualSocratesThread({ projectId, findingId, onResolved }: ContextualSocratesThreadProps) {
|
||||
const [messages, setMessages] = useState<ApiMessage[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const endRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const url = `/api/projects/${encodeURIComponent(projectId)}/findings/${encodeURIComponent(findingId)}/socrates`;
|
||||
|
||||
// Initial load + open the conversation if empty.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`load: ${res.status}`);
|
||||
const body = (await res.json()) as { messages?: ApiMessage[] };
|
||||
if (cancelled) return;
|
||||
const initial = body.messages ?? [];
|
||||
setMessages(initial);
|
||||
setLoaded(true);
|
||||
|
||||
if (initial.length === 0) {
|
||||
await sendImpl("");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[ContextualSocratesThread] load failed:", err);
|
||||
if (!cancelled) setLoaded(true);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [url]);
|
||||
|
||||
useEffect(() => {
|
||||
endRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||
}, [messages]);
|
||||
|
||||
const sendImpl = useCallback(
|
||||
async (text: string) => {
|
||||
setSending(true);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ text }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`send: ${res.status}`);
|
||||
const body = (await res.json()) as {
|
||||
assistant: { id: string; turn: { text: string; options?: Array<{ n: number; label: string; sub?: string }> } };
|
||||
user: { id: string };
|
||||
};
|
||||
setMessages(prev => {
|
||||
const next = [...prev];
|
||||
if (text.trim().length > 0) {
|
||||
next.push({ id: body.user.id, role: "user", text });
|
||||
}
|
||||
next.push({
|
||||
id: body.assistant.id,
|
||||
role: "assistant",
|
||||
text: body.assistant.turn.text,
|
||||
options: body.assistant.turn.options,
|
||||
});
|
||||
return next;
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[ContextualSocratesThread] send failed:", err);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
},
|
||||
[url]
|
||||
);
|
||||
|
||||
const onSend = useCallback(async () => {
|
||||
const text = input.trim();
|
||||
if (!text || sending) return;
|
||||
setInput("");
|
||||
await sendImpl(text);
|
||||
}, [input, sending, sendImpl]);
|
||||
|
||||
const onResolve = useCallback(async () => {
|
||||
try {
|
||||
await fetch(url, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: "resolved" }),
|
||||
});
|
||||
onResolved?.();
|
||||
} catch (err) {
|
||||
console.error("[ContextualSocratesThread] resolve failed:", err);
|
||||
}
|
||||
}, [url, onResolved]);
|
||||
|
||||
const onDismiss = useCallback(async () => {
|
||||
try {
|
||||
await fetch(url, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: "dismissed" }),
|
||||
});
|
||||
onResolved?.();
|
||||
} catch (err) {
|
||||
console.error("[ContextualSocratesThread] dismiss failed:", err);
|
||||
}
|
||||
}, [url, onResolved]);
|
||||
|
||||
return (
|
||||
<div className="ctx-thread">
|
||||
<div className="ctx-thread-messages">
|
||||
{!loaded ? (
|
||||
<div className="ctx-thread-loading">…</div>
|
||||
) : (
|
||||
messages.map(m => (
|
||||
<div key={m.id} className={`ctx-bubble ctx-bubble-${m.role}`}>
|
||||
{m.role === "assistant" ? <Sigil size={18} /> : null}
|
||||
<div className="ctx-bubble-body">
|
||||
<div className="ctx-bubble-text">{m.text}</div>
|
||||
{m.options && m.options.length > 0 ? (
|
||||
<div className="ctx-bubble-options">
|
||||
{m.options.map(o => (
|
||||
<button
|
||||
key={o.n}
|
||||
type="button"
|
||||
className="ctx-option"
|
||||
onClick={() => setInput(prev => (prev ? `${prev} ${o.label}` : o.label))}
|
||||
>
|
||||
<span className="ctx-option-n">{o.n}.</span>
|
||||
<span className="ctx-option-label">{o.label}</span>
|
||||
{o.sub ? <span className="ctx-option-sub">{o.sub}</span> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
<div className="ctx-thread-input-row">
|
||||
<textarea
|
||||
className="ctx-thread-input"
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void onSend();
|
||||
}
|
||||
}}
|
||||
placeholder="Reply to Socrates…"
|
||||
rows={2}
|
||||
/>
|
||||
<div className="ctx-thread-actions">
|
||||
<button type="button" className="ctx-send" onClick={onSend} disabled={sending || !input.trim()}>
|
||||
⌘↵
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ctx-thread-resolve">
|
||||
<button type="button" className="ctx-resolve-btn" onClick={onResolve}>
|
||||
Mark resolved
|
||||
</button>
|
||||
<button type="button" className="ctx-dismiss-btn" onClick={onDismiss}>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
// In-dock card showing a Socrates-proposed change.
|
||||
//
|
||||
// Displays: reasoning, op summary, impact summary (added/removed/changed +
|
||||
// validation diff), and Accept / Reject controls. Accept calls into the
|
||||
// ModelStore which routes through the same applyOps + persistence pipeline
|
||||
// as user-originated edits.
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { ImpactSummary } from "../../lib/sysml/impact";
|
||||
import type { ModelOp } from "../../lib/sync/ops";
|
||||
|
||||
export interface ProposalPayload {
|
||||
reasoning: string;
|
||||
ops: ModelOp[];
|
||||
impactSummary: ImpactSummary;
|
||||
meta?: { provider?: string; model?: string };
|
||||
}
|
||||
|
||||
interface ProposalCardProps {
|
||||
proposal: ProposalPayload;
|
||||
onAccept: () => Promise<void> | void;
|
||||
onReject: () => void;
|
||||
pending?: boolean;
|
||||
}
|
||||
|
||||
export function ProposalCard({ proposal, onAccept, onReject, pending }: ProposalCardProps) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const { reasoning, ops, impactSummary } = proposal;
|
||||
|
||||
async function accept() {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await onAccept();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`proposal-card ${pending ? "proposal-card-pending" : ""}`}>
|
||||
<div className="proposal-card-head">
|
||||
<span className="proposal-card-tag">PROPOSAL</span>
|
||||
<span className="proposal-card-stats">
|
||||
{summarizeStats(impactSummary)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="proposal-card-reasoning">{reasoning}</div>
|
||||
|
||||
{ops.length > 0 && (
|
||||
<details className="proposal-card-ops" open>
|
||||
<summary>Ops · {ops.length}</summary>
|
||||
<ul>
|
||||
{ops.map((op, i) => (
|
||||
<li key={i}>{summarizeOp(op)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{(impactSummary.added.length > 0 || impactSummary.removed.length > 0 || impactSummary.changed.length > 0) && (
|
||||
<div className="proposal-card-section">
|
||||
<div className="proposal-card-section-label">Impact</div>
|
||||
<div className="proposal-card-impact-grid">
|
||||
{impactSummary.added.length > 0 && (
|
||||
<div className="proposal-card-impact-row">
|
||||
<span className="proposal-card-impact-key">+ added</span>
|
||||
<span className="proposal-card-impact-val">
|
||||
{impactSummary.added.map(e => `${e.label} (${e.kind})`).join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{impactSummary.removed.length > 0 && (
|
||||
<div className="proposal-card-impact-row">
|
||||
<span className="proposal-card-impact-key">− removed</span>
|
||||
<span className="proposal-card-impact-val">
|
||||
{impactSummary.removed.map(e => `${e.label} (${e.kind})`).join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{impactSummary.changed.length > 0 && (
|
||||
<div className="proposal-card-impact-row">
|
||||
<span className="proposal-card-impact-key">~ changed</span>
|
||||
<span className="proposal-card-impact-val">
|
||||
{impactSummary.changed.map(e => `${e.label} (${e.kind})`).join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(impactSummary.issuesCreated.length > 0 || impactSummary.issuesResolved.length > 0) && (
|
||||
<div className="proposal-card-section">
|
||||
<div className="proposal-card-section-label">Validation</div>
|
||||
{impactSummary.issuesResolved.length > 0 && (
|
||||
<ul className="proposal-card-issue-list proposal-card-issues-resolved">
|
||||
{impactSummary.issuesResolved.slice(0, 4).map((i, idx) => (
|
||||
<li key={idx}>✓ resolves {i.code}: {i.message}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{impactSummary.issuesCreated.length > 0 && (
|
||||
<ul className="proposal-card-issue-list proposal-card-issues-created">
|
||||
{impactSummary.issuesCreated.slice(0, 4).map((i, idx) => (
|
||||
<li key={idx}>⚠ creates {i.code}: {i.message}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!impactSummary.ok && (
|
||||
<div className="proposal-card-section proposal-card-error">
|
||||
<div className="proposal-card-section-label">Cannot apply</div>
|
||||
<ul className="proposal-card-issue-list">
|
||||
{impactSummary.errors.map((e, i) => (
|
||||
<li key={i}>{e.code}: {e.message}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="proposal-card-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="proposal-card-btn proposal-card-btn-primary"
|
||||
onClick={accept}
|
||||
disabled={busy || pending || !impactSummary.ok || ops.length === 0}
|
||||
>
|
||||
{busy ? "Applying…" : "Accept"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="proposal-card-btn"
|
||||
onClick={onReject}
|
||||
disabled={busy || pending}
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{proposal.meta?.model && (
|
||||
<div className="proposal-card-meta">
|
||||
via {proposal.meta.provider} · {proposal.meta.model}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function summarizeStats(impact: ImpactSummary): string {
|
||||
if (!impact.ok) return "would not apply";
|
||||
const parts: string[] = [];
|
||||
const { stats } = impact;
|
||||
if (stats.blocksDelta) parts.push(`${signed(stats.blocksDelta)} block${Math.abs(stats.blocksDelta) === 1 ? "" : "s"}`);
|
||||
if (stats.associationsDelta) parts.push(`${signed(stats.associationsDelta)} assoc`);
|
||||
if (stats.constraintsDelta) parts.push(`${signed(stats.constraintsDelta)} constraint${Math.abs(stats.constraintsDelta) === 1 ? "" : "s"}`);
|
||||
if (stats.requirementsDelta) parts.push(`${signed(stats.requirementsDelta)} req${Math.abs(stats.requirementsDelta) === 1 ? "" : "s"}`);
|
||||
if (stats.issuesDelta) parts.push(`${signed(stats.issuesDelta)} issue${Math.abs(stats.issuesDelta) === 1 ? "" : "s"}`);
|
||||
if (parts.length === 0) parts.push("structural rearrangement");
|
||||
return parts.join(" · ");
|
||||
}
|
||||
function signed(n: number): string {
|
||||
return n > 0 ? `+${n}` : `${n}`;
|
||||
}
|
||||
|
||||
function summarizeOp(op: ModelOp): string {
|
||||
switch (op.kind) {
|
||||
case "add-block":
|
||||
return `+ block "${op.block.label}" (${op.block.kind})`;
|
||||
case "remove-block":
|
||||
return `− block ${op.blockId}`;
|
||||
case "update-block":
|
||||
return `~ block ${op.blockId}`;
|
||||
case "add-association":
|
||||
return `+ assoc ${op.association.fromBlockId}→${op.association.toBlockId} (${op.association.kind})`;
|
||||
case "remove-association":
|
||||
return `− assoc ${op.associationId}`;
|
||||
case "update-association":
|
||||
return `~ assoc ${op.associationId}`;
|
||||
case "add-constraint":
|
||||
return `+ constraint "${op.constraint.label}"`;
|
||||
case "remove-constraint":
|
||||
return `− constraint ${op.constraintId}`;
|
||||
case "update-constraint":
|
||||
return `~ constraint ${op.constraintId}`;
|
||||
case "add-requirement":
|
||||
return `+ req ${op.requirement.tag}`;
|
||||
case "remove-requirement":
|
||||
return `− req ${op.requirementId}`;
|
||||
case "update-requirement":
|
||||
return `~ req ${op.requirementId}`;
|
||||
case "add-property":
|
||||
return `+ property ${op.blockId}.${op.property.name}`;
|
||||
case "update-property":
|
||||
return `~ property ${op.blockId}.${op.propertyId}`;
|
||||
case "remove-property":
|
||||
return `− property ${op.blockId}.${op.propertyId}`;
|
||||
case "add-relation":
|
||||
return `+ relation ${op.requirementId}.${op.relation.kind}`;
|
||||
case "remove-relation":
|
||||
return `− relation ${op.requirementId}[${op.relationIndex}]`;
|
||||
}
|
||||
}
|
||||
@@ -1,389 +0,0 @@
|
||||
// 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;
|
||||
}
|
||||
Reference in New Issue
Block a user