From 605a03f691397486bfa0fbe179523b39fb01bfee Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 21 Sep 2026 16:52:01 +0000 Subject: [PATCH 01/10] feat(workspace): connect evidence and practice --- services/collaboration/src/postgres-store.ts | 43 +- .../features/sparring/SparringSetup.tsx | 7 +- .../features/workspace/WorkspaceEditor.tsx | 55 ++- .../workspace/WorkspacePracticePanel.tsx | 367 +++++++++++++++++ .../features/workspace/WorkspaceToolScope.tsx | 31 ++ src/lib/ai/client.ts | 37 +- src/lib/stores/sparring-store.ts | 25 +- src/lib/workspace/practice-client.ts | 135 +++++++ src/lib/workspace/tool-context.ts | 47 +++ src/locales/en.json | 26 ++ src/locales/es.json | 26 ++ src/locales/hu.json | 26 ++ src/pages/AskPage.tsx | 7 +- src/pages/AttackPage.tsx | 6 +- src/pages/BrainstormPage.tsx | 6 +- src/pages/CrossfirePage.tsx | 53 ++- src/pages/FactoidsPage.tsx | 6 +- src/pages/FlowchartPage.tsx | 4 + src/pages/GeneratePage.tsx | 6 +- src/pages/SparringPage.tsx | 50 ++- src/pages/WorkspacePage.tsx | 29 ++ src/test/workspace/tool-context.test.ts | 43 ++ .../_shared/workspace-context.test.ts | 78 ++++ .../functions/_shared/workspace-context.ts | 125 ++++++ supabase/functions/ai/index.ts | 43 +- ...0921161000_workspace_evidence_practice.sql | 377 ++++++++++++++++++ supabase/tests/workspace_practice_test.sql | 117 ++++++ 27 files changed, 1712 insertions(+), 63 deletions(-) create mode 100644 src/components/features/workspace/WorkspacePracticePanel.tsx create mode 100644 src/components/features/workspace/WorkspaceToolScope.tsx create mode 100644 src/lib/workspace/practice-client.ts create mode 100644 src/lib/workspace/tool-context.ts create mode 100644 src/test/workspace/tool-context.test.ts create mode 100644 supabase/functions/_shared/workspace-context.test.ts create mode 100644 supabase/functions/_shared/workspace-context.ts create mode 100644 supabase/migrations/20260921161000_workspace_evidence_practice.sql create mode 100644 supabase/tests/workspace_practice_test.sql diff --git a/services/collaboration/src/postgres-store.ts b/services/collaboration/src/postgres-store.ts index 2ba4d6dfc..8e7ecec43 100644 --- a/services/collaboration/src/postgres-store.ts +++ b/services/collaboration/src/postgres-store.ts @@ -1,13 +1,14 @@ import { createHash } from "node:crypto"; import { z } from "zod"; import * as Y from "yjs"; +import { yXmlFragmentToProsemirrorJSON } from "@tiptap/y-tiptap"; import { acceptProposalHunkInDocument, deserializeProposal, rejectProposalHunks, serializeProposal, } from "../../../src/lib/workspace/review"; -import { validateWorkspaceDocument } from "../../../src/lib/workspace/schema"; +import { validateWorkspaceDocument, WORKSPACE_CONTENT_FIELD, workspaceSchema } from "../../../src/lib/workspace/schema"; import type { DurableAppend, DurableUpdate, DurableUpdateStore } from "./server"; export interface PgClient { @@ -42,7 +43,7 @@ const durableRowSchema = z.object({ sync_type: z.number(), }); const storedUpdateRowSchema = durableRowSchema.extend({ mutation_id: z.string() }); -const snapshotRowSchema = z.object({ snapshot: z.instanceof(Uint8Array) }); +const snapshotRowSchema = z.object({ snapshot: z.instanceof(Uint8Array), text_projection: z.string().optional() }); const authorizationRowSchema = z.object({ allowed: z.boolean() }); const proposalRowSchema = z.object({ durable_sequence: bigintSchema, @@ -52,6 +53,18 @@ const proposalRowSchema = z.object({ workspace_id: z.string().uuid(), }); +function snapshotText(snapshot: Uint8Array) { + const document = new Y.Doc(); + try { + if (snapshot.byteLength) Y.applyUpdate(document, snapshot); + return workspaceSchema.nodeFromJSON( + yXmlFragmentToProsemirrorJSON(document.getXmlFragment(WORKSPACE_CONTENT_FIELD)), + ).textContent.slice(0, 100_000); + } finally { + document.destroy(); + } +} + export class PostgresDurableUpdateStore implements DurableUpdateStore { private readonly writers = new Map(); @@ -84,14 +97,26 @@ export class PostgresDurableUpdateStore implements DurableUpdateStore { } async loadSnapshot(documentId: string, epoch: number) { - const result = await this.getWriter(documentId, epoch).query( - `SELECT snapshot + const client = this.getWriter(documentId, epoch); + const result = await client.query( + `SELECT snapshot, text_projection FROM private.workspace_document_state WHERE document_id = $1 AND epoch = $2`, [documentId, epoch], ); const input = result.rows[0]; - return input ? new Uint8Array(snapshotRowSchema.parse(input).snapshot) : undefined; + if (!input) return undefined; + const row = snapshotRowSchema.parse(input); + const snapshot = new Uint8Array(row.snapshot); + if (!row.text_projection) { + await client.query( + `UPDATE private.workspace_document_state + SET text_projection = $3 + WHERE document_id = $1 AND epoch = $2 AND text_projection = ''`, + [documentId, epoch, snapshotText(snapshot)], + ); + } + return snapshot; } async load(documentId: string, epoch: number): Promise { @@ -204,9 +229,9 @@ export class PostgresDurableUpdateStore implements DurableUpdateStore { ); await client.query( `UPDATE private.workspace_document_state - SET durable_sequence = $2, snapshot = $3, updated_at = now() + SET durable_sequence = $2, snapshot = $3, text_projection = $4, updated_at = now() WHERE document_id = $1`, - [update.documentId, sequence, Buffer.from(update.snapshot)], + [update.documentId, sequence, Buffer.from(update.snapshot), snapshotText(update.snapshot)], ); await client.query( `UPDATE public.workspace_documents @@ -374,9 +399,9 @@ export class PostgresDurableUpdateStore implements DurableUpdateStore { ); await client.query( `UPDATE private.workspace_document_state - SET durable_sequence = $2, snapshot = $3, updated_at = now() + SET durable_sequence = $2, snapshot = $3, text_projection = $4, updated_at = now() WHERE document_id = $1`, - [input.documentId, sequence, Buffer.from(snapshot)], + [input.documentId, sequence, Buffer.from(snapshot), snapshotText(snapshot)], ); await client.query( `UPDATE public.workspace_documents diff --git a/src/components/features/sparring/SparringSetup.tsx b/src/components/features/sparring/SparringSetup.tsx index e889083da..628cc9ebf 100644 --- a/src/components/features/sparring/SparringSetup.tsx +++ b/src/components/features/sparring/SparringSetup.tsx @@ -26,6 +26,7 @@ export interface SparringSetupValues { interface SparringSetupProps { onStart: (values: SparringSetupValues) => void; isStarting?: boolean; + initialValues?: Partial; } const DIFFICULTIES: Array<{ id: SparringDifficulty; name: string }> = [ @@ -34,10 +35,10 @@ const DIFFICULTIES: Array<{ id: SparringDifficulty; name: string }> = [ { id: 'hard', name: 'Ruthless' }, ]; -export function SparringSetup({ onStart, isStarting = false }: SparringSetupProps) { +export function SparringSetup({ onStart, isStarting = false, initialValues }: SparringSetupProps) { const { t } = useTranslation(); - const [motion, setMotion] = useState(''); - const [userSide, setUserSide] = useState('pro'); + const [motion, setMotion] = useState(initialValues?.motion ?? ''); + const [userSide, setUserSide] = useState(initialValues?.userSide ?? 'pro'); const [format, setFormat] = useState('standard'); const [difficulty, setDifficulty] = useState('medium'); const [isOnline, setIsOnline] = useState(() => typeof navigator === 'undefined' || navigator.onLine); diff --git a/src/components/features/workspace/WorkspaceEditor.tsx b/src/components/features/workspace/WorkspaceEditor.tsx index 353bd3e29..cea48f2e7 100644 --- a/src/components/features/workspace/WorkspaceEditor.tsx +++ b/src/components/features/workspace/WorkspaceEditor.tsx @@ -20,6 +20,7 @@ import { createWorkspaceEditor } from "@/lib/workspace/editor-schema"; import { createProposal, createQuoteAnchor, type ProposalHunkInput } from "@/lib/workspace/review"; import { createWorkspaceComment, saveWorkspaceProposal } from "@/lib/workspace/client"; import { useOnlineStatus } from "@/hooks/use-online-status"; +import type { WorkspaceSource } from "@/lib/workspace/practice-client"; const acknowledgementSchema = z.object({ epoch: z.number().int().nonnegative(), @@ -32,6 +33,8 @@ export type SaveStatus = "Offline" | "Saved" | "Saving"; export function WorkspaceEditor({ documentId, + citationToInsert, + aiSuggestion, editable, epoch, members, @@ -40,9 +43,14 @@ export function WorkspaceEditor({ userName, workspaceId, onDurableSequence, + onCitationInserted, + onAiSuggestionConsumed, onSaveStatus, + onTextChange, }: { documentId: string; + citationToInsert?: WorkspaceSource | null; + aiSuggestion?: { id: string; text: string } | null; editable: boolean; epoch: number; members: Array<{ user_id: string }>; @@ -51,7 +59,10 @@ export function WorkspaceEditor({ userName: string; workspaceId: string; onDurableSequence?: (sequence: number) => void; + onCitationInserted?: () => void; + onAiSuggestionConsumed?: () => void; onSaveStatus?: (status: SaveStatus) => void; + onTextChange?: (text: string) => void; }) { const { t } = useTranslation(); const online = useOnlineStatus(); @@ -87,6 +98,7 @@ export function WorkspaceEditor({ setStatus(navigator.onLine ? "Saving" : "Offline"); let provider: HocuspocusProvider | undefined; let activeEditor: ReturnType | undefined; + let reportText: (() => void) | undefined; const pending = new Set(); const loadedPending = new Set(); let bufferedHunk: ProposalHunkInput | undefined; @@ -231,6 +243,9 @@ export function WorkspaceEditor({ provider, user: { color: `hsl(${Math.abs(userId.charCodeAt(0) * 47) % 360} 70% 45%)`, name: userName }, }); + reportText = () => onTextChange?.(activeEditor?.getText() ?? ""); + activeEditor.on("update", reportText); + reportText(); document.on("update", (_update, origin) => { if (origin !== provider) setStatus("Saving"); void persist(); @@ -248,11 +263,49 @@ export function WorkspaceEditor({ flushSuggestion(); window.removeEventListener("offline", offline); window.removeEventListener("online", online); + if (activeEditor && reportText) activeEditor.off("update", reportText); activeEditor?.destroy(); provider?.destroy(); document.destroy(); }; - }, [documentId, editable, epoch, identity, onDurableSequence, online, queryClient, t, token, userId, userName, workspaceId]); + }, [documentId, editable, epoch, identity, onDurableSequence, onTextChange, online, queryClient, t, token, userId, userName, workspaceId]); + + useEffect(() => { + if (!editor || !citationToInsert) return; + const snapshot = citationToInsert.citation_snapshot; + editor.chain().focus().insertContent({ + type: "citation", + attrs: { + href: typeof snapshot.url === "string" ? snapshot.url : null, + locator: citationToInsert.title, + quote: citationToInsert.content, + sourceId: citationToInsert.id, + title: citationToInsert.title, + }, + }).run(); + onCitationInserted?.(); + }, [citationToInsert, editor, onCitationInserted]); + + useEffect(() => { + if (!editor || !aiSuggestion || status !== "Saved") return; + onAiSuggestionConsumed?.(); + const proposal = createProposal({ + authorId: userId, + documentEpoch: epoch, + editor, + hunks: [{ + content: [{ type: "paragraph", content: [{ type: "text", text: aiSuggestion.text }] }], + from: 0, + id: aiSuggestion.id, + kind: "format", + to: editor.state.doc.content.size, + }], + id: crypto.randomUUID(), + }); + void saveWorkspaceProposal({ documentId, proposal, workspaceId }) + .then(() => queryClient.invalidateQueries({ queryKey: ["workspace-proposals", userId, documentId] })) + .catch(() => setReviewError(t("workspaces.suggestionFailed", "Suggestion could not be saved."))); + }, [aiSuggestion, documentId, editor, epoch, onAiSuggestionConsumed, queryClient, status, t, userId, workspaceId]); if (!COLLABORATION_URL) { return

{t("workspaces.collaborationUnavailable", "Collaboration service is not configured.")}

; diff --git a/src/components/features/workspace/WorkspacePracticePanel.tsx b/src/components/features/workspace/WorkspacePracticePanel.tsx new file mode 100644 index 000000000..7840911a1 --- /dev/null +++ b/src/components/features/workspace/WorkspacePracticePanel.tsx @@ -0,0 +1,367 @@ +import { useMemo, useState, type FormEvent } from "react"; +import { useNavigate } from "react-router-dom"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { askChat } from "@/lib/ai/client"; +import { useSessionStore } from "@/lib/stores/session-store"; +import { useSettingsStore } from "@/lib/stores/settings-store"; +import { useSparringStore } from "@/lib/stores/sparring-store"; +import { + createWorkspaceThread, + listWorkspacePractice, + saveWorkspaceArtifact, + saveWorkspaceSource, + saveWorkspaceThreadMessage, + type WorkspaceSource, +} from "@/lib/workspace/practice-client"; +import { workspaceRouteState, type WorkspaceTool, type WorkspaceToolContext } from "@/lib/workspace/tool-context"; + +const TOOL_ROUTES: Array<{ tool: WorkspaceTool; route: string }> = [ + { tool: "generate", route: "/app/generate" }, + { tool: "factoids", route: "/app/factoids" }, + { tool: "brainstorm", route: "/app/brainstorm" }, + { tool: "attack", route: "/app/attack" }, + { tool: "ask", route: "/app/ask" }, + { tool: "flowchart", route: "/app/flowchart" }, + { tool: "crossfire", route: "/app/crossfire" }, + { tool: "sparring", route: "/app/sparring" }, +]; + +export function WorkspacePracticePanel({ + documentEpoch, + documentId, + documents, + documentText, + documentTitle, + motion, + onInsertCitation, + onSuggestRewrite, + side, + userId, + workspaceId, +}: { + documentEpoch: number; + documentId: string; + documents: Array<{ id: string; side: "pro" | "con"; title: string }>; + documentText: string; + documentTitle: string; + motion: string; + onInsertCitation: (source: WorkspaceSource) => void; + onSuggestRewrite: (suggestion: { id: string; text: string }) => void; + side: "pro" | "con"; + userId: string; + workspaceId: string; +}) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const personalSources = useSessionStore((state) => state.sourceLibrary); + const savedCases = useSessionStore((state) => state.savedCases); + const savedAttacks = useSessionStore((state) => state.savedAttacks); + const savedBrainstorms = useSessionStore((state) => state.savedBrainstorms); + const savedFactoids = useSessionStore((state) => state.savedFactoids); + const savedChats = useSessionStore((state) => state.savedChats); + const savedFlowcharts = useSessionStore((state) => state.savedFlowcharts); + const savedSparring = useSparringStore((state) => state.savedSessions); + const askConfig = useSettingsStore((state) => state.getFeatureConfig("ask")); + const [sourceTitle, setSourceTitle] = useState(""); + const [sourceContent, setSourceContent] = useState(""); + const [sourceSearch, setSourceSearch] = useState(""); + const [selectedSourceIds, setSelectedSourceIds] = useState([]); + const [selectedDocumentIds, setSelectedDocumentIds] = useState([documentId]); + const [threadName, setThreadName] = useState(""); + const [threadId, setThreadId] = useState(null); + const [message, setMessage] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const practice = useQuery({ + queryKey: ["workspace-practice", userId, workspaceId], + queryFn: () => listWorkspacePractice(workspaceId), + refetchInterval: 3_000, + }); + const visibleSources = useMemo(() => { + const search = sourceSearch.trim().toLowerCase(); + return practice.data?.sources.filter((source) => !search || `${source.title} ${source.content}`.toLowerCase().includes(search)) ?? []; + }, [practice.data?.sources, sourceSearch]); + const selectedThread = threadId ?? practice.data?.threads[0]?.id ?? null; + const threadMessages = practice.data?.messages.filter((item) => item.thread_id === selectedThread) ?? []; + const privateResults = useMemo(() => [ + ...savedCases.map((item) => ({ ...item, tool: "generate" as const })), + ...savedAttacks.map((item) => ({ ...item, tool: "attack" as const })), + ...savedBrainstorms.map((item) => ({ ...item, tool: "brainstorm" as const })), + ...savedFactoids.map((item) => ({ ...item, tool: "factoids" as const })), + ...savedChats.map((item) => ({ ...item, tool: "ask" as const })), + ...savedFlowcharts.map((item) => ({ ...item, tool: "flowchart" as const })), + ...savedSparring.map((item) => ({ createdAt: item.createdAt, data: item, id: item.id, title: item.motion, tool: "sparring" as const })), + ].sort((left, right) => right.createdAt - left.createdAt).slice(0, 10), [savedAttacks, savedBrainstorms, savedCases, savedChats, savedFactoids, savedFlowcharts, savedSparring]); + + const refresh = () => queryClient.invalidateQueries({ queryKey: ["workspace-practice", userId, workspaceId] }); + const run = async (operation: () => Promise) => { + if (busy || !navigator.onLine) return; + setBusy(true); + setError(null); + try { + await operation(); + await refresh(); + } catch { + setError(t("workspaces.practiceFailed", "The workspace practice item could not be saved.")); + } finally { + setBusy(false); + } + }; + const context = (requestId: string = crypto.randomUUID()): WorkspaceToolContext => ({ + documentEpoch, + documentId, + documentIds: selectedDocumentIds.includes(documentId) ? selectedDocumentIds : [documentId, ...selectedDocumentIds].slice(0, 12), + documentText, + documentTitle, + motion, + requestId, + returnUrl: `/app/workspaces/${workspaceId}`, + side, + sourceIds: selectedSourceIds.slice(0, 20), + threadId: selectedThread, + workspaceId, + }); + const launch = (tool: WorkspaceTool, route: string) => { + const session = useSessionStore.getState(); + if (tool === "generate") session.setGenerateForm({ motion, side }); + if (tool === "factoids") session.setFactoidsForm({ claim: documentText || motion, direction: side === "pro" ? "for" : "against" }); + if (tool === "brainstorm") session.setBrainstormForm({ context: documentText, motion, side }); + if (tool === "attack") session.setAttackForm({ caseText: documentText }); + if (tool === "flowchart") session.setPendingFlowchartData({ + edges: [], + nodes: [{ id: `workspace-${documentId}`, position: { x: 300, y: 80 }, data: { description: documentText, label: documentTitle, type: "thesis" }, type: "custom" }], + }); + const workspaceContext = context(); + navigate(route, { + state: { + ...workspaceRouteState(workspaceContext), + ...(tool === "crossfire" ? { argument: documentText, resolution: motion } : {}), + }, + }); + }; + const resume = (artifact: NonNullable["artifacts"][number]) => { + if (artifact.document_id !== documentId || artifact.document_epoch !== documentEpoch) return; + navigate(TOOL_ROUTES.find(({ tool }) => tool === artifact.tool)?.route ?? context().returnUrl, { + state: { workspaceArtifact: artifact.progress, workspaceContext: context(artifact.request_id) }, + }); + }; + + const addSource = (event: FormEvent) => { + event.preventDefault(); + const title = sourceTitle.trim(); + const content = sourceContent.trim(); + if (!title || !content) return; + void run(async () => { + await saveWorkspaceSource(workspaceId, { content, metadata: { capturedVia: "workspace" }, title, type: "text" }); + setSourceTitle(""); + setSourceContent(""); + }); + }; + + const sendThreadMessage = (event: FormEvent) => { + event.preventDefault(); + const content = message.trim(); + if (!selectedThread || !content) return; + const requestId = crypto.randomUUID(); + const assistantMessageId = crypto.randomUUID(); + setMessage(""); + void run(async () => { + await saveWorkspaceThreadMessage({ content, requestId, role: "user", status: "complete", threadId: selectedThread, workspaceId }); + await saveWorkspaceThreadMessage({ content: "Thinking…", messageId: assistantMessageId, requestId, role: "assistant", status: "progress", threadId: selectedThread, workspaceId }); + try { + const result = await askChat( + content, + threadMessages.filter(({ status }) => status === "complete").map(({ role, content: item }) => ({ role, content: item })), + "general", + askConfig, + { workspaceContext: context(requestId) }, + ); + await saveWorkspaceThreadMessage({ content: result.response, messageId: assistantMessageId, requestId, role: "assistant", status: "complete", threadId: selectedThread, workspaceId }); + } catch (cause) { + await saveWorkspaceThreadMessage({ + content: cause instanceof Error ? cause.message : "Interrupted", + messageId: assistantMessageId, + requestId, + role: "assistant", + status: "interrupted", + threadId: selectedThread, + workspaceId, + }); + throw cause; + } + }); + }; + const suggestRewrite = () => { + const requestId = crypto.randomUUID(); + const workspaceContext = context(requestId); + void run(async () => { + const result = await askChat( + "Rewrite the selected debate document for clarity and persuasive force. Return only the replacement document text.", + [], + "general", + askConfig, + { workspaceContext }, + ); + await saveWorkspaceArtifact({ + documentEpoch, + documentId, + inputSnapshot: { documentText, instruction: "rewrite" }, + outputSnapshot: result, + requestId, + status: "complete", + tool: "ask", + workspaceId, + }); + onSuggestRewrite({ id: requestId, text: result.response }); + }); + }; + + return ( +
+
+ {t("workspaces.evidence", "Evidence")} +
+ setSourceTitle(event.target.value)} /> +