From 874508f63b020f6eadbb369f2bba167d0dcb4eef Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Feb 2026 20:03:34 +0000 Subject: [PATCH 001/812] Make transcript overlay consistent across all voice inputs - Add explicit "Send to writer" flow instead of auto-sending - Add "Clean up transcript" option before sending - Show TranscriptOverlay for both document selection and provocation voice - Store pending context (selectedText, provocation) for deferred sending - Clear distinction between raw transcript, cleaned version, and result summary - Propagate recording state from ProvocationsDisplay to parent https://claude.ai/code/session_01GJ79rwf8ykmRiTJyiWm8wr --- client/src/components/ProvocationsDisplay.tsx | 12 +- client/src/components/TranscriptOverlay.tsx | 222 +++++++++++++++--- client/src/pages/Workspace.tsx | 107 +++++++-- 3 files changed, 285 insertions(+), 56 deletions(-) diff --git a/client/src/components/ProvocationsDisplay.tsx b/client/src/components/ProvocationsDisplay.tsx index 63aec412..0a290047 100644 --- a/client/src/components/ProvocationsDisplay.tsx +++ b/client/src/components/ProvocationsDisplay.tsx @@ -47,6 +47,7 @@ interface ProvocationsDisplayProps { provocations: Provocation[]; onUpdateStatus: (id: string, status: Provocation["status"]) => void; onVoiceResponse?: (provocationId: string, transcript: string, provocationData: { type: string; title: string; content: string; sourceExcerpt: string }) => void; + onTranscriptUpdate?: (transcript: string, isRecording: boolean) => void; onHoverProvocation?: (provocationId: string | null) => void; isLoading?: boolean; isMerging?: boolean; @@ -56,12 +57,14 @@ function ProvocationCard({ provocation, onUpdateStatus, onVoiceResponse, + onTranscriptUpdate, onHover, isMerging }: { provocation: Provocation; onUpdateStatus: (status: Provocation["status"]) => void; onVoiceResponse?: (transcript: string, provocationData: { type: string; title: string; content: string; sourceExcerpt: string }) => void; + onTranscriptUpdate?: (transcript: string, isRecording: boolean) => void; onHover?: (isHovered: boolean) => void; isMerging?: boolean; }) { @@ -145,6 +148,12 @@ function ProvocationCard({ sourceExcerpt: provocation.sourceExcerpt, }); }} + onInterimTranscript={(interim) => onTranscriptUpdate?.(interim, true)} + onRecordingChange={(isRecording) => { + if (isRecording) { + onTranscriptUpdate?.("", true); + } + }} size="sm" variant="outline" className={isMerging ? "opacity-50 pointer-events-none" : ""} @@ -208,7 +217,7 @@ function ProvocationCard({ ); } -export function ProvocationsDisplay({ provocations, onUpdateStatus, onVoiceResponse, onHoverProvocation, isLoading, isMerging }: ProvocationsDisplayProps) { +export function ProvocationsDisplay({ provocations, onUpdateStatus, onVoiceResponse, onTranscriptUpdate, onHoverProvocation, isLoading, isMerging }: ProvocationsDisplayProps) { const [filter, setFilter] = useState("all"); const safeProvocations = provocations ?? []; @@ -305,6 +314,7 @@ export function ProvocationsDisplay({ provocations, onUpdateStatus, onVoiceRespo provocation={provocation} onUpdateStatus={(status) => onUpdateStatus(provocation.id, status)} onVoiceResponse={(transcript, provocationData) => onVoiceResponse?.(provocation.id, transcript, provocationData)} + onTranscriptUpdate={onTranscriptUpdate} onHover={(isHovered) => onHoverProvocation?.(isHovered ? provocation.id : null)} isMerging={isMerging} /> diff --git a/client/src/components/TranscriptOverlay.tsx b/client/src/components/TranscriptOverlay.tsx index a1772eb6..14d7800e 100644 --- a/client/src/components/TranscriptOverlay.tsx +++ b/client/src/components/TranscriptOverlay.tsx @@ -1,78 +1,224 @@ +import { useState } from "react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; -import { X, Mic, Loader2, Sparkles } from "lucide-react"; +import { X, Mic, Loader2, Sparkles, Wand2, Send, RotateCcw } from "lucide-react"; import { ScrollArea } from "@/components/ui/scroll-area"; +import { apiRequest } from "@/lib/queryClient"; interface TranscriptOverlayProps { isVisible: boolean; isRecording: boolean; rawTranscript: string; - summary: string; - isSummarizing: boolean; + // What gets sent to the writer + cleanedTranscript?: string; + // Result from the writer (what was changed) + resultSummary: string; + isProcessing: boolean; + // Callbacks onClose: () => void; + onSend?: (transcript: string) => void; + onCleanTranscript?: (cleaned: string) => void; + // Context for cleaning + context?: "selection" | "provocation" | "document"; } export function TranscriptOverlay({ isVisible, isRecording, rawTranscript, - summary, - isSummarizing, + cleanedTranscript, + resultSummary, + isProcessing, onClose, + onSend, + onCleanTranscript, + context = "document", }: TranscriptOverlayProps) { + const [isCleaning, setIsCleaning] = useState(false); + const [showRaw, setShowRaw] = useState(true); + if (!isVisible) return null; + const displayTranscript = cleanedTranscript || rawTranscript; + const hasCleanedVersion = cleanedTranscript && cleanedTranscript !== rawTranscript; + + const handleCleanTranscript = async () => { + if (!rawTranscript.trim()) return; + setIsCleaning(true); + try { + const response = await apiRequest("POST", "/api/summarize-intent", { + transcript: rawTranscript, + context: context === "selection" ? "instruction" : "instruction", + }); + const data = await response.json(); + if (data.summary && onCleanTranscript) { + onCleanTranscript(data.summary); + setShowRaw(false); + } + } catch (error) { + console.error("Failed to clean transcript:", error); + } finally { + setIsCleaning(false); + } + }; + + const handleSend = () => { + if (onSend && displayTranscript.trim()) { + onSend(displayTranscript); + } + }; + + const handleRevertToRaw = () => { + if (onCleanTranscript) { + onCleanTranscript(rawTranscript); + } + setShowRaw(true); + }; + return ( -
-
+
+
+

+ + Voice Input + {isRecording && (Recording...)} +

- -
- - - - - Raw Transcript - {isRecording && (Recording...)} - - - - -

- {rawTranscript || (isRecording ? "Listening..." : "No transcript yet")} -

-
-
-
+
+ {/* Raw/Cleaned Transcript Card */} - - {isSummarizing ? ( - - ) : ( - + +
+ + {hasCleanedVersion ? (showRaw ? "Raw Transcript" : "Cleaned Transcript") : "Your Voice Input"} + + {hasCleanedVersion && ( + + )} +
+ {rawTranscript.length > 0 && ( + + {(showRaw ? rawTranscript : displayTranscript).length.toLocaleString()} chars + )} - - Summary - {isSummarizing && (Processing...)} -
- + -

- {summary || (isSummarizing ? "Generating summary..." : "Summary will appear after you finish speaking")} +

+ {(showRaw ? rawTranscript : displayTranscript) || (isRecording ? "Listening..." : "No transcript yet")}

+ + {/* Action buttons - only show after recording stops */} + {!isRecording && rawTranscript.trim() && !resultSummary && ( +
+ + {hasCleanedVersion && ( + + )} + {onSend && ( + + )} +
+ )} + + {/* What gets sent explanation */} + {!isRecording && rawTranscript.trim() && !resultSummary && ( +

+ {hasCleanedVersion && !showRaw + ? "The cleaned version will be sent as your instruction to the AI writer." + : "Your raw transcript will be sent as-is to the AI writer. Use 'Clean up' to remove speech artifacts first."} +

+ )} + + {/* Result Summary Card - shows after writer responds */} + {resultSummary && ( + + + + + What Changed + + + + +

+ {resultSummary} +

+
+
+
+ )} + + {/* Processing indicator */} + {isProcessing && !resultSummary && ( +
+ + Updating document... +
+ )}
); diff --git a/client/src/pages/Workspace.tsx b/client/src/pages/Workspace.tsx index ac03e654..69156a3f 100644 --- a/client/src/pages/Workspace.tsx +++ b/client/src/pages/Workspace.tsx @@ -75,9 +75,23 @@ export default function Workspace() { // Transcript overlay state const [showTranscriptOverlay, setShowTranscriptOverlay] = useState(false); const [rawTranscript, setRawTranscript] = useState(""); + const [cleanedTranscript, setCleanedTranscript] = useState(undefined); const [transcriptSummary, setTranscriptSummary] = useState(""); const [isRecordingFromMain, setIsRecordingFromMain] = useState(false); + // Pending voice context for deferred sending (holds selectedText until user reviews transcript) + const [pendingVoiceContext, setPendingVoiceContext] = useState<{ + selectedText?: string; + provocation?: { + id: string; + type: string; + title: string; + content: string; + sourceExcerpt: string; + }; + context: "selection" | "provocation" | "document"; + } | null>(null); + // Edit history for coherent iteration const [editHistory, setEditHistory] = useState([]); @@ -422,39 +436,43 @@ export default function Workspace() { const handleVoiceResponse = useCallback((provocationId: string, transcript: string, provocationData: { type: string; title: string; content: string; sourceExcerpt: string }) => { if (!document || !transcript.trim()) return; - writeMutation.mutate({ - instruction: transcript, + // Store the context for deferred sending - user will review and click "Send to writer" + setPendingVoiceContext({ provocation: { - type: provocationData.type as "opportunity" | "fallacy" | "alternative", + id: provocationId, + type: provocationData.type, title: provocationData.title, content: provocationData.content, sourceExcerpt: provocationData.sourceExcerpt, }, - activeLens: activeLens || undefined, - description: `Addressed provocation: ${provocationData.title}`, + context: "provocation", }); - // Mark the provocation as addressed - setProvocations((prev) => - prev.map((p) => (p.id === provocationId ? { ...p, status: "addressed" as const } : p)) - ); - }, [document, writeMutation, activeLens]); + // Show the transcript overlay for review + setRawTranscript(transcript); + setShowTranscriptOverlay(true); + setTranscriptSummary(""); + setCleanedTranscript(undefined); + // Don't auto-send anymore - user will click "Send to writer" after reviewing + }, [document]); const toggleDiffView = useCallback(() => { setShowDiffView(prev => !prev); }, []); // Handle voice merge from text selection in ReadingPane + // Now stores context and lets user review transcript before sending const handleSelectionVoiceMerge = useCallback((selectedText: string, transcript: string) => { if (!document || !transcript.trim()) return; - writeMutation.mutate({ - instruction: transcript, + // Store the context for deferred sending - user will review and click "Send to writer" + setPendingVoiceContext({ selectedText, - activeLens: activeLens || undefined, - description: "Voice edit on selection", + context: "selection", }); - }, [document, writeMutation, activeLens]); + // Transcript is already set via handleTranscriptUpdate + // Don't auto-send anymore - user will click "Send to writer" after reviewing + }, [document]); const handleTranscriptUpdate = useCallback((transcript: string, isRecording: boolean) => { setRawTranscript(transcript); @@ -468,8 +486,58 @@ export default function Workspace() { const handleCloseTranscriptOverlay = useCallback(() => { setShowTranscriptOverlay(false); setRawTranscript(""); + setCleanedTranscript(undefined); setTranscriptSummary(""); setIsRecordingFromMain(false); + setPendingVoiceContext(null); + }, []); + + // Handle explicit send from TranscriptOverlay + const handleSendTranscript = useCallback((transcript: string) => { + if (!document || !transcript.trim()) return; + + // Use the pending context if available + const context = pendingVoiceContext; + + if (context?.provocation) { + // Sending as provocation response + writeMutation.mutate({ + instruction: transcript, + provocation: { + type: context.provocation.type as "opportunity" | "fallacy" | "alternative", + title: context.provocation.title, + content: context.provocation.content, + sourceExcerpt: context.provocation.sourceExcerpt, + }, + activeLens: activeLens || undefined, + description: `Addressed provocation: ${context.provocation.title}`, + }); + + // Mark the provocation as addressed + setProvocations((prev) => + prev.map((p) => (p.id === context.provocation!.id ? { ...p, status: "addressed" as const } : p)) + ); + } else if (context?.selectedText) { + // Sending as selection edit + writeMutation.mutate({ + instruction: transcript, + selectedText: context.selectedText, + activeLens: activeLens || undefined, + description: "Voice edit on selection", + }); + } else { + // Sending as general document instruction + writeMutation.mutate({ + instruction: transcript, + activeLens: activeLens || undefined, + description: "Voice instruction", + }); + } + }, [document, pendingVoiceContext, writeMutation, activeLens]); + + // Handle cleaned transcript from TranscriptOverlay + const handleCleanTranscript = useCallback((cleaned: string) => { + setCleanedTranscript(cleaned); }, []); const activeLensSummary = lenses?.find((l) => l.type === activeLens)?.summary; @@ -739,9 +807,13 @@ export default function Workspace() { isVisible={showTranscriptOverlay} isRecording={isRecordingFromMain} rawTranscript={rawTranscript} - summary={transcriptSummary} - isSummarizing={writeMutation.isPending} + cleanedTranscript={cleanedTranscript} + resultSummary={transcriptSummary} + isProcessing={writeMutation.isPending} onClose={handleCloseTranscriptOverlay} + onSend={handleSendTranscript} + onCleanTranscript={handleCleanTranscript} + context={pendingVoiceContext?.context || "document"} /> @@ -786,6 +858,7 @@ export default function Workspace() { provocations={provocations} onUpdateStatus={handleUpdateProvocationStatus} onVoiceResponse={handleVoiceResponse} + onTranscriptUpdate={handleTranscriptUpdate} onHoverProvocation={setHoveredProvocationId} isLoading={analyzeMutation.isPending} isMerging={writeMutation.isPending} From 63942b4ecee1713fd4c912940ab9cc7b1fa15a84 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Feb 2026 00:35:51 +0000 Subject: [PATCH 002/812] Enhance write API with smarter context and better instruction handling Improvements: 1. Auto-clean voice transcripts - detect speech artifacts and extract clear intent before processing 2. Planning step for complex instructions - generate execution plan for restructuring or multi-step changes 3. Preservation directives - explicit rules about what NOT to change based on instruction type (correct, style, condense, selection) 4. Better provocation response guidance - include examples of good responses per provocation type (opportunity, fallacy, alternative) 5. Style metrics extraction - analyze reference documents for avg sentence length, formality indicators, structure patterns These changes improve edit quality by: - Reducing unnecessary changes from verbose voice input - Making complex instructions more deterministic - Preventing AI from making unsolicited "improvements" - Giving better guidance for provocation responses - Using reference docs more intelligently https://claude.ai/code/session_01GJ79rwf8ykmRiTJyiWm8wr --- server/routes.ts | 288 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 280 insertions(+), 8 deletions(-) diff --git a/server/routes.ts b/server/routes.ts index 0a9e2350..ea63bbe8 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -48,6 +48,215 @@ const instructionPatterns: Record = { general: [], // fallback }; +// Speech artifact patterns to detect voice transcripts +const speechArtifacts = [ + /\b(um|uh|er|ah|like|you know|basically|so basically|I mean|kind of|sort of)\b/gi, + /\b(gonna|wanna|gotta|kinda|sorta)\b/gi, + /(\w+)\s+\1\b/gi, // repeated words + /^(so|and|but|okay|alright|well)\s/i, // filler starts +]; + +// Detect if instruction looks like a voice transcript +function isLikelyVoiceTranscript(text: string): boolean { + let artifactCount = 0; + for (const pattern of speechArtifacts) { + const matches = text.match(pattern); + if (matches) artifactCount += matches.length; + } + // If more than 2 artifacts per 100 words, likely voice + const wordCount = text.split(/\s+/).length; + return artifactCount > 0 && (artifactCount / wordCount) > 0.02; +} + +// Clean voice transcript to extract clear intent +async function cleanVoiceTranscript(transcript: string): Promise { + try { + const response = await openai.chat.completions.create({ + model: "gpt-4o-mini", + messages: [ + { + role: "system", + content: `You are an expert at extracting clear editing instructions from spoken transcripts. + +Your job is to: +1. Remove speech artifacts (um, uh, like, you know, basically, so, repeated words) +2. Extract the core instruction/intent +3. Make it a clear, actionable editing directive + +Keep the user's intent intact. Don't add information they didn't mention. +Output ONLY the cleaned instruction, nothing else.` + }, + { + role: "user", + content: transcript + } + ], + max_tokens: 500, + temperature: 0.2, + }); + return response.choices[0]?.message?.content?.trim() || transcript; + } catch { + return transcript; // Fall back to original if cleaning fails + } +} + +// Determine if instruction is complex enough to need planning +function isComplexInstruction(instruction: string, instructionType: InstructionType): boolean { + // Complex if: restructure type, multiple actions, or long instruction + if (instructionType === "restructure") return true; + if (instruction.length > 200) return true; + + // Check for multiple action words + const actionWords = instruction.match(/\b(add|remove|change|move|update|fix|expand|condense|rewrite|split|merge|create|delete)\b/gi); + if (actionWords && actionWords.length >= 2) return true; + + // Check for list-like instructions (numbered or bulleted) + if (/\d\.\s|[-•]\s/.test(instruction)) return true; + + return false; +} + +// Generate a plan for complex instructions +async function generateEditPlan( + document: string, + instruction: string, + selectedText: string | undefined, + objective: string +): Promise { + try { + const response = await openai.chat.completions.create({ + model: "gpt-4o-mini", + messages: [ + { + role: "system", + content: `You are an expert editor planning document changes. Given an instruction, create a brief execution plan. + +Output a concise numbered list (3-5 steps max) of specific changes to make. +Each step should be atomic and verifiable. +Focus on WHAT to change, not HOW to write it. + +Example: +Instruction: "Add more detail about pricing and move the FAQ to the end" +Plan: +1. Expand the pricing section with specific tier information +2. Add pricing comparison table after the tier descriptions +3. Move the FAQ section to after the Contact section +4. Update any internal references to FAQ location` + }, + { + role: "user", + content: `Document (first 1000 chars): ${document.slice(0, 1000)}${document.length > 1000 ? "..." : ""} +${selectedText ? `\nSelected text: "${selectedText}"` : ""} +Objective: ${objective} +Instruction: ${instruction} + +Create a brief execution plan:` + } + ], + max_tokens: 300, + temperature: 0.3, + }); + return response.choices[0]?.message?.content?.trim() || ""; + } catch { + return ""; // Skip planning if it fails + } +} + +// Extract style metrics from reference documents +function extractStyleMetrics(content: string): { + avgSentenceLength: number; + avgParagraphLength: number; + formalityIndicators: string[]; + structurePatterns: string[]; +} { + const sentences = content.split(/[.!?]+/).filter(s => s.trim().length > 0); + const paragraphs = content.split(/\n\n+/).filter(p => p.trim().length > 0); + const words = content.split(/\s+/).length; + + const avgSentenceLength = sentences.length > 0 ? Math.round(words / sentences.length) : 0; + const avgParagraphLength = paragraphs.length > 0 ? Math.round(sentences.length / paragraphs.length) : 0; + + // Detect formality indicators + const formalityIndicators: string[] = []; + if (/\b(therefore|however|moreover|furthermore|consequently)\b/i.test(content)) { + formalityIndicators.push("uses formal transitions"); + } + if (/\b(I|we|you)\b/i.test(content)) { + formalityIndicators.push("uses personal pronouns"); + } else { + formalityIndicators.push("avoids personal pronouns"); + } + if (/\b(don't|won't|can't|isn't|aren't)\b/.test(content)) { + formalityIndicators.push("uses contractions (informal)"); + } else { + formalityIndicators.push("avoids contractions (formal)"); + } + + // Detect structure patterns + const structurePatterns: string[] = []; + if (/^#+\s/m.test(content)) { + structurePatterns.push("uses markdown headers"); + } + if (/^[-*]\s/m.test(content)) { + structurePatterns.push("uses bullet lists"); + } + if (/^\d+\.\s/m.test(content)) { + structurePatterns.push("uses numbered lists"); + } + if (/\*\*[^*]+\*\*/.test(content)) { + structurePatterns.push("uses bold for emphasis"); + } + + return { avgSentenceLength, avgParagraphLength, formalityIndicators, structurePatterns }; +} + +// Format style metrics for prompt inclusion +function formatStyleGuidance(docs: ReferenceDocument[]): string { + const metrics = docs.map(doc => ({ + name: doc.name, + type: doc.type, + ...extractStyleMetrics(doc.content) + })); + + const lines: string[] = ["STYLE GUIDANCE (extracted from reference documents):"]; + + for (const m of metrics) { + lines.push(`\n[${m.type.toUpperCase()}: ${m.name}]`); + lines.push(`- Average sentence length: ${m.avgSentenceLength} words`); + lines.push(`- Average paragraph length: ${m.avgParagraphLength} sentences`); + if (m.formalityIndicators.length > 0) { + lines.push(`- Formality: ${m.formalityIndicators.join(", ")}`); + } + if (m.structurePatterns.length > 0) { + lines.push(`- Structure: ${m.structurePatterns.join(", ")}`); + } + } + + lines.push("\nMatch these style characteristics in your edits."); + return lines.join("\n"); +} + +// Provocation response examples for better guidance +const provocationResponseExamples: Record = { + opportunity: `Example good responses to opportunity provocations: +- "Add a section about X to address this gap" +- "Expand the benefits section to include Y" +- "Include a case study showing Z" +The goal is to enrich the document with new content that addresses the opportunity.`, + + fallacy: `Example good responses to fallacy provocations: +- "Add evidence to support the claim about X" +- "Soften the absolute language in paragraph Y" +- "Add a counterargument and address it" +The goal is to strengthen the argument with better reasoning or evidence.`, + + alternative: `Example good responses to alternative provocations: +- "Acknowledge the alternative approach and explain why we chose this one" +- "Add a comparison section weighing both options" +- "Include the alternative as an option for different use cases" +The goal is to show thoughtful consideration of alternatives.`, +}; + // Strategy prompts for each instruction type const instructionStrategies: Record = { expand: "Add depth, examples, supporting details, and elaboration. Develop ideas more fully while maintaining coherence.", @@ -281,7 +490,7 @@ Output only valid JSON, no markdown.` document, objective, selectedText, - instruction, + instruction: rawInstruction, provocation, activeLens, tone, @@ -290,10 +499,28 @@ Output only valid JSON, no markdown.` editHistory } = parsed.data; + // Step 1: Clean voice transcripts before processing + let instruction = rawInstruction; + let wasVoiceTranscript = false; + if (isLikelyVoiceTranscript(rawInstruction)) { + wasVoiceTranscript = true; + instruction = await cleanVoiceTranscript(rawInstruction); + console.log(`[Write API] Cleaned voice transcript: "${rawInstruction.slice(0, 50)}..." → "${instruction.slice(0, 50)}..."`); + } + // Classify the instruction type const instructionType = classifyInstruction(instruction); const strategy = instructionStrategies[instructionType]; + // Step 2: Generate plan for complex instructions + let editPlan = ""; + if (isComplexInstruction(instruction, instructionType)) { + editPlan = await generateEditPlan(document, instruction, selectedText, objective); + if (editPlan) { + console.log(`[Write API] Generated plan for complex instruction`); + } + } + // Build context sections const contextParts: string[] = []; @@ -301,6 +528,12 @@ Output only valid JSON, no markdown.` contextParts.push(`INSTRUCTION TYPE: ${instructionType} STRATEGY: ${strategy}`); + // Add edit plan if generated + if (editPlan) { + contextParts.push(`EXECUTION PLAN (follow this step by step): +${editPlan}`); + } + // Add edit history for coherent iteration if (editHistory && editHistory.length > 0) { const historyStr = editHistory @@ -313,17 +546,21 @@ ${historyStr}`); // Add reference document context for style inference if (referenceDocuments && referenceDocuments.length > 0) { - const refSummaries = referenceDocuments.map(d => { + // Extract and format style metrics from references + const styleGuidance = formatStyleGuidance(referenceDocuments); + + // Also include a brief excerpt for context + const refExcerpts = referenceDocuments.map(d => { const typeLabel = d.type === "style" ? "STYLE GUIDE" : d.type === "template" ? "TEMPLATE" : "EXAMPLE"; - return `[${typeLabel}: ${d.name}]\n${d.content.slice(0, 1000)}${d.content.length > 1000 ? "..." : ""}`; + return `[${typeLabel}: ${d.name}]\n${d.content.slice(0, 500)}${d.content.length > 500 ? "..." : ""}`; }).join("\n\n---\n\n"); - contextParts.push(`REFERENCE DOCUMENTS (use these to guide tone, style, and structure): -${refSummaries} + contextParts.push(`${styleGuidance} -Analyze the style, structure, and voice of these references. Match the target document's quality, formatting patterns, and professional standards where appropriate.`); +REFERENCE EXCERPTS (for additional context): +${refExcerpts}`); } if (activeLens) { @@ -339,11 +576,16 @@ Analyze the style, structure, and voice of these references. Match the target do } if (provocation) { + const responseGuidance = provocationResponseExamples[provocation.type]; contextParts.push(`PROVOCATION BEING ADDRESSED: Type: ${provocation.type} Challenge: ${provocation.title} Details: ${provocation.content} -Relevant excerpt: "${provocation.sourceExcerpt}"`); +Relevant excerpt: "${provocation.sourceExcerpt}" + +${responseGuidance} + +The user's response should be integrated thoughtfully - don't just append it, weave it into the document naturally.`); } if (tone) { @@ -367,6 +609,30 @@ Relevant excerpt: "${provocation.sourceExcerpt}"`); ? `The user has selected specific text to focus on. Apply the instruction primarily to this selection, but ensure it integrates well with the rest of the document.` : `Apply the instruction to improve the document holistically.`; + // Build preservation directives based on context + const preservationDirectives: string[] = []; + if (selectedText) { + preservationDirectives.push("- PRESERVE all text outside the selected area unless the instruction explicitly affects it"); + preservationDirectives.push("- DO NOT reformat or restructure sections that weren't mentioned"); + } + if (instructionType === "correct") { + preservationDirectives.push("- ONLY fix the specific error mentioned - no other changes"); + } + if (instructionType === "style") { + preservationDirectives.push("- PRESERVE the content and meaning - only change the voice/tone"); + } + if (instructionType === "condense") { + preservationDirectives.push("- PRESERVE all key information - only remove redundancy and filler"); + } + // Always include these + preservationDirectives.push("- DO NOT add information the user didn't mention or request"); + preservationDirectives.push("- DO NOT remove content unless explicitly asked to"); + preservationDirectives.push("- PRESERVE markdown formatting and structure unless asked to change it"); + + const preservationSection = preservationDirectives.length > 0 + ? `\n\nPRESERVATION RULES (follow strictly):\n${preservationDirectives.join("\n")}` + : ""; + // Two-step process: 1) Generate evolved document, 2) Analyze changes const documentResponse = await openai.chat.completions.create({ model: "gpt-5.2", @@ -380,13 +646,19 @@ DOCUMENT OBJECTIVE: ${objective} Your role is to evolve the document based on the user's instruction while always keeping the objective in mind. The document should get better with each iteration - clearer, more compelling, better structured. +APPROACH: +1. First, understand exactly what the user wants changed +2. Identify the minimal set of changes needed +3. Execute those changes precisely +4. Verify you haven't made unintended changes + Guidelines: 1. ${focusInstruction} 2. Preserve the document's voice and structure unless explicitly asked to change it 3. Make targeted improvements, not wholesale rewrites 4. The output should be the complete evolved document (not just the changed parts) 5. Use markdown formatting for structure (headers, lists, emphasis) where appropriate -${contextSection} +${contextSection}${preservationSection} Output only the evolved document text. No explanations or meta-commentary.` }, From 45e8237c8a29392cbb01d8e0e13ca726eb6db5a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Feb 2026 07:26:35 +0000 Subject: [PATCH 003/812] Fix microphone buttons not connecting to writer API The core bug was that isRecordingFromMain was never reset to false after voice recording stopped, causing the TranscriptOverlay to stay stuck in "Recording..." state and never showing the "Send to writer" button. Three fixes: - Workspace: handleVoiceResponse now sets isRecordingFromMain=false - Workspace: handleTranscriptUpdate preserves transcript when recording stops with empty string (prevents overwriting the final transcript) - ProvocationsDisplay: onRecordingChange now signals both start AND stop - ReadingPane: header mic button now routes through the transcript overlay and writer pipeline instead of just appending raw text to the document https://claude.ai/code/session_01Uas2DK36GQoYEB4TfGGsAx --- client/src/components/ProvocationsDisplay.tsx | 4 +--- client/src/components/ReadingPane.tsx | 18 +++++++++--------- client/src/pages/Workspace.tsx | 8 +++++++- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/client/src/components/ProvocationsDisplay.tsx b/client/src/components/ProvocationsDisplay.tsx index 0a290047..ea9b8db6 100644 --- a/client/src/components/ProvocationsDisplay.tsx +++ b/client/src/components/ProvocationsDisplay.tsx @@ -150,9 +150,7 @@ function ProvocationCard({ }} onInterimTranscript={(interim) => onTranscriptUpdate?.(interim, true)} onRecordingChange={(isRecording) => { - if (isRecording) { - onTranscriptUpdate?.("", true); - } + onTranscriptUpdate?.("", isRecording); }} size="sm" variant="outline" diff --git a/client/src/components/ReadingPane.tsx b/client/src/components/ReadingPane.tsx index 745cef82..618241b1 100644 --- a/client/src/components/ReadingPane.tsx +++ b/client/src/components/ReadingPane.tsx @@ -272,14 +272,6 @@ export function ReadingPane({ text, activeLens, lensSummary, onTextChange, highl }, 50); }, []); - // Handle voice transcript for appending to document - const handleVoiceAppend = useCallback((transcript: string) => { - if (transcript.trim() && onTextChange) { - const newText = text + (text.endsWith('\n') || text.endsWith(' ') ? '' : ' ') + transcript; - onTextChange(newText); - } - }, [text, onTextChange]); - const wordCount = text.split(/\s+/).filter(Boolean).length; const readingTime = Math.ceil(wordCount / 200); @@ -304,7 +296,15 @@ export function ReadingPane({ text, activeLens, lensSummary, onTextChange, highl {wordCount.toLocaleString()} words {readingTime} min read { + onTranscriptUpdate?.(transcript, false); + }} + onInterimTranscript={(interim) => { + onTranscriptUpdate?.(interim, true); + }} + onRecordingChange={(recording) => { + onTranscriptUpdate?.("", recording); + }} size="icon" variant="ghost" className="h-8 w-8" diff --git a/client/src/pages/Workspace.tsx b/client/src/pages/Workspace.tsx index 69156a3f..436c7a6a 100644 --- a/client/src/pages/Workspace.tsx +++ b/client/src/pages/Workspace.tsx @@ -453,6 +453,7 @@ export default function Workspace() { setShowTranscriptOverlay(true); setTranscriptSummary(""); setCleanedTranscript(undefined); + setIsRecordingFromMain(false); // Don't auto-send anymore - user will click "Send to writer" after reviewing }, [document]); @@ -475,11 +476,16 @@ export default function Workspace() { }, [document]); const handleTranscriptUpdate = useCallback((transcript: string, isRecording: boolean) => { - setRawTranscript(transcript); + // Only update rawTranscript if there's content or recording is starting (clear for fresh start) + // When isRecording=false and transcript is empty, preserve the existing transcript + if (transcript || isRecording) { + setRawTranscript(transcript); + } setIsRecordingFromMain(isRecording); if (isRecording && !showTranscriptOverlay) { setShowTranscriptOverlay(true); setTranscriptSummary(""); + setCleanedTranscript(undefined); } }, [showTranscriptOverlay]); From dad1a78f1e561ffe9022cdcf52b69e52db6ea518 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Feb 2026 07:43:10 +0000 Subject: [PATCH 004/812] Remove Lenses panel, add provocation regeneration with voice input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove LensesPanel from workspace layout to reclaim horizontal space (55/45 split instead of 20/45/35 three-panel layout) - Clean up all lens-related state, props, and imports across components - Remove lens banner from ReadingPane - Add POST /api/generate-provocations endpoint that accepts optional voice/text guidance to generate targeted provocations - Add "Provoke More" UI in ProvocationsDisplay with text input + mic for instructing what kind of challenges to generate - New provocations append to existing list - Validated all 10 mic buttons follow consistent patterns: Document-modifying mics: Record → TranscriptOverlay → Clean → Send → /api/write Text-field mics: Direct fill (objective, guidance, source text) https://claude.ai/code/session_01Uas2DK36GQoYEB4TfGGsAx --- client/src/components/ProvocationsDisplay.tsx | 76 +++++++++++++-- client/src/components/ReadingPane.tsx | 28 +----- client/src/pages/Workspace.tsx | 68 ++++++++------ server/routes.ts | 94 +++++++++++++++++++ shared/schema.ts | 9 ++ 5 files changed, 210 insertions(+), 65 deletions(-) diff --git a/client/src/components/ProvocationsDisplay.tsx b/client/src/components/ProvocationsDisplay.tsx index ea9b8db6..fe835c52 100644 --- a/client/src/components/ProvocationsDisplay.tsx +++ b/client/src/components/ProvocationsDisplay.tsx @@ -5,16 +5,19 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import { Skeleton } from "@/components/ui/skeleton"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { VoiceRecorder } from "./VoiceRecorder"; -import { - Lightbulb, - AlertTriangle, - GitBranch, - Check, - X, +import { Input } from "@/components/ui/input"; +import { + Lightbulb, + AlertTriangle, + GitBranch, + Check, + X, Star, MessageSquareWarning, ChevronDown, - ChevronUp + ChevronUp, + RefreshCw, + Loader2 } from "lucide-react"; import { useState } from "react"; import type { Provocation, ProvocationType } from "@shared/schema"; @@ -49,8 +52,10 @@ interface ProvocationsDisplayProps { onVoiceResponse?: (provocationId: string, transcript: string, provocationData: { type: string; title: string; content: string; sourceExcerpt: string }) => void; onTranscriptUpdate?: (transcript: string, isRecording: boolean) => void; onHoverProvocation?: (provocationId: string | null) => void; + onRegenerateProvocations?: (guidance?: string) => void; isLoading?: boolean; isMerging?: boolean; + isRegenerating?: boolean; } function ProvocationCard({ @@ -215,8 +220,11 @@ function ProvocationCard({ ); } -export function ProvocationsDisplay({ provocations, onUpdateStatus, onVoiceResponse, onTranscriptUpdate, onHoverProvocation, isLoading, isMerging }: ProvocationsDisplayProps) { +export function ProvocationsDisplay({ provocations, onUpdateStatus, onVoiceResponse, onTranscriptUpdate, onHoverProvocation, onRegenerateProvocations, isLoading, isMerging, isRegenerating }: ProvocationsDisplayProps) { const [filter, setFilter] = useState("all"); + const [guidance, setGuidance] = useState(""); + const [guidanceInterim, setGuidanceInterim] = useState(""); + const [isRecordingGuidance, setIsRecordingGuidance] = useState(false); const safeProvocations = provocations ?? []; @@ -319,6 +327,58 @@ export function ProvocationsDisplay({ provocations, onUpdateStatus, onVoiceRespo ))}
+ + {onRegenerateProvocations && ( +
+
+ setGuidance(e.target.value)} + className="flex-1 text-sm h-8" + readOnly={isRecordingGuidance} + disabled={isRegenerating} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + onRegenerateProvocations(guidance || undefined); + setGuidance(""); + } + }} + /> + { + setGuidance(transcript); + setGuidanceInterim(""); + }} + onInterimTranscript={setGuidanceInterim} + onRecordingChange={setIsRecordingGuidance} + size="icon" + variant="ghost" + className="h-8 w-8" + /> + +
+ {isRecordingGuidance && ( +

Listening... describe what you want challenged

+ )} +
+ )}
); } diff --git a/client/src/components/ReadingPane.tsx b/client/src/components/ReadingPane.tsx index 618241b1..96ac5294 100644 --- a/client/src/components/ReadingPane.tsx +++ b/client/src/components/ReadingPane.tsx @@ -3,25 +3,13 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { BookOpen, Eye, Download, Mic, Square, Send, X, Loader2 } from "lucide-react"; +import { BookOpen, Download, Mic, Square, Send, X, Loader2 } from "lucide-react"; import { useToast } from "@/hooks/use-toast"; import { apiRequest } from "@/lib/queryClient"; import { VoiceRecorder } from "@/components/VoiceRecorder"; -import type { LensType } from "@shared/schema"; - -const lensLabels: Record = { - consumer: "Consumer's Lens", - executive: "Executive's Lens", - technical: "Technical Lens", - financial: "Financial Lens", - strategic: "Strategic Lens", - skeptic: "Skeptic's Lens", -}; interface ReadingPaneProps { text: string; - activeLens: LensType | null; - lensSummary?: string; onTextChange?: (text: string) => void; highlightText?: string; onVoiceMerge?: (selectedText: string, transcript: string) => void; @@ -30,7 +18,7 @@ interface ReadingPaneProps { onTextEdit?: (newText: string) => void; } -export function ReadingPane({ text, activeLens, lensSummary, onTextChange, highlightText, onVoiceMerge, isMerging, onTranscriptUpdate, onTextEdit }: ReadingPaneProps) { +export function ReadingPane({ text, onTextChange, highlightText, onVoiceMerge, isMerging, onTranscriptUpdate, onTextEdit }: ReadingPaneProps) { const { toast } = useToast(); const [selectedText, setSelectedText] = useState(""); const [selectionPosition, setSelectionPosition] = useState<{ x: number; y: number } | null>(null); @@ -321,18 +309,6 @@ export function ReadingPane({ text, activeLens, lensSummary, onTextChange, highl
- {activeLens && lensSummary && ( -
-
- - Viewing through {lensLabels[activeLens]} -
-

- {lensSummary} -

-
- )} -
diff --git a/client/src/pages/Workspace.tsx b/client/src/pages/Workspace.tsx index 436c7a6a..9a1b9ea1 100644 --- a/client/src/pages/Workspace.tsx +++ b/client/src/pages/Workspace.tsx @@ -4,7 +4,6 @@ import { useToast } from "@/hooks/use-toast"; import { apiRequest } from "@/lib/queryClient"; import { generateId } from "@/lib/utils"; import { TextInputForm } from "@/components/TextInputForm"; -import { LensesPanel } from "@/components/LensesPanel"; import { ProvocationsDisplay } from "@/components/ProvocationsDisplay"; import { OutlineBuilder } from "@/components/OutlineBuilder"; import { ReadingPane } from "@/components/ReadingPane"; @@ -36,10 +35,8 @@ import { Badge } from "@/components/ui/badge"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import type { Document, - Lens, Provocation, OutlineItem, - LensType, ToneOption, DocumentVersion, WriteRequest, @@ -57,8 +54,6 @@ export default function Workspace() { const [document, setDocument] = useState(null); const [objective, setObjective] = useState(""); const [referenceDocuments, setReferenceDocuments] = useState([]); - const [lenses, setLenses] = useState([]); - const [activeLens, setActiveLens] = useState(null); const [provocations, setProvocations] = useState([]); const [outline, setOutline] = useState([]); const [selectedTone, setSelectedTone] = useState("practical"); @@ -115,16 +110,13 @@ export default function Workspace() { }); return await response.json() as { document: Document; - lenses: Lens[]; provocations: Provocation[]; warnings?: Array<{ type: string; message: string }>; }; }, onSuccess: (data) => { - const lensesData = data.lenses ?? []; const provocationsData = data.provocations ?? []; setDocument(data.document); - setLenses(lensesData); setProvocations(provocationsData); setPhase("workspace"); @@ -149,7 +141,7 @@ export default function Workspace() { toast({ title: "Analysis Complete", - description: `Generated ${lensesData.length} lenses and ${provocationsData.length} provocations.`, + description: `Generated ${provocationsData.length} provocations.`, }); }, onError: (error) => { @@ -276,6 +268,33 @@ export default function Workspace() { }, }); + const regenerateProvocationsMutation = useMutation({ + mutationFn: async ({ guidance }: { guidance?: string }) => { + if (!document) throw new Error("No document"); + const response = await apiRequest("POST", "/api/generate-provocations", { + text: document.rawText, + guidance, + referenceDocuments: referenceDocuments.length > 0 ? referenceDocuments : undefined, + }); + return await response.json() as { provocations: Provocation[] }; + }, + onSuccess: (data) => { + const newProvocations = data.provocations ?? []; + setProvocations(prev => [...prev, ...newProvocations]); + toast({ + title: "New Provocations Generated", + description: `Added ${newProvocations.length} new provocations.`, + }); + }, + onError: (error) => { + toast({ + title: "Generation Failed", + description: error instanceof Error ? error.message : "Something went wrong", + variant: "destructive", + }); + }, + }); + const handleAnalyze = useCallback((text: string, docObjective?: string, refs?: ReferenceDocument[]) => { if (docObjective) { setObjective(docObjective); @@ -382,13 +401,15 @@ export default function Workspace() { setRefinedPreview(null); }, []); + const handleRegenerateProvocations = useCallback((guidance?: string) => { + regenerateProvocationsMutation.mutate({ guidance }); + }, [regenerateProvocationsMutation]); + const handleReset = useCallback(() => { setPhase("input"); setDocument(null); setObjective(""); setReferenceDocuments([]); - setLenses([]); - setActiveLens(null); setProvocations([]); setOutline([]); setRefinedPreview(null); @@ -515,7 +536,6 @@ export default function Workspace() { content: context.provocation.content, sourceExcerpt: context.provocation.sourceExcerpt, }, - activeLens: activeLens || undefined, description: `Addressed provocation: ${context.provocation.title}`, }); @@ -528,25 +548,22 @@ export default function Workspace() { writeMutation.mutate({ instruction: transcript, selectedText: context.selectedText, - activeLens: activeLens || undefined, description: "Voice edit on selection", }); } else { // Sending as general document instruction writeMutation.mutate({ instruction: transcript, - activeLens: activeLens || undefined, description: "Voice instruction", }); } - }, [document, pendingVoiceContext, writeMutation, activeLens]); + }, [document, pendingVoiceContext, writeMutation]); // Handle cleaned transcript from TranscriptOverlay const handleCleanTranscript = useCallback((cleaned: string) => { setCleanedTranscript(cleaned); }, []); - const activeLensSummary = lenses?.find((l) => l.type === activeLens)?.summary; const hasOutlineContent = outline?.some((item) => item.content) ?? false; const canShowDiff = versions.length >= 2; const previousVersion = versions.length >= 2 ? versions[versions.length - 2] : null; @@ -764,18 +781,7 @@ export default function Workspace() {
- - - - - - - + {showDiffView && previousVersion && currentVersion ? ( @@ -793,8 +799,6 @@ export default function Workspace() { ) : ( - +
diff --git a/server/routes.ts b/server/routes.ts index ea63bbe8..c75544a4 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -5,6 +5,7 @@ import OpenAI from "openai"; import { analyzeTextRequestSchema, writeRequestSchema, + generateProvocationsRequestSchema, lensTypes, provocationType, instructionTypes, @@ -478,6 +479,99 @@ Output only valid JSON, no markdown.` } }); + // Generate new provocations for an existing document + app.post("/api/generate-provocations", async (req, res) => { + try { + const parsed = generateProvocationsRequestSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: "Invalid request", details: parsed.error.errors }); + } + + const { text, guidance, referenceDocuments } = parsed.data; + + const MAX_ANALYSIS_LENGTH = 8000; + const analysisText = text.slice(0, MAX_ANALYSIS_LENGTH); + + const refDocSummary = referenceDocuments && referenceDocuments.length > 0 + ? referenceDocuments.map(d => `[${d.type.toUpperCase()}: ${d.name}]\n${d.content.slice(0, 500)}${d.content.length > 500 ? "..." : ""}`).join("\n\n") + : null; + + const refContext = refDocSummary + ? `\n\nReference documents:\n${refDocSummary}\n\nCompare against these for gaps.` + : ""; + + const guidanceContext = guidance + ? `\n\nUSER GUIDANCE: The user specifically wants provocations about: ${guidance}` + : ""; + + const provDescriptions = provocationType.map(t => `- ${t}: ${provocationPrompts[t]}`).join("\n"); + + const response = await openai.chat.completions.create({ + model: "gpt-5.2", + max_completion_tokens: 4096, + messages: [ + { + role: "system", + content: `You are a critical thinking partner. Challenge assumptions and push thinking deeper. + +Generate provocations in these categories: +${provDescriptions} +${refContext}${guidanceContext} + +Respond with a JSON object containing a "provocations" array. Generate 2-3 provocations per category (6-9 total). +For each provocation: +- type: The category (opportunity, fallacy, or alternative) +- title: A punchy headline (max 60 chars) +- content: A 2-3 sentence explanation +- sourceExcerpt: A relevant quote from the source text (max 150 chars) + +Output only valid JSON, no markdown.` + }, + { + role: "user", + content: `Generate provocations for this text:\n\n${analysisText}` + } + ], + response_format: { type: "json_object" }, + }); + + const content = response.choices[0]?.message?.content || "{}"; + let parsedResponse: Record = {}; + try { + parsedResponse = JSON.parse(content); + } catch { + console.error("Failed to parse provocations JSON:", content); + return res.json({ provocations: [] }); + } + + const provocationsArray = Array.isArray(parsedResponse.provocations) + ? parsedResponse.provocations + : []; + + const provocations = provocationsArray.map((p: unknown, idx: number): Provocation => { + const item = p as Record; + const provType = provocationType.includes(item?.type as ProvocationType) + ? item.type as ProvocationType + : provocationType[idx % 3]; + + return { + id: `${provType}-${Date.now()}-${idx}`, + type: provType, + title: typeof item?.title === 'string' ? item.title : "Untitled Provocation", + content: typeof item?.content === 'string' ? item.content : "", + sourceExcerpt: typeof item?.sourceExcerpt === 'string' ? item.sourceExcerpt : "", + status: "pending", + }; + }); + + res.json({ provocations }); + } catch (error) { + console.error("Generate provocations error:", error); + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ error: "Failed to generate provocations", details: errorMessage }); + } + }); + // Unified write endpoint - single interface to the AI writer app.post("/api/write", async (req, res) => { try { diff --git a/shared/schema.ts b/shared/schema.ts index 7717286f..35908d63 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -92,6 +92,15 @@ export const analyzeTextRequestSchema = z.object({ export type AnalyzeTextRequest = z.infer; +// Generate provocations request (for regeneration with optional guidance) +export const generateProvocationsRequestSchema = z.object({ + text: z.string().min(1, "Text is required"), + guidance: z.string().optional(), + referenceDocuments: z.array(referenceDocumentSchema).optional(), +}); + +export type GenerateProvocationsRequest = z.infer; + // Unified write request - single interface to the AI writer export const provocationContextSchema = z.object({ type: z.enum(provocationType), From 5e596f7f63f044d7ba0f04641eb3b33859d9aebd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Feb 2026 07:57:56 +0000 Subject: [PATCH 005/812] Add template generation and interview flow features - Add /api/generate-template endpoint to create document templates from objectives - Add /api/interview/question endpoint for AI-guided interview questions - Add /api/interview/summary endpoint to synthesize interview Q&A into writer instructions - Add "Generate Document Template" button in TextInputForm references section - Create InterviewPanel component with voice/text answer input - Wire interview state, mutations, and handlers into Workspace - Interview tab shows between Provocations and Outline with active indicator https://claude.ai/code/session_01Uas2DK36GQoYEB4TfGGsAx --- client/src/components/InterviewPanel.tsx | 217 +++++++++++++++++++++++ client/src/components/TextInputForm.tsx | 51 ++++++ client/src/pages/Workspace.tsx | 160 ++++++++++++++++- server/routes.ts | 199 ++++++++++++++++++++- shared/schema.ts | 45 +++++ 5 files changed, 668 insertions(+), 4 deletions(-) create mode 100644 client/src/components/InterviewPanel.tsx diff --git a/client/src/components/InterviewPanel.tsx b/client/src/components/InterviewPanel.tsx new file mode 100644 index 00000000..2ff33ad9 --- /dev/null +++ b/client/src/components/InterviewPanel.tsx @@ -0,0 +1,217 @@ +import { useState } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { AutoExpandTextarea } from "@/components/ui/auto-expand-textarea"; +import { VoiceRecorder } from "./VoiceRecorder"; +import { + MessageCircleQuestion, + Play, + Square, + Send, + Loader2, + CheckCircle2, + Mic, +} from "lucide-react"; +import type { InterviewEntry } from "@shared/schema"; + +interface InterviewPanelProps { + isActive: boolean; + entries: InterviewEntry[]; + currentQuestion: string | null; + currentTopic: string | null; + isLoadingQuestion: boolean; + isMerging: boolean; + onStart: () => void; + onAnswer: (answer: string) => void; + onEnd: () => void; +} + +export function InterviewPanel({ + isActive, + entries, + currentQuestion, + currentTopic, + isLoadingQuestion, + isMerging, + onStart, + onAnswer, + onEnd, +}: InterviewPanelProps) { + const [answerText, setAnswerText] = useState(""); + const [isRecordingAnswer, setIsRecordingAnswer] = useState(false); + const [answerInterim, setAnswerInterim] = useState(""); + + const handleSubmitAnswer = () => { + if (answerText.trim()) { + onAnswer(answerText.trim()); + setAnswerText(""); + } + }; + + const handleVoiceAnswer = (transcript: string) => { + if (transcript.trim()) { + onAnswer(transcript.trim()); + setAnswerText(""); + setAnswerInterim(""); + } + }; + + // Not active - show start button + if (!isActive) { + return ( +
+
+ +

Interview Mode

+

+ Start an AI-guided interview that will ask you provocative questions to develop your document. + Your answers will be merged into the document when you end the session. +

+ {entries.length > 0 && ( +

+ Previous session: {entries.length} questions answered +

+ )} + +
+
+ ); + } + + return ( +
+ {/* Header */} +
+ +

Interview

+ + {entries.length} answered + + +
+ + {/* Q&A History */} + +
+ {entries.map((entry) => ( + + + + {entry.topic} + + + + +

{entry.question}

+

+ {entry.answer} +

+
+
+ ))} + + {/* Current question */} + {isLoadingQuestion && ( + + + + Thinking of the next question... + + + )} + + {currentQuestion && !isLoadingQuestion && ( + + + + {currentTopic && {currentTopic}} + + + +

{currentQuestion}

+ + {/* Answer input */} +
+
+ setAnswerText(e.target.value)} + readOnly={isRecordingAnswer} + className={`text-sm pr-20 ${isRecordingAnswer ? "border-primary" : ""}`} + minRows={2} + maxRows={8} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSubmitAnswer(); + } + }} + /> +
+ + +
+
+ {isRecordingAnswer && ( +
+ + Listening... speak your answer +
+ )} +
+
+
+ )} +
+
+
+ ); +} diff --git a/client/src/components/TextInputForm.tsx b/client/src/components/TextInputForm.tsx index 6e5b6845..60f7d59b 100644 --- a/client/src/components/TextInputForm.tsx +++ b/client/src/components/TextInputForm.tsx @@ -36,6 +36,9 @@ export function TextInputForm({ onSubmit, onBlankDocument, isLoading }: TextInpu const [isRecordingText, setIsRecordingText] = useState(false); const [textInterim, setTextInterim] = useState(""); + // Template generation + const [isGeneratingTemplate, setIsGeneratingTemplate] = useState(false); + // Raw transcript storage for "show original" const [objectiveRawTranscript, setObjectiveRawTranscript] = useState(null); const [textRawTranscript, setTextRawTranscript] = useState(null); @@ -163,6 +166,31 @@ export function TextInputForm({ onSubmit, onBlankDocument, isLoading }: TextInpu } }; + const handleGenerateTemplate = async () => { + if (!objective.trim()) return; + setIsGeneratingTemplate(true); + try { + const response = await apiRequest("POST", "/api/generate-template", { + objective: objective.trim(), + }); + const data = await response.json(); + if (data.template) { + const templateDoc: ReferenceDocument = { + id: generateId("ref"), + name: data.name || `Template: ${objective.slice(0, 40)}`, + content: data.template, + type: "template", + }; + setReferenceDocuments((prev) => [...prev, templateDoc]); + setIsReferencesOpen(true); + } + } catch (error) { + console.error("Failed to generate template:", error); + } finally { + setIsGeneratingTemplate(false); + } + }; + return (
@@ -300,6 +328,29 @@ export function TextInputForm({ onSubmit, onBlankDocument, isLoading }: TextInpu + {/* Generate template from objective */} + {objective.trim() && !referenceDocuments.some(d => d.type === "template") && ( + + )} + {/* Existing references */} {referenceDocuments.length > 0 && (
diff --git a/client/src/pages/Workspace.tsx b/client/src/pages/Workspace.tsx index 9a1b9ea1..22cfbeba 100644 --- a/client/src/pages/Workspace.tsx +++ b/client/src/pages/Workspace.tsx @@ -5,6 +5,7 @@ import { apiRequest } from "@/lib/queryClient"; import { generateId } from "@/lib/utils"; import { TextInputForm } from "@/components/TextInputForm"; import { ProvocationsDisplay } from "@/components/ProvocationsDisplay"; +import { InterviewPanel } from "@/components/InterviewPanel"; import { OutlineBuilder } from "@/components/OutlineBuilder"; import { ReadingPane } from "@/components/ReadingPane"; import { DimensionsToolbar } from "@/components/DimensionsToolbar"; @@ -23,6 +24,7 @@ import { Sparkles, RotateCcw, MessageSquareWarning, + MessageCircleQuestion, ListTree, Settings2, GitCompare, @@ -42,7 +44,9 @@ import type { WriteRequest, WriteResponse, ReferenceDocument, - EditHistoryEntry + EditHistoryEntry, + InterviewEntry, + InterviewQuestionResponse } from "@shared/schema"; type AppPhase = "input" | "blank-document" | "workspace"; @@ -93,6 +97,12 @@ export default function Workspace() { // Suggestions from last write response const [lastSuggestions, setLastSuggestions] = useState([]); + // Interview state + const [isInterviewActive, setIsInterviewActive] = useState(false); + const [interviewEntries, setInterviewEntries] = useState([]); + const [currentInterviewQuestion, setCurrentInterviewQuestion] = useState(null); + const [currentInterviewTopic, setCurrentInterviewTopic] = useState(null); + // Voice input for objective (no writer call, direct update) const [isRecordingObjective, setIsRecordingObjective] = useState(false); const [objectiveInterimTranscript, setObjectiveInterimTranscript] = useState(""); @@ -295,6 +305,92 @@ export default function Workspace() { }, }); + const interviewQuestionMutation = useMutation({ + mutationFn: async ({ overrideEntries }: { overrideEntries?: InterviewEntry[] } = {}) => { + if (!document) throw new Error("No document"); + const templateDoc = referenceDocuments.find(d => d.type === "template"); + const entries = overrideEntries ?? interviewEntries; + const response = await apiRequest("POST", "/api/interview/question", { + objective, + document: document.rawText, + template: templateDoc?.content, + previousEntries: entries.length > 0 ? entries : undefined, + provocations: provocations.length > 0 ? provocations : undefined, + }); + return await response.json() as InterviewQuestionResponse; + }, + onSuccess: (data) => { + setCurrentInterviewQuestion(data.question); + setCurrentInterviewTopic(data.topic); + }, + onError: (error) => { + toast({ + title: "Interview Error", + description: error instanceof Error ? error.message : "Failed to generate question", + variant: "destructive", + }); + }, + }); + + const interviewSummaryMutation = useMutation({ + mutationFn: async () => { + if (!document || interviewEntries.length === 0) throw new Error("No entries to merge"); + // Step 1: Get summarized instruction from interview entries + const summaryResponse = await apiRequest("POST", "/api/interview/summary", { + objective, + entries: interviewEntries, + document: document.rawText, + }); + const { instruction } = await summaryResponse.json() as { instruction: string }; + + // Step 2: Use the writer to merge into document + const writeResponse = await apiRequest("POST", "/api/write", { + document: document.rawText, + objective, + instruction, + referenceDocuments: referenceDocuments.length > 0 ? referenceDocuments : undefined, + editHistory: editHistory.length > 0 ? editHistory : undefined, + }); + return await writeResponse.json() as WriteResponse; + }, + onSuccess: (data) => { + if (document) { + const newVersion: DocumentVersion = { + id: generateId("v"), + text: data.document, + timestamp: Date.now(), + description: `Interview merge (${interviewEntries.length} answers)`, + }; + setVersions(prev => [...prev, newVersion]); + setDocument({ ...document, rawText: data.document }); + + const historyEntry: EditHistoryEntry = { + instruction: `Interview session with ${interviewEntries.length} Q&A pairs`, + instructionType: data.instructionType || "general", + summary: data.summary || "Interview responses merged", + timestamp: Date.now(), + }; + setEditHistory(prev => [...prev.slice(-9), historyEntry]); + + setIsInterviewActive(false); + setCurrentInterviewQuestion(null); + setCurrentInterviewTopic(null); + + toast({ + title: "Interview Merged", + description: `${interviewEntries.length} answers integrated into your document.`, + }); + } + }, + onError: (error) => { + toast({ + title: "Merge Failed", + description: error instanceof Error ? error.message : "Something went wrong", + variant: "destructive", + }); + }, + }); + const handleAnalyze = useCallback((text: string, docObjective?: string, refs?: ReferenceDocument[]) => { if (docObjective) { setObjective(docObjective); @@ -405,6 +501,35 @@ export default function Workspace() { regenerateProvocationsMutation.mutate({ guidance }); }, [regenerateProvocationsMutation]); + const handleStartInterview = useCallback(() => { + setIsInterviewActive(true); + setActiveTab("interview"); + interviewQuestionMutation.mutate({}); + }, [interviewQuestionMutation]); + + const handleInterviewAnswer = useCallback((answer: string) => { + if (!currentInterviewQuestion || !currentInterviewTopic) return; + + const entry: InterviewEntry = { + id: generateId("iq"), + question: currentInterviewQuestion, + answer, + topic: currentInterviewTopic, + timestamp: Date.now(), + }; + const updatedEntries = [...interviewEntries, entry]; + setInterviewEntries(updatedEntries); + setCurrentInterviewQuestion(null); + setCurrentInterviewTopic(null); + + // Fetch next question, passing updated entries directly + interviewQuestionMutation.mutate({ overrideEntries: updatedEntries }); + }, [currentInterviewQuestion, currentInterviewTopic, interviewEntries, interviewQuestionMutation]); + + const handleEndInterview = useCallback(() => { + interviewSummaryMutation.mutate(); + }, [interviewSummaryMutation]); + const handleReset = useCallback(() => { setPhase("input"); setDocument(null); @@ -418,6 +543,10 @@ export default function Workspace() { setIsRecordingBlank(false); setEditHistory([]); setLastSuggestions([]); + setIsInterviewActive(false); + setInterviewEntries([]); + setCurrentInterviewQuestion(null); + setCurrentInterviewTopic(null); }, []); const handleDocumentTextChange = useCallback((newText: string) => { @@ -840,8 +969,19 @@ export default function Workspace() { )} - + + Interview + {isInterviewActive && ( + + )} + + @@ -877,6 +1017,20 @@ export default function Workspace() { /> + + + + { + try { + const parsed = generateTemplateRequestSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: "Invalid request", details: parsed.error.errors }); + } + + const { objective } = parsed.data; + + const response = await openai.chat.completions.create({ + model: "gpt-5.2", + max_completion_tokens: 4096, + messages: [ + { + role: "system", + content: `You are an expert document strategist. Given a document objective, generate a comprehensive template that serves as a blueprint for the document the user needs to create. + +The template should: +1. Include all key sections/headings the document should have +2. Under each section, provide brief guidance on what content belongs there +3. Include placeholder prompts that help the user think about what to write +4. Be structured with markdown headings and bullet points +5. Cover completeness — include sections that are commonly forgotten + +The template is NOT the final document. It's a guide that helps the user know what to cover and in what order. + +Output only the template content in markdown format. No meta-commentary.` + }, + { + role: "user", + content: `Generate a comprehensive document template for this objective:\n\n${objective}` + } + ], + }); + + const template = response.choices[0]?.message?.content?.trim() || ""; + + res.json({ + template, + name: `Template: ${objective.slice(0, 50)}${objective.length > 50 ? "..." : ""}`, + }); + } catch (error) { + console.error("Generate template error:", error); + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ error: "Failed to generate template", details: errorMessage }); + } + }); + + // Generate next interview question based on context + app.post("/api/interview/question", async (req, res) => { + try { + const parsed = interviewQuestionRequestSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: "Invalid request", details: parsed.error.errors }); + } + + const { objective, document: docText, template, previousEntries, provocations } = parsed.data; + + // Build context from previous Q&A + const previousContext = previousEntries && previousEntries.length > 0 + ? previousEntries.map(e => `Topic: ${e.topic}\nQ: ${e.question}\nA: ${e.answer}`).join("\n\n") + : "No previous questions yet — this is the first question."; + + // Build template context + const templateContext = template + ? `\n\nDOCUMENT TEMPLATE (sections that need to be covered):\n${template.slice(0, 2000)}` + : ""; + + // Build document context + const documentContext = docText + ? `\n\nCURRENT DOCUMENT STATE:\n${docText.slice(0, 2000)}` + : ""; + + // Build provocations context + const pendingProvocations = provocations + ? provocations.filter(p => p.status === "pending") + : []; + const provocationsContext = pendingProvocations.length > 0 + ? `\n\nPENDING PROVOCATIONS (challenges not yet addressed):\n${pendingProvocations.map(p => `- [${p.type}] ${p.title}: ${p.content}`).join("\n")}` + : ""; + + const response = await openai.chat.completions.create({ + model: "gpt-5.2", + max_completion_tokens: 1024, + messages: [ + { + role: "system", + content: `You are a skilled interviewer helping a user develop their document by asking probing questions. Your goal is to extract the information, perspectives, and insights the user needs to include in their document. + +OBJECTIVE: ${objective} +${templateContext}${documentContext}${provocationsContext} + +PREVIOUS Q&A: +${previousContext} + +Your job: +1. Identify what's MISSING — gaps in content, unexplored angles, unaddressed template sections +2. Ask ONE focused, provocative question that will elicit useful content for the document +3. Make the question specific and actionable — the user should be able to answer it directly +4. Don't repeat topics already covered in previous Q&A +5. Prioritize questions that address pending provocations or uncovered template sections +6. If the template is mostly covered and provocations addressed, ask about nuance, examples, or edge cases + +Respond with a JSON object: +- question: The question to ask (conversational, direct, max 200 chars) +- topic: A short label for what this question covers (max 40 chars) +- reasoning: Brief internal reasoning for why this question matters (max 100 chars) + +Output only valid JSON, no markdown.` + }, + { + role: "user", + content: `Generate the next interview question to help me develop my document.` + } + ], + response_format: { type: "json_object" }, + }); + + const content = response.choices[0]?.message?.content || "{}"; + let result: InterviewQuestionResponse; + try { + const parsed = JSON.parse(content); + result = { + question: typeof parsed.question === 'string' ? parsed.question : "What's the most important thing you haven't covered yet?", + topic: typeof parsed.topic === 'string' ? parsed.topic : "General", + reasoning: typeof parsed.reasoning === 'string' ? parsed.reasoning : "", + }; + } catch { + result = { + question: "What's the most important thing you haven't covered yet?", + topic: "General", + reasoning: "Fallback question", + }; + } + + res.json(result); + } catch (error) { + console.error("Interview question error:", error); + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ error: "Failed to generate interview question", details: errorMessage }); + } + }); + + // Summarize interview entries into a coherent instruction for the writer + app.post("/api/interview/summary", async (req, res) => { + try { + const parsed = interviewSummaryRequestSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: "Invalid request", details: parsed.error.errors }); + } + + const { objective, entries, document: docText } = parsed.data; + + const qaText = entries.map(e => `Topic: ${e.topic}\nQ: ${e.question}\nA: ${e.answer}`).join("\n\n---\n\n"); + + const response = await openai.chat.completions.create({ + model: "gpt-5.2", + max_completion_tokens: 2048, + messages: [ + { + role: "system", + content: `You are an expert at synthesizing interview responses into clear editing instructions. + +Given a series of Q&A pairs from a document development interview, create a single comprehensive instruction that tells a document editor how to integrate all the information gathered. + +OBJECTIVE: ${objective} + +The instruction should: +1. Group related answers by theme +2. Specify where new content should be added or what should be modified +3. Include all key points from the user's answers +4. Be written as a clear directive to a document editor + +Output only the instruction text. No meta-commentary.` + }, + { + role: "user", + content: `${docText ? `Current document:\n${docText.slice(0, 2000)}\n\n---\n\n` : ""}Interview Q&A:\n\n${qaText}` + } + ], + }); + + const instruction = response.choices[0]?.message?.content?.trim() || ""; + + res.json({ instruction }); + } catch (error) { + console.error("Interview summary error:", error); + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ error: "Failed to summarize interview", details: errorMessage }); + } + }); + return httpServer; } diff --git a/shared/schema.ts b/shared/schema.ts index 35908d63..87f43806 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -187,6 +187,51 @@ export interface DocumentVersion { } // Workspace state for context provider +// Generate template request - creates a document template from an objective +export const generateTemplateRequestSchema = z.object({ + objective: z.string().min(1, "Objective is required"), +}); + +export type GenerateTemplateRequest = z.infer; + +// Interview entry - a single Q&A pair from the interview flow +export const interviewEntrySchema = z.object({ + id: z.string(), + question: z.string(), + answer: z.string(), + topic: z.string(), + timestamp: z.number(), +}); + +export type InterviewEntry = z.infer; + +// Interview question request - generates the next provocative question +export const interviewQuestionRequestSchema = z.object({ + objective: z.string().min(1, "Objective is required"), + document: z.string().optional(), + template: z.string().optional(), + previousEntries: z.array(interviewEntrySchema).optional(), + provocations: z.array(provocationSchema).optional(), +}); + +export type InterviewQuestionRequest = z.infer; + +// Interview question response +export interface InterviewQuestionResponse { + question: string; + topic: string; + reasoning: string; +} + +// Interview summary request - summarize all entries for merge +export const interviewSummaryRequestSchema = z.object({ + objective: z.string().min(1, "Objective is required"), + entries: z.array(interviewEntrySchema).min(1, "At least one entry is required"), + document: z.string().optional(), +}); + +export type InterviewSummaryRequest = z.infer; + export interface WorkspaceState { document: Document | null; objective: string; From 04a8de180381bd81d986570291d4a9f1e2c38691 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Feb 2026 18:55:46 +0000 Subject: [PATCH 006/812] Remove lenses, standardize text areas, and ensure consistent writer pipeline - Remove lenses feature entirely (schema, API, components, workspace state) - Add voice + text instruction input to OutlineBuilder sections - Route all voice input through TranscriptOverlay with transcript and summary - Move action buttons above generated content in ProvocationsDisplay and TranscriptOverlay - All edits (outline, selection, provocation, document) go through /api/write https://claude.ai/code/session_01WGFvVtBrx354qCuNAGp2vr --- client/src/components/LensesPanel.tsx | 148 ------------------ client/src/components/OutlineBuilder.tsx | 120 ++++++++++++-- client/src/components/ProvocationsDisplay.tsx | 55 +++---- client/src/components/TranscriptOverlay.tsx | 140 ++++++++--------- client/src/lib/workspace-context.tsx | 16 +- client/src/pages/Workspace.tsx | 48 +++++- server/routes.ts | 123 +-------------- shared/schema.ts | 27 ---- 8 files changed, 253 insertions(+), 424 deletions(-) delete mode 100644 client/src/components/LensesPanel.tsx diff --git a/client/src/components/LensesPanel.tsx b/client/src/components/LensesPanel.tsx deleted file mode 100644 index 663cecf1..00000000 --- a/client/src/components/LensesPanel.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Skeleton } from "@/components/ui/skeleton"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Eye, Users, Briefcase, Code, DollarSign, Target, AlertTriangle, Check } from "lucide-react"; -import type { Lens, LensType } from "@shared/schema"; - -const lensIcons: Record = { - consumer: Users, - executive: Briefcase, - technical: Code, - financial: DollarSign, - strategic: Target, - skeptic: AlertTriangle, -}; - -const lensLabels: Record = { - consumer: "Consumer's Lens", - executive: "Executive's Lens", - technical: "Technical Lens", - financial: "Financial Lens", - strategic: "Strategic Lens", - skeptic: "Skeptic's Lens", -}; - -const lensDescriptions: Record = { - consumer: "How customers and end-users perceive this", - executive: "Leadership and decision-making perspective", - technical: "Implementation and technical feasibility", - financial: "Cost, revenue, and ROI considerations", - strategic: "Long-term positioning and competitive advantage", - skeptic: "Critical analysis and potential weaknesses", -}; - -interface LensesPanelProps { - lenses: Lens[]; - activeLens: LensType | null; - onSelectLens: (lens: LensType | null) => void; - isLoading?: boolean; -} - -export function LensesPanel({ lenses, activeLens, onSelectLens, isLoading }: LensesPanelProps) { - if (isLoading) { - return ( -
-
- -

Analyzing Lenses...

-
- {[1, 2, 3].map((i) => ( - - - - - - ))} -
- ); - } - - const availableLensTypes: LensType[] = ["consumer", "executive", "technical", "financial", "strategic", "skeptic"]; - - return ( -
-
- -

Lenses

- - {(lenses ?? []).length} analyzed - -
- - -
-

- Select a lens to view the source through a specific perspective. -

- - {availableLensTypes.map((lensType) => { - const lens = (lenses ?? []).find((l) => l.type === lensType); - const Icon = lensIcons[lensType]; - const isActive = activeLens === lensType; - const isAvailable = !!lens; - - return ( - isAvailable && onSelectLens(isActive ? null : lensType)} - > - - -
- -
- {lensLabels[lensType]} - {isActive && } -
-
- - {lens ? ( -
-

- {lens.summary} -

- {lens.keyPoints.length > 0 && ( -
- {lens.keyPoints.slice(0, 3).map((point, i) => ( - - {point.length > 25 ? point.slice(0, 25) + "..." : point} - - ))} -
- )} -
- ) : ( -

- {lensDescriptions[lensType]} -

- )} -
-
- ); - })} - - {activeLens && ( - - )} -
-
-
- ); -} diff --git a/client/src/components/OutlineBuilder.tsx b/client/src/components/OutlineBuilder.tsx index a3127848..871985af 100644 --- a/client/src/components/OutlineBuilder.tsx +++ b/client/src/components/OutlineBuilder.tsx @@ -6,15 +6,17 @@ import { Textarea } from "@/components/ui/textarea"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; -import { - ListTree, - Plus, - GripVertical, - ChevronDown, +import { VoiceRecorder } from "./VoiceRecorder"; +import { + ListTree, + Plus, + GripVertical, + ChevronDown, ChevronRight, Trash2, Wand2, - Loader2 + Loader2, + Send } from "lucide-react"; import type { OutlineItem, ToneOption } from "@shared/schema"; @@ -25,6 +27,9 @@ interface OutlineBuilderProps { onRemoveItem: (id: string) => void; onReorder: (items: OutlineItem[]) => void; onExpandHeading: (id: string, heading: string, tone?: ToneOption) => Promise; + onVoiceInput?: (sectionId: string, heading: string, transcript: string) => void; + onTranscriptUpdate?: (transcript: string, isRecording: boolean) => void; + onTextInstruction?: (sectionId: string, heading: string, instruction: string, currentContent: string) => void; isLoading?: boolean; } @@ -33,16 +38,24 @@ function OutlineItemCard({ onUpdate, onRemove, onExpand, + onVoiceInput, + onTranscriptUpdate, + onTextInstruction, isExpanding, }: { item: OutlineItem; onUpdate: (updates: Partial) => void; onRemove: () => void; onExpand: () => void; + onVoiceInput?: (transcript: string) => void; + onTranscriptUpdate?: (transcript: string, isRecording: boolean) => void; + onTextInstruction?: (instruction: string) => void; isExpanding: boolean; }) { const [isEditing, setIsEditing] = useState(false); const [editValue, setEditValue] = useState(item.heading); + const [showInstruction, setShowInstruction] = useState(false); + const [instructionText, setInstructionText] = useState(""); const handleSaveHeading = () => { if (editValue.trim()) { @@ -51,8 +64,16 @@ function OutlineItemCard({ setIsEditing(false); }; + const handleSubmitInstruction = () => { + if (instructionText.trim() && onTextInstruction) { + onTextInstruction(instructionText.trim()); + setInstructionText(""); + setShowInstruction(false); + } + }; + return ( - @@ -61,7 +82,7 @@ function OutlineItemCard({
- + - + {isEditing ? ( ) : ( - setIsEditing(true)} data-testid={`text-heading-${item.id}`} @@ -93,7 +114,7 @@ function OutlineItemCard({ {item.heading} )} - +
+
+ ) : ( + + )} +
+