diff --git a/e2e/workspace-collaboration.spec.ts b/e2e/workspace-collaboration.spec.ts index 8671c2a17..c9b23392d 100644 --- a/e2e/workspace-collaboration.spec.ts +++ b/e2e/workspace-collaboration.spec.ts @@ -3,6 +3,7 @@ import { expect, test, type Browser, type BrowserContext, type Page } from "@pla import { spawn, type ChildProcess } from "node:child_process"; import { createConnection } from "node:net"; import { kill as killProcess } from "node:process"; +import type { WorkspaceToolContext } from "../src/lib/workspace/tool-context"; const supabaseUrl = process.env.LOCAL_SUPABASE_URL; const anonKey = process.env.LOCAL_SUPABASE_ANON_KEY; @@ -348,3 +349,142 @@ test("real local users collaborate, recover, review, restore, and revoke access" await Promise.allSettled(ids.map((id) => admin.auth.admin.deleteUser(id))); } }); + +test("workspace evidence, team AI review, and practice survive a teammate reload", async ({ browser }, testInfo) => { + test.skip(testInfo.project.name === "mobile-chromium", "The core mobile collaboration journey runs above"); + test.setTimeout(120_000); + const admin = createClient(supabaseUrl!, serviceKey!, { auth: { persistSession: false } }); + const password = `Local-${crypto.randomUUID()}!`; + const ids: string[] = []; + const contexts: BrowserContext[] = []; + let collaboration: ChildProcess | undefined; + + try { + const sessions: Session[] = []; + for (const prefix of ["practice-owner", "practice-member"]) { + const email = `${prefix}-${Date.now()}-${crypto.randomUUID()}@workspace.test`; + const created = await admin.auth.admin.createUser({ email, email_confirm: true, password }); + if (created.error || !created.data.user) throw created.error ?? new Error("User creation failed"); + ids.push(created.data.user.id); + const client = createClient(supabaseUrl!, anonKey!, { auth: { persistSession: false } }); + const signedIn = await client.auth.signInWithPassword({ email, password }); + if (signedIn.error || !signedIn.data.session) throw signedIn.error ?? new Error("Sign in failed"); + sessions.push(signedIn.data.session); + } + + collaboration = await startCollaboration(); + const owner = await authenticatedPage(browser, sessions[0]); + const member = await authenticatedPage(browser, sessions[1]); + contexts.push(owner.context, member.context); + await owner.page.route("**/functions/v1/ai", async (route) => { + const body = route.request().postDataJSON() as { + input?: { message?: string }; + workspaceContext?: WorkspaceToolContext; + }; + const workspaceContext = body.workspaceContext; + if (workspaceContext?.threadId && workspaceContext.threadRequest && body.input?.message) { + const started = await admin.rpc("start_workspace_thread_generation", { + p_assistant_message_id: workspaceContext.threadRequest.assistantMessageId, + p_content: body.input.message, + p_request_id: workspaceContext.requestId, + p_thread_id: workspaceContext.threadId, + p_user_id: ids[0], + p_user_message_id: workspaceContext.threadRequest.userMessageId, + p_workspace_id: workspaceContext.workspaceId, + }); + if (started.error) throw started.error; + } + await route.fulfill({ + body: JSON.stringify({ output: { response: "Phones should be limited with a clear enforcement mechanism.", followUpQuestions: [], uncertainties: [] } }), + contentType: "application/json", + status: 200, + }); + }); + + await owner.page.goto("/app/workspaces"); + await owner.page.getByRole("textbox", { name: "Motion" }).fill("Schools should limit phones"); + await owner.page.getByRole("button", { name: "Create workspace" }).click(); + await expect(owner.page).toHaveURL(/\/app\/workspaces\/[0-9a-f-]+$/, { timeout: 20_000 }); + const workspaceUrl = owner.page.url(); + const workspaceId = workspaceUrl.split("/").at(-1)!; + const membership = await admin.from("workspace_members").insert({ workspace_id: workspaceId, user_id: ids[1] }); + if (membership.error) throw membership.error; + + await owner.page.getByRole("button", { name: "R1" }).click(); + await owner.page.locator(".ProseMirror").fill("Phones interrupt learning"); + await expect(owner.page.getByText("Saved", { exact: true })).toBeVisible(); + + await openPanel(owner.page, "Evidence"); + await owner.page.getByLabel("Source title").fill("Attention study"); + await owner.page.getByLabel("Quoted evidence").fill("Notifications reduce sustained attention."); + await owner.page.getByRole("button", { name: "Add evidence" }).click(); + await expect(owner.page.getByText("Attention study", { exact: true })).toBeVisible(); + await owner.page.getByRole("button", { name: "Insert citation" }).click(); + await expect(owner.page.locator(".ProseMirror")).toContainText("Attention study"); + + await openPanel(owner.page, "Team threads"); + await owner.page.getByLabel("Thread name").fill("Both sides"); + await owner.page.getByRole("button", { name: "Create", exact: true }).click(); + await owner.page.getByLabel("Team message").fill("Compare both sides"); + await owner.page.getByRole("button", { name: "Send", exact: true }).click(); + let pendingMessage: { request_id: string; thread_id: string } | null = null; + await expect.poll(async () => { + const result = await admin.from("workspace_thread_messages") + .select("request_id,thread_id").eq("workspace_id", workspaceId).eq("role", "assistant").eq("status", "progress").maybeSingle(); + if (result.error) throw result.error; + pendingMessage = result.data; + return Boolean(pendingMessage); + }).toBe(true); + if (!pendingMessage) throw new Error("Thread progress was not stored"); + const completed = await admin.rpc("complete_workspace_thread_generation", { + p_content: "Phones should be limited with a clear enforcement mechanism.", + p_request_id: pendingMessage.request_id, + p_status: "complete", + p_thread_id: pendingMessage.thread_id, + p_user_id: ids[0], + p_workspace_id: workspaceId, + }); + if (completed.error) throw completed.error; + await expect(owner.page.getByText(/clear enforcement mechanism/)).toBeVisible(); + + await openPanel(owner.page, "Practice"); + await owner.page.getByRole("button", { name: "Suggest AI rewrite" }).click(); + await openPanel(owner.page, "Review"); + await expect(owner.page.getByRole("button", { name: "Reject" })).toHaveCount(1); + await owner.page.getByRole("button", { name: "Reject" }).click(); + await expect(owner.page.locator(".ProseMirror")).toContainText("Phones interrupt learning"); + await openPanel(owner.page, "Practice"); + await owner.page.getByRole("button", { name: "Suggest AI rewrite" }).click(); + await openPanel(owner.page, "Review"); + await expect(owner.page.getByRole("button", { name: "Accept" })).toHaveCount(1); + await owner.page.getByRole("button", { name: "Accept" }).click(); + await expect(owner.page.locator(".ProseMirror")).toContainText("clear enforcement mechanism"); + + await openPanel(owner.page, "Practice"); + await owner.page.getByRole("button", { name: "Crossfire", exact: true }).click(); + await expect(owner.page).toHaveURL(/\/app\/crossfire$/); + await expect(owner.page.getByTestId("crossfire-resolution")).toHaveValue("Schools should limit phones"); + await expect(owner.page.getByText(/Shared workspace session/)).toBeVisible(); + await expect.poll(async () => { + const result = await admin.from("workspace_artifacts").select("id").eq("workspace_id", workspaceId).eq("tool", "crossfire"); + if (result.error) throw result.error; + return result.data.length; + }).toBe(1); + await owner.page.getByRole("link", { name: "Return to workspace" }).click(); + + await member.page.goto(workspaceUrl); + await member.page.getByRole("button", { name: "R1" }).click(); + await openPanel(member.page, "Practice"); + await expect(member.page.locator("summary").filter({ hasText: /Crossfire.*progress/i })).toBeVisible(); + await member.page.reload(); + await openPanel(member.page, "Practice"); + await expect(member.page.locator("summary").filter({ hasText: /Crossfire.*progress/i })).toBeVisible(); + + await owner.page.goto("/app/ask"); + await expect(owner.page.getByText(/Shared workspace session/)).toHaveCount(0); + } finally { + await stop(collaboration); + await Promise.allSettled(contexts.map((context) => context.close())); + await Promise.allSettled(ids.map((id) => admin.auth.admin.deleteUser(id))); + } +}); diff --git a/services/collaboration/src/postgres-store.test.ts b/services/collaboration/src/postgres-store.test.ts index 8a40673b0..e98af150c 100644 --- a/services/collaboration/src/postgres-store.test.ts +++ b/services/collaboration/src/postgres-store.test.ts @@ -81,6 +81,7 @@ describe("Postgres collaboration writer locks", () => { }); it("appends and snapshots only while author membership and epoch are current", async () => { + const { snapshot } = proposalFixture(); const client = new FakeClient(); const pool = { connect: vi.fn().mockResolvedValue(client) }; const store = new PostgresDurableUpdateStore(pool); @@ -104,7 +105,7 @@ describe("Postgres collaboration writer locks", () => { documentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", epoch: 3, mutationId: "a".repeat(43), - snapshot: new Uint8Array([3, 4]), + snapshot, syncType: 2, }); @@ -114,6 +115,8 @@ describe("Postgres collaboration writer locks", () => { .toBeLessThan(appendQueries.findIndex((text) => text.includes("SELECT state.workspace_id, state.durable_sequence"))); expect(client.query.mock.calls.some(([text]) => String(text).startsWith("INSERT INTO private.workspace_document_updates"))).toBe(true); expect(client.query.mock.calls.some(([text]) => String(text).startsWith("UPDATE private.workspace_document_state"))).toBe(true); + const projectionWrite = client.query.mock.calls.find(([text]) => String(text).startsWith("UPDATE private.workspace_document_state")); + expect(projectionWrite?.[1]?.[3]).toBe("Hello world"); expect(client.query).toHaveBeenCalledWith("COMMIT"); await release(); }); @@ -145,6 +148,7 @@ describe("Postgres collaboration writer locks", () => { }); it("loads the current snapshot and durable acknowledgement from the writer session", async () => { + const { snapshot } = proposalFixture(); const client = new FakeClient(); const pool = { connect: vi.fn().mockResolvedValue(client) }; const store = new PostgresDurableUpdateStore(pool); @@ -156,7 +160,7 @@ describe("Postgres collaboration writer locks", () => { client.query.mockReset(); client.query.mockImplementation(async (text: string) => { if (text.includes("SELECT snapshot")) { - return { rows: [{ snapshot: new Uint8Array([9, 8]) }] }; + return { rows: [{ snapshot, text_projection: "" }] }; } return { rows: [{ @@ -171,7 +175,8 @@ describe("Postgres collaboration writer locks", () => { await expect(store.loadSnapshot( "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", 3, - )).resolves.toEqual(new Uint8Array([9, 8])); + )).resolves.toEqual(snapshot); + expect(client.query.mock.calls.some(([text]) => String(text).includes("SET text_projection"))).toBe(true); await expect(store.find( "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", 3, 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..d7587db9d --- /dev/null +++ b/src/components/features/workspace/WorkspacePracticePanel.tsx @@ -0,0 +1,355 @@ +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, + 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; + 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(), + threadRequest: WorkspaceToolContext["threadRequest"] = null, + ): 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), + threadRequest, + 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(); + const userMessageId = crypto.randomUUID(); + setMessage(""); + void run(async () => { + await askChat( + content, + threadMessages.filter(({ status }) => status === "complete").map(({ role, content: item }) => ({ role, content: item })), + "general", + askConfig, + { workspaceContext: context(requestId, { assistantMessageId, userMessageId }) }, + ); + }); + }; + 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)} /> +