From 0d3287fe347fcba26719f2cd52d0381cbde29cb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Pavl=C3=AD=C4=8Dek?= Date: Thu, 19 Mar 2026 16:33:12 +0100 Subject: [PATCH 1/4] Fix: Middle of sentence changes --- src/components/dialogue/DialogueRowsPanel.jsx | 8 +++ .../$projectId/dialogue/$dialogueId/index.jsx | 53 +++++++++---------- 2 files changed, 32 insertions(+), 29 deletions(-) diff --git a/src/components/dialogue/DialogueRowsPanel.jsx b/src/components/dialogue/DialogueRowsPanel.jsx index 4116e7e..5df90a2 100644 --- a/src/components/dialogue/DialogueRowsPanel.jsx +++ b/src/components/dialogue/DialogueRowsPanel.jsx @@ -177,6 +177,14 @@ export function DialogueRowsPanel({ }; const updateRow = (index, updates) => { + const targetRow = dialogueRows[index]; + if (!targetRow) return; + + const hasValueChange = Object.entries(updates || {}).some( + ([key, value]) => targetRow?.[key] !== value + ); + if (!hasValueChange) return; + const newRows = dialogueRows.map((row, i) => i === index ? { ...row, ...updates } : row ); diff --git a/src/routes/projects/$projectId/dialogue/$dialogueId/index.jsx b/src/routes/projects/$projectId/dialogue/$dialogueId/index.jsx index 31a59ad..e7c16b4 100644 --- a/src/routes/projects/$projectId/dialogue/$dialogueId/index.jsx +++ b/src/routes/projects/$projectId/dialogue/$dialogueId/index.jsx @@ -401,7 +401,11 @@ function DialogueEditorPage() { }); onNodesChangeBase(filteredChanges); }, [onNodesChangeBase]); - const [selectedNode, setSelectedNode] = useState(null); + const [selectedNodeId, setSelectedNodeId] = useState(null); + const selectedNode = useMemo( + () => nodes.find((node) => node.id === selectedNodeId) || null, + [nodes, selectedNodeId] + ); const [selectedEdge, setSelectedEdge] = useState(null); const [isSaving, setIsSaving] = useState(false); const [saveStatus, setSaveStatus] = useState('saved'); @@ -681,16 +685,6 @@ function DialogueEditorPage() { setShowDesktopGraphLoader(true); }, [deviceType, hasGraphInitialized, hasLoadedViewport, hasInitialFocus]); - // Sync selected node with nodes state to reflect updates - useEffect(() => { - if (selectedNode) { - const updatedNode = nodes.find((n) => n.id === selectedNode.id); - if (updatedNode) { - setSelectedNode(updatedNode); - } - } - }, [nodes]); - // Sync selected edge with edges state to reflect updates useEffect(() => { if (selectedEdge) { @@ -797,20 +791,20 @@ function DialogueEditorPage() { ); // Save to history when selected node changes (user clicked away after editing) - const prevSelectedNodeRef = useRef(selectedNode); + const prevSelectedNodeRef = useRef(selectedNodeId); useEffect(() => { - const prevNode = prevSelectedNodeRef.current; + const prevNodeId = prevSelectedNodeRef.current; if ( - prevNode && - prevNode !== selectedNode && + prevNodeId && + prevNodeId !== selectedNodeId && hasPendingNodeDataEditsRef.current ) { // User left an edited node: create one undo checkpoint. saveToHistory(nodes, edges); hasPendingNodeDataEditsRef.current = false; } - prevSelectedNodeRef.current = selectedNode; - }, [selectedNode, nodes, edges, saveToHistory]); + prevSelectedNodeRef.current = selectedNodeId; + }, [selectedNodeId, nodes, edges, saveToHistory]); // Warn before leaving with unsaved changes const shouldWarnOnLeave = hasUnsavedChanges && saveStatus !== 'saved'; @@ -1115,7 +1109,7 @@ function DialogueEditorPage() { (edgeId) => { const edge = edges.find((candidate) => candidate.id === edgeId); if (!edge) return; - setSelectedNode(null); + setSelectedNodeId(null); setSelectedEdge(edge); // On desktop, the drawer opens automatically via its open condition. // On mobile/tablet, the edge action bar shows first; panel opens only when the user @@ -1204,7 +1198,7 @@ function DialogueEditorPage() { // Suppress click if it follows a context-menu action (Radix focus-restoration artifact) if (Date.now() - lastContextMenuActionRef.current < 300) return; - setSelectedNode(node); + setSelectedNodeId(node.id); setSelectedEdge(null); // Deselect edge when node is selected }, [lastContextMenuActionRef]); @@ -1213,13 +1207,13 @@ function DialogueEditorPage() { // Suppress click if it follows a context-menu action (Radix focus-restoration artifact) if (Date.now() - lastContextMenuActionRef.current < 300) return; setSelectedEdge(edge); - setSelectedNode(null); // Deselect node when edge is selected + setSelectedNodeId(null); // Deselect node when edge is selected // On mobile/tablet, the edge action bar handles the panel open — don't auto-open here }, [lastContextMenuActionRef]); // Handle pane click (deselect all) const onPaneClick = useCallback(() => { - setSelectedNode(null); + setSelectedNodeId(null); setSelectedEdge(null); }, []); @@ -1255,7 +1249,7 @@ function DialogueEditorPage() { return updatedNodes; }); - setSelectedNode((current) => (current?.id === nodeId ? null : current)); + setSelectedNodeId((current) => (current === nodeId ? null : current)); }, [setNodes, setEdges, saveToHistory, markUnsaved]); const deleteEdgeById = useCallback((edgeId) => { @@ -1319,7 +1313,7 @@ function DialogueEditorPage() { return updatedNodes; }); - setSelectedNode(null); + setSelectedNodeId(null); }, [selectedNode, edges, setNodes, setEdges, saveToHistory, markUnsaved]); // Checks if edge is the only incoming connection to its target, then either @@ -1465,7 +1459,7 @@ function DialogueEditorPage() { action: { label: t('editor.validation.focusNode'), onClick: () => { - setSelectedNode(node); + setSelectedNodeId(node.id); setSelectedEdge(null); if (deviceType !== 'desktop') { setIsMobilePanelOpen(true); @@ -1533,7 +1527,7 @@ function DialogueEditorPage() { if (missingRequiredNodes.size > 0) { const firstMissingNode = missingRequiredNodes.values().next().value?.node; if (firstMissingNode) { - setSelectedNode(firstMissingNode); + setSelectedNodeId(firstMissingNode.id); setSelectedEdge(null); setIsMobilePanelOpen(false); } @@ -1573,7 +1567,7 @@ function DialogueEditorPage() { return; } - setSelectedNode(null); + setSelectedNodeId(null); setSelectedEdge(null); setIsMobilePanelOpen(false); setPreviewActiveNodeRef(null); @@ -2848,7 +2842,7 @@ function DialogueEditorPage() { variant="ghost" size="icon" className="h-8 w-8 rounded-full" - onClick={() => setSelectedNode(null)} + onClick={() => setSelectedNodeId(null)} > @@ -2965,7 +2959,7 @@ function DialogueEditorPage() { if (deviceType !== 'desktop') { setIsMobilePanelOpen(false); } else { - setSelectedNode(null); + setSelectedNodeId(null); setSelectedEdge(null); } }} @@ -2996,7 +2990,7 @@ function DialogueEditorPage() { if (deviceType !== 'desktop') { setIsMobilePanelOpen(false); } else { - setSelectedNode(null); + setSelectedNodeId(null); } }} className="h-8 w-8" @@ -3283,3 +3277,4 @@ function DialogueEditorPage() { ); } + From ecf705c9bfe659070d086dffc10a4a319e4974fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Pavl=C3=AD=C4=8Dek?= Date: Thu, 19 Mar 2026 16:37:52 +0100 Subject: [PATCH 2/4] Fix: Middle of sentence changes (participants) --- src/components/dialogue/DialogueRowsPanel.jsx | 53 +++++++------------ 1 file changed, 19 insertions(+), 34 deletions(-) diff --git a/src/components/dialogue/DialogueRowsPanel.jsx b/src/components/dialogue/DialogueRowsPanel.jsx index 5df90a2..6e244c7 100644 --- a/src/components/dialogue/DialogueRowsPanel.jsx +++ b/src/components/dialogue/DialogueRowsPanel.jsx @@ -194,7 +194,6 @@ export function DialogueRowsPanel({ // Auto-complete state for dynamic text const [activeRowIndex, setActiveRowIndex] = useState(null); const [autocompleteVisible, setAutocompleteVisible] = useState(false); - const [autocompletePosition, setAutocompletePosition] = useState({ top: 0, left: 0 }); const [autocompleteFilter, setAutocompleteFilter] = useState(''); const textareaRefs = useRef({}); @@ -215,14 +214,6 @@ export function DialogueRowsPanel({ setActiveRowIndex(index); setAutocompleteFilter(filterText.toLowerCase()); setAutocompleteVisible(true); - - // Position autocomplete near cursor - const textarea = event.target; - const rect = textarea.getBoundingClientRect(); - setAutocompletePosition({ - top: rect.top - 150, - left: rect.left + 10, - }); } else { setAutocompleteVisible(false); } @@ -463,7 +454,7 @@ export function DialogueRowsPanel({ {isExpanded && (
{/* Dialogue Text */} -
+
{/* Audio File */} @@ -585,30 +594,6 @@ export function DialogueRowsPanel({ )} - {/* Autocomplete Dropdown */} - {autocompleteVisible && filteredParticipants.length > 0 && ( -
- {filteredParticipants.map((participant) => ( - - ))} -
- )}
); } From dc57673f31b32fc3ac5ebddee79b6ad6c11863a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Pavl=C3=AD=C4=8Dek?= Date: Thu, 19 Mar 2026 16:55:03 +0100 Subject: [PATCH 3/4] Fix: Middle of sentence changes (participants) --- src/components/dialogue/DialogueRowsPanel.jsx | 128 ++++++++++++------ src/components/ui/app-header.jsx | 7 +- 2 files changed, 91 insertions(+), 44 deletions(-) diff --git a/src/components/dialogue/DialogueRowsPanel.jsx b/src/components/dialogue/DialogueRowsPanel.jsx index 6e244c7..45bc49c 100644 --- a/src/components/dialogue/DialogueRowsPanel.jsx +++ b/src/components/dialogue/DialogueRowsPanel.jsx @@ -6,6 +6,7 @@ import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { Input } from '@/components/ui/input'; import { Slider } from '@/components/ui/slider'; +import { NativeSelect } from '@/components/ui/native-select'; import { useToast } from '@/components/ui/toaster'; import { v4 as uuidv4 } from 'uuid'; import { @@ -191,12 +192,22 @@ export function DialogueRowsPanel({ onChange(newRows); }; - // Auto-complete state for dynamic text + // Participant token insertion state const [activeRowIndex, setActiveRowIndex] = useState(null); - const [autocompleteVisible, setAutocompleteVisible] = useState(false); - const [autocompleteFilter, setAutocompleteFilter] = useState(''); + const [participantTokenFilter, setParticipantTokenFilter] = useState(''); + const [participantTokenStart, setParticipantTokenStart] = useState(null); + const [participantTokenCursor, setParticipantTokenCursor] = useState(null); + const [participantSelectValue, setParticipantSelectValue] = useState(''); const textareaRefs = useRef({}); + const closeParticipantPicker = () => { + setActiveRowIndex(null); + setParticipantTokenFilter(''); + setParticipantTokenStart(null); + setParticipantTokenCursor(null); + setParticipantSelectValue(''); + }; + const handleTextChange = (index, value, event) => { updateRow(index, { text: value }); @@ -212,47 +223,71 @@ export function DialogueRowsPanel({ // Check if we're still in the middle of typing a variable if (!filterText.includes('}') && !filterText.includes(' ') && !filterText.includes('\n')) { setActiveRowIndex(index); - setAutocompleteFilter(filterText.toLowerCase()); - setAutocompleteVisible(true); + setParticipantTokenFilter(filterText.toLowerCase()); + setParticipantTokenStart(dollarIndex); + setParticipantTokenCursor(cursorPos); + setParticipantSelectValue(''); } else { - setAutocompleteVisible(false); + closeParticipantPicker(); } } else { - setAutocompleteVisible(false); + closeParticipantPicker(); } }; - const insertParticipant = (participantName) => { - if (activeRowIndex === null) return; + const insertParticipant = (rowIndex, participantName) => { + if (rowIndex === null || rowIndex === undefined) return; + if (participantTokenStart === null || participantTokenCursor === null) return; - const row = dialogueRows[activeRowIndex]; - const textarea = textareaRefs.current[activeRowIndex]; + const row = dialogueRows[rowIndex]; + const textarea = textareaRefs.current[rowIndex]; if (!textarea) return; - const cursorPos = textarea.selectionStart; - const textBeforeCursor = row.text.substring(0, cursorPos); - const dollarIndex = textBeforeCursor.lastIndexOf('$'); - // Replace from $ to cursor with ${participantName} const newText = - row.text.substring(0, dollarIndex) + + row.text.substring(0, participantTokenStart) + `\${${participantName}}` + - row.text.substring(cursorPos); + row.text.substring(participantTokenCursor); - updateRow(activeRowIndex, { text: newText }); - setAutocompleteVisible(false); + updateRow(rowIndex, { text: newText }); + closeParticipantPicker(); // Focus back on textarea setTimeout(() => { textarea.focus(); - const newPos = dollarIndex + participantName.length + 3; // Position after ${name} + const newPos = participantTokenStart + participantName.length + 3; // Position after ${name} textarea.setSelectionRange(newPos, newPos); }, 0); }; - const filteredParticipants = participants.filter((p) => - p.name.toLowerCase().includes(autocompleteFilter) - ); + const buildParticipantGroups = (filter = '') => { + const normalizedFilter = String(filter || '').toLowerCase().trim(); + const candidates = (participants || []).filter((participant) => + String(participant?.name || '').toLowerCase().includes(normalizedFilter) + ); + const list = candidates.length > 0 ? candidates : participants; + const groups = new Map(); + + list.forEach((participant) => { + const name = String(participant?.name || '').trim(); + if (!name) return; + const category = String(participant?.category || 'Participants').trim() || 'Participants'; + if (!groups.has(category)) { + groups.set(category, []); + } + groups.get(category).push({ + id: participant.id, + name, + }); + }); + + return Array.from(groups.entries()) + .map(([label, options]) => ({ + label, + options: options.sort((a, b) => a.name.localeCompare(b.name)), + })) + .sort((a, b) => a.label.localeCompare(b.label)); + }; // Handle audio file upload const handleAudioUpload = async (index, file) => { @@ -469,24 +504,37 @@ export function DialogueRowsPanel({ placeholder="Enter dialogue text... Type $ to insert participant names" className="min-h-[100px] font-mono text-sm" /> - {autocompleteVisible && - activeRowIndex === index && - filteredParticipants.length > 0 && ( -
- {filteredParticipants.map((participant) => ( - + {activeRowIndex === index && ( +
+ + { + const selected = e.target.value; + setParticipantSelectValue(selected); + if (selected) { + insertParticipant(index, selected); + } + }} + > + + {buildParticipantGroups(participantTokenFilter).map((group) => ( + + {group.options.map((option) => ( + + ))} + ))} -
- )} + +
+ )}
{/* Audio File */} diff --git a/src/components/ui/app-header.jsx b/src/components/ui/app-header.jsx index 1e61e3b..dd0a026 100644 --- a/src/components/ui/app-header.jsx +++ b/src/components/ui/app-header.jsx @@ -107,15 +107,14 @@ export const AppHeader = forwardRef(function AppHeader( ) : null} - -
+
{rightItems} - {menuButton}
) : null} From 79ec831d4e5da055bc7d6967acaef5fa80513726 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Pavl=C3=AD=C4=8Dek?= Date: Thu, 19 Mar 2026 16:57:38 +0100 Subject: [PATCH 4/4] Graph editor: Open global palette --- src/components/ui/command-palette.jsx | 23 +++ .../$projectId/dialogue/$dialogueId/index.jsx | 163 +----------------- 2 files changed, 26 insertions(+), 160 deletions(-) diff --git a/src/components/ui/command-palette.jsx b/src/components/ui/command-palette.jsx index 2dc4ef4..50379a3 100644 --- a/src/components/ui/command-palette.jsx +++ b/src/components/ui/command-palette.jsx @@ -15,10 +15,12 @@ import { Play, Plus, RotateCcw, + Redo2, Save, Search as SearchIcon, Settings, ShieldCheck, + Undo2, Upload, } from 'lucide-react'; import { cn } from '@/lib/utils'; @@ -276,6 +278,27 @@ export function CommandPalette({ open, onOpenChange, actions: actionsProp, place groups.push({ group: 'File', items: fileItems }); } + const editItems = []; + if (isDialogue) { + editItems.push( + { + icon: Undo2, + label: 'Undo', + shortcut: 'Ctrl+Z', + onSelect: () => dispatchMenuCommand('dialogue-undo'), + }, + { + icon: Redo2, + label: 'Redo', + shortcut: 'Ctrl+Y', + onSelect: () => dispatchMenuCommand('dialogue-redo'), + } + ); + } + if (editItems.length > 0) { + groups.push({ group: 'Edit', items: editItems }); + } + const viewItems = []; if (isDialogue) { viewItems.push({ diff --git a/src/routes/projects/$projectId/dialogue/$dialogueId/index.jsx b/src/routes/projects/$projectId/dialogue/$dialogueId/index.jsx index e7c16b4..a3803e7 100644 --- a/src/routes/projects/$projectId/dialogue/$dialogueId/index.jsx +++ b/src/routes/projects/$projectId/dialogue/$dialogueId/index.jsx @@ -421,7 +421,7 @@ function DialogueEditorPage() { // Onboarding tour const { runTour, finishTour, resetTour } = useOnboarding('dialogue-editor'); const openSettingsCommand = useSettingsCommandStore((state) => state.openWithContext); - const openCommandPalette = useCommandPaletteStore((state) => state.openWithActions); + const setCommandPaletteOpen = useCommandPaletteStore((state) => state.setOpen); // Device detection const [deviceType, setDeviceType] = useState('desktop'); @@ -2545,168 +2545,11 @@ function DialogueEditorPage() { {!isDesktopElectron && (