From 436e427cb3af4ac438eb4ca74293055cfffe7cff Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 18:21:45 +0000 Subject: [PATCH 1/2] feat: bulk-add messages from a pasted dialogue script Roadmap Phase 2, item #14. - New parseScript utility: one 'Name: message' per line; Me/You/I (any case) map to sent messages, other speakers to the contact; speakerless lines continue the previous message; timestamps advance one minute per message from a start time, wrapping past midnight. - Bulk Add card in the control panel: script textarea, 'Add to chat' button, and a 'Replace existing' checkbox guarded by a confirm. - Unit tests for the parser (7) and UI tests for append, replace, and empty-script no-op. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SN53MtH3hvbHr7syhwu54q --- src/App.test.tsx | 36 +++++++++++++++++ src/App.tsx | 11 +++++ src/components/ControlPanel.tsx | 57 ++++++++++++++++++++++++++ src/script-parser.test.ts | 46 +++++++++++++++++++++ src/script-parser.ts | 71 +++++++++++++++++++++++++++++++++ 5 files changed, 221 insertions(+) create mode 100644 src/script-parser.test.ts create mode 100644 src/script-parser.ts diff --git a/src/App.test.tsx b/src/App.test.tsx index 153487d..92bc27b 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -397,6 +397,42 @@ describe("autosave", () => { }) }) +describe("bulk add", () => { + it("builds a thread from a pasted script", () => { + render() + fireEvent.change(screen.getByLabelText("Dialogue script"), { + target: { value: "Me: Bulk one\nSarah: Bulk two" }, + }) + fireEvent.click(screen.getByRole("button", { name: "Add to chat" })) + + expect(screen.getAllByText("Bulk one").length).toBeGreaterThan(0) + expect(screen.getAllByText("Bulk two").length).toBeGreaterThan(0) + // The textarea clears after a successful add. + expect(screen.getByLabelText("Dialogue script")).toHaveValue("") + }) + + it("replace mode swaps out the existing conversation", () => { + const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true) + render() + fireEvent.click(screen.getByRole("checkbox", { name: /replace existing/i })) + fireEvent.change(screen.getByLabelText("Dialogue script"), { + target: { value: "Me: Fresh start" }, + }) + fireEvent.click(screen.getByRole("button", { name: "Add to chat" })) + + expect(screen.getAllByText("Fresh start").length).toBeGreaterThan(0) + expect(screen.queryByText(/Hey! How are you/)).not.toBeInTheDocument() + confirmSpy.mockRestore() + }) + + it("does nothing for an empty script", () => { + render() + const before = screen.getAllByTitle("Delete").length + fireEvent.click(screen.getByRole("button", { name: "Add to chat" })) + expect(screen.getAllByTitle("Delete")).toHaveLength(before) + }) +}) + describe("responsible use", () => { it("shows the watermark by default and allows turning it off", () => { render() diff --git a/src/App.tsx b/src/App.tsx index 8c35e08..66043e8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -144,6 +144,16 @@ function App() { setConfig(INITIAL_CONFIG) }, []) + const handleBulkAdd = useCallback( + (msgs: Omit[], replace: boolean) => { + setMessages((prev) => { + const withIds = msgs.map((m) => ({ ...m, id: crypto.randomUUID() })) + return replace ? withIds : [...prev, ...withIds] + }) + }, + [], + ) + const handleAddMessage = useCallback( (msg: Omit) => { setMessages((prev) => [...prev, { ...msg, id: crypto.randomUUID() }]) @@ -262,6 +272,7 @@ function App() { onUpdateConfig={setConfig} onExport={handleExport} onReset={handleReset} + onBulkAdd={handleBulkAdd} /> diff --git a/src/components/ControlPanel.tsx b/src/components/ControlPanel.tsx index c5b77d6..c5db31f 100644 --- a/src/components/ControlPanel.tsx +++ b/src/components/ControlPanel.tsx @@ -9,6 +9,7 @@ import type { ExportFormat, ExportOptions, } from "../types" +import { parseScript } from "../script-parser" type ControlPanelProps = { messages: Message[] @@ -20,6 +21,7 @@ type ControlPanelProps = { onUpdateConfig: (config: ChatConfig) => void onExport: (options: ExportOptions) => void onReset: () => void + onBulkAdd: (msgs: Omit[], replace: boolean) => void } export function ControlPanel({ @@ -32,6 +34,7 @@ export function ControlPanel({ onUpdateConfig, onExport, onReset, + onBulkAdd, }: ControlPanelProps) { const [text, setText] = useState("") const [sender, setSender] = useState("them") @@ -45,10 +48,26 @@ export function ControlPanel({ const [editingId, setEditingId] = useState(null) const [exportFormat, setExportFormat] = useState("png") const [exportScale, setExportScale] = useState(2) + const [script, setScript] = useState("") + const [replaceOnBulk, setReplaceOnBulk] = useState(false) const fileInputRef = useRef(null) const isSystem = sender === "system" + const handleBulkAdd = () => { + const parsed = parseScript(script) + if (parsed.length === 0) return + if ( + replaceOnBulk && + messages.length > 0 && + !confirm(`Replace the existing ${messages.length} messages?`) + ) { + return + } + onBulkAdd(parsed, replaceOnBulk) + setScript("") + } + const clearImage = () => { setImage("") if (fileInputRef.current) fileInputRef.current.value = "" @@ -566,6 +585,44 @@ export function ControlPanel({ + {/* Bulk add from a script */} +
+

+ Bulk Add +

+