Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
268 changes: 207 additions & 61 deletions client/src/components/QuickReplies.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import React, { useState, useEffect } from "react";
import { Plus, X, Check, Pencil, GripVertical, ChevronLeft, ChevronRight } from "lucide-react";

const CATEGORIES = ["General", "Social", "Needs", "Urgent"];
const DEFAULT_CATEGORIES = ["General", "Social", "Needs", "Urgent"];
const CATEGORIES_KEY = "vf_quick_reply_categories";
Comment on lines +4 to +5

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Replace the stale CATEGORIES reference.

Line 270 expands CATEGORIES, but this change renamed that constant to DEFAULT_CATEGORIES. CATEGORIES is undeclared, so every QuickReplies render throws before the tabs display. Build allCats from categories so keyboard navigation also includes custom categories.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/src/components/QuickReplies.jsx` around lines 4 - 5, Update the
category list construction in QuickReplies to replace the stale CATEGORIES
reference with DEFAULT_CATEGORIES and the current categories state, ensuring
allCats includes both default and custom categories for rendering and keyboard
navigation.


const generateId = () => Math.random().toString(36).substr(2, 9);

Expand All @@ -18,6 +19,20 @@ const DEFAULT_QUICK_REPLIES = [
const STORAGE_KEY = "vf_quick_replies";

export function QuickReplies({ onSelect, showToast }) {
const [categories, setCategories] = useState(() => {
try {
const saved = localStorage.getItem(CATEGORIES_KEY);
if (saved === null) return DEFAULT_CATEGORIES;
const parsed = JSON.parse(saved);
if (Array.isArray(parsed) && parsed.every((c) => typeof c === "string")) {
return parsed;
}
return DEFAULT_CATEGORIES;
} catch {
return DEFAULT_CATEGORIES;
}
});
Comment on lines +22 to +34

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Enforce one category invariant at every storage boundary.

An empty array, "All", duplicate names, whitespace-only names, or a category list without "General" passes the current checks. This can create duplicate tabs, remove the fallback category, and make replies inaccessible after restore. Normalize category data to retain DEFAULT_CATEGORIES, then append valid unique custom categories.

  • client/src/components/QuickReplies.jsx#L22-L34: normalize persisted categories during initial state creation.
  • client/src/components/QuickReplies.jsx#L45-L57: use the same normalized list when restoring reply categories.
  • client/src/components/QuickReplies.jsx#L107-L117: normalize synchronized category data before calling setCategories.
  • client/src/pages/Settings.jsx#L242-L248: reject or normalize imported quick_reply_categories before writing localStorage.
📍 Affects 2 files
  • client/src/components/QuickReplies.jsx#L22-L34 (this comment)
  • client/src/components/QuickReplies.jsx#L45-L57
  • client/src/components/QuickReplies.jsx#L107-L117
  • client/src/pages/Settings.jsx#L242-L248
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/src/components/QuickReplies.jsx` around lines 22 - 34, Introduce one
shared category-normalization helper and use it at every storage boundary:
normalize persisted categories during QuickReplies initial state creation,
normalize restored reply categories before applying them, and normalize
synchronized category data before setCategories in QuickReplies.jsx; also reject
or normalize imported quick_reply_categories in Settings.jsx before writing
localStorage. The normalized result must always retain DEFAULT_CATEGORIES,
include General, remove empty/whitespace-only, All, and duplicate names, and
append only valid unique custom categories.


const [replies, setReplies] = useState(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
Expand All @@ -27,11 +42,19 @@ export function QuickReplies({ onSelect, showToast }) {
Array.isArray(parsed) &&
parsed.every((item) => item && typeof item.phrase === "string" && typeof item.label === "string")
) {
return parsed.map((item, idx) => ({
// Load initial categories to validate
let cats = DEFAULT_CATEGORIES;
try {
const savedCats = localStorage.getItem(CATEGORIES_KEY);
if (savedCats) {
const parsedCats = JSON.parse(savedCats);
if (Array.isArray(parsedCats)) cats = parsedCats;
}
} catch {}
return parsed.map((item) => ({
...item,
id: item.id || generateId(),
hotkey: item.hotkey || String(idx + 1),
category: item.category && CATEGORIES.includes(item.category) ? item.category : "General",
category: item.category && cats.includes(item.category) ? item.category : "General",
}));
}
return DEFAULT_QUICK_REPLIES;
Expand All @@ -47,7 +70,9 @@ export function QuickReplies({ onSelect, showToast }) {
const [newPhrase, setNewPhrase] = useState("");
const [selectedCategoryTab, setSelectedCategoryTab] = useState("All");
const [newCategory, setNewCategory] = useState("General");
const [draggedId, setDraggedId] = useState(null);
const [isAddingCategory, setIsAddingCategory] = useState(false);
const [newCategoryInput, setNewCategoryInput] = useState("");
const tablistRef = React.useRef(null);

useEffect(() => {
try {
Expand All @@ -57,45 +82,107 @@ export function QuickReplies({ onSelect, showToast }) {
}
}, [replies]);

const handleDragStart = (e, id) => {
setDraggedId(id);
e.dataTransfer.effectAllowed = "move";
};
useEffect(() => {
try {
localStorage.setItem(CATEGORIES_KEY, JSON.stringify(categories));
} catch {
console.error('Failed to persist categories to localStorage');
}
}, [categories]);

const handleDragOver = (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
};
useEffect(() => {
function handleSync() {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
if (Array.isArray(parsed)) {
setReplies(parsed);
}
}
} catch (err) {
console.error("Failed to sync quick replies:", err);
}

try {
const savedCategories = localStorage.getItem(CATEGORIES_KEY);
if (savedCategories) {
const parsed = JSON.parse(savedCategories);
if (Array.isArray(parsed)) {
setCategories(parsed);
Comment thread
Kritika200520 marked this conversation as resolved.
}
Comment thread
Kritika200520 marked this conversation as resolved.
}
} catch (err) {
console.error("Failed to sync categories:", err);
}
}

window.addEventListener("storage", handleSync);
window.addEventListener("voiceforge:quickRepliesChanged", handleSync);
window.addEventListener("voiceforge:quickReplyCategoriesChanged", handleSync);

const handleDrop = (e, targetId) => {
return () => {
window.removeEventListener("storage", handleSync);
window.removeEventListener("voiceforge:quickRepliesChanged", handleSync);
window.removeEventListener("voiceforge:quickReplyCategoriesChanged", handleSync);
};
}, []);

const handleAddCategory = (e) => {
e.preventDefault();
if (!draggedId || draggedId === targetId) return;

setReplies((prev) => {
const fromIndex = prev.findIndex((r) => r.id === draggedId);
const toIndex = prev.findIndex((r) => r.id === targetId);
if (fromIndex === -1 || toIndex === -1) return prev;

const updated = [...prev];
const [movedItem] = updated.splice(fromIndex, 1);
updated.splice(toIndex, 0, movedItem);
return updated;
});
setDraggedId(null);
const cleanCat = newCategoryInput.trim();

if (!cleanCat) {
showToast("Category name cannot be empty", "error");
return;
}

if (cleanCat.length > 20) {
showToast("Category name is too long (max 20 characters)", "error");
return;
}

const isDuplicate = categories.some(
(c) => c.toLowerCase() === cleanCat.toLowerCase()
);

if (isDuplicate || cleanCat.toLowerCase() === "all") {
showToast("Category already exists", "error");
return;
}

const nextCats = [...categories, cleanCat];
setCategories(nextCats);
Comment thread
Kritika200520 marked this conversation as resolved.
setNewCategoryInput("");
setIsAddingCategory(false);
showToast("Category added", "success");
window.dispatchEvent(new Event("voiceforge:quickReplyCategoriesChanged"));
Comment thread
Kritika200520 marked this conversation as resolved.
};
Comment on lines +154 to +160

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Persist category changes before emitting the category-change event.

window.dispatchEvent() synchronously invokes this component’s handleSync. It reads localStorage before the effects at lines 85-91 persist the new state. The stale setCategories and setReplies calls can overwrite the add or delete update.

  • client/src/components/QuickReplies.jsx#L154-L160: persist nextCats, or include it in event detail, before dispatching the event.
  • client/src/components/QuickReplies.jsx#L168-L185: persist both nextCats and the reassigned replies, or include both in event detail, before dispatching the event.
🧰 Tools
🪛 ast-grep (0.45.0)

[warning] 154-154: Avoid using the initial state variable in setState
Context: setCategories(nextCats)
Note: [CWE-710] Improper Adherence to Coding Standards. Security best practice.

(setstate-same-var)

📍 Affects 1 file
  • client/src/components/QuickReplies.jsx#L154-L160 (this comment)
  • client/src/components/QuickReplies.jsx#L168-L185
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/src/components/QuickReplies.jsx` around lines 154 - 160, Persist
category changes before dispatching voiceforge:quickReplyCategoriesChanged: in
the add-category flow around client/src/components/QuickReplies.jsx lines
154-160, persist nextCats before the event; in the delete-category flow around
lines 168-185, persist both nextCats and the reassigned replies before
dispatching. Ensure handleSync reads the updated localStorage values rather than
overwriting changes with stale state.


const handleMove = (id, direction) => {
setReplies((prev) => {
const index = prev.findIndex((r) => r.id === id);
if (index === -1) return prev;
const newIndex = direction === "left" ? index - 1 : index + 1;
if (newIndex < 0 || newIndex >= prev.length) return prev;

const updated = [...prev];
const [movedItem] = updated.splice(index, 1);
updated.splice(newIndex, 0, movedItem);
return updated;
});
const handleDeleteCategory = (catToDelete) => {
const confirmDelete = window.confirm(
`Are you sure you want to delete the category "${catToDelete}"? Existing replies in this category will be moved to "General".`
);
if (!confirmDelete) return;

const nextCats = categories.filter((c) => c !== catToDelete);
setCategories(nextCats);

setReplies((prev) =>
prev.map((r) => {
if (r.category === catToDelete) {
return { ...r, category: "General" };
}
return r;
})
);
Comment on lines +168 to +178

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Reset open reply forms when deleting their selected category.

If newCategory or editingReplyData.category equals catToDelete, this handler removes the option but retains the stale form value. A later save writes a reply back into the deleted category. Set active form categories to "General" here, and reject a category that is no longer in categories during reply save.

🧰 Tools
🪛 ast-grep (0.45.0)

[warning] 168-168: Avoid using the initial state variable in setState
Context: setCategories(nextCats)
Note: [CWE-710] Improper Adherence to Coding Standards. Security best practice.

(setstate-same-var)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/src/components/QuickReplies.jsx` around lines 168 - 178, Update the
category-deletion handler to reset `newCategory` and `editingReplyData.category`
to `"General"` when they equal `catToDelete`, alongside updating replies. In the
reply-save logic, validate the selected category against the current
`categories` collection and reject or normalize values that are no longer
present.


if (selectedCategoryTab === catToDelete) {
setSelectedCategoryTab("All");
}

showToast("Category deleted and phrases moved to General", "success");
window.dispatchEvent(new Event("voiceforge:quickReplyCategoriesChanged"));
};

const handleAdd = (e) => {
Expand Down Expand Up @@ -250,30 +337,89 @@ export function QuickReplies({ onSelect, showToast }) {
{/* Category Tabs */}
<div
ref={tablistRef}
className="mb-3 flex overflow-x-auto gap-1.5 pb-1 no-scrollbar"
className="mb-3 flex overflow-x-auto gap-1.5 pb-1 no-scrollbar items-center"
role="tablist"
aria-label="Quick replies categories"
onKeyDown={handleTabKeyDown}
>
{allCats.map((cat) => (
<button
key={cat}
role="tab"
aria-selected={selectedCategoryTab === cat}
aria-controls={`tabpanel-${cat}`}
tabIndex={selectedCategoryTab === cat ? 0 : -1}
onClick={() => setSelectedCategoryTab(cat)}
className={[
"rounded-md px-2.5 py-1 text-xs font-semibold transition-colors duration-150 shrink-0",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-400 focus-visible:ring-offset-1 dark:focus-visible:ring-offset-black",
selectedCategoryTab === cat
? "bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300"
: "text-neutral-500 hover:bg-neutral-100 hover:text-neutral-700 dark:text-neutral-400 dark:hover:bg-surface dark:hover:text-neutral-300",
].join(" ")}
>
{cat}
</button>
))}
{["All", ...categories].map((cat) => {
const isDefault = DEFAULT_CATEGORIES.includes(cat);
return (
<div key={cat} className="flex items-center gap-0.5 shrink-0">
<button
role="tab"
aria-selected={selectedCategoryTab === cat}
aria-controls={`tabpanel-${cat}`}
tabIndex={selectedCategoryTab === cat ? 0 : -1}
onClick={() => setSelectedCategoryTab(cat)}
className={[
"rounded-md px-2.5 py-1 text-xs font-semibold transition-colors duration-150 shrink-0",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-400 focus-visible:ring-offset-1 dark:focus-visible:ring-offset-black",
selectedCategoryTab === cat
? "bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300"
: "text-neutral-500 hover:bg-neutral-100 hover:text-neutral-700 dark:text-neutral-400 dark:hover:bg-surface dark:hover:text-neutral-300",
].join(" ")}
>
{cat}
</button>
{isEditing && !isDefault && cat !== "All" && (
<button
type="button"
onClick={() => handleDeleteCategory(cat)}
aria-label={`Delete category ${cat}`}
className="p-0.5 text-neutral-400 hover:text-red-500 transition-colors"
>
<X size={10} aria-hidden="true" />
</button>
)}
</div>
);
})}

{isEditing && (
Comment thread
Kritika200520 marked this conversation as resolved.
<div className="flex items-center gap-1 shrink-0 ml-1">
{isAddingCategory ? (
<form onSubmit={handleAddCategory} className="flex items-center gap-1">
<input
type="text"
value={newCategoryInput}
onChange={(e) => setNewCategoryInput(e.target.value)}
placeholder="Category..."
maxLength={20}
className="rounded-md border border-blue-400 bg-white px-2 py-0.5 text-[11px] text-neutral-800 focus:outline-none dark:border-blue-500 dark:bg-black dark:text-neutral-100"
autoFocus
/>
<button
type="submit"
aria-label="Save category"
className="flex h-5 w-5 items-center justify-center rounded-full bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 transition-colors"
>
<Check size={10} aria-hidden="true" />
</button>
<button
type="button"
onClick={() => {
setIsAddingCategory(false);
setNewCategoryInput("");
}}
aria-label="Cancel"
className="flex h-5 w-5 items-center justify-center rounded-full text-neutral-400 hover:bg-neutral-100 hover:text-neutral-600 dark:text-neutral-500 dark:hover:bg-neutral-800 dark:hover:text-neutral-200 transition-colors"
>
<X size={10} aria-hidden="true" />
</button>
</form>
) : (
<button
type="button"
onClick={() => setIsAddingCategory(true)}
className="flex items-center gap-0.5 rounded-md border border-dashed border-neutral-300 px-2 py-0.5 text-[11px] font-semibold text-neutral-500 hover:border-neutral-400 hover:text-neutral-700 dark:border-neutral-700 dark:text-neutral-400 dark:hover:border-neutral-600 dark:hover:text-neutral-300 transition-colors"
>
<Plus size={10} aria-hidden="true" />
<span>Category</span>
</button>
)}
</div>
)}
</div>

<div className="flex flex-wrap items-center gap-2" role="group" aria-label="Quick reply phrases">
Expand Down Expand Up @@ -302,7 +448,7 @@ export function QuickReplies({ onSelect, showToast }) {
aria-label="Category"
className="bg-transparent text-xs text-neutral-500 dark:text-neutral-400 focus:outline-none border-l border-neutral-200 dark:border-neutral-700 pl-1.5 mr-1 cursor-pointer"
>
{CATEGORIES.map((cat) => (
{categories.map((cat) => (
<option key={cat} value={cat} className="dark:bg-neutral-900 dark:text-neutral-100">
{cat}
</option>
Expand Down Expand Up @@ -429,7 +575,7 @@ export function QuickReplies({ onSelect, showToast }) {
aria-label="Category"
className="bg-transparent text-xs text-neutral-500 dark:text-neutral-400 focus:outline-none border-l border-neutral-200 dark:border-neutral-700 pl-1.5 mr-1 cursor-pointer"
>
{CATEGORIES.map((cat) => (
{categories.map((cat) => (
<option key={cat} value={cat} className="dark:bg-neutral-900 dark:text-neutral-100">
{cat}
</option>
Expand Down
4 changes: 4 additions & 0 deletions client/src/pages/Settings.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,12 +134,14 @@ export default function Settings() {
history: localStorage.getItem("vf_history"),
favorites: localStorage.getItem("vf_favorites"),
quick_replies: localStorage.getItem("vf_quick_replies"),
quick_reply_categories: localStorage.getItem("vf_quick_reply_categories"),
voiceSettings: localStorage.getItem("voiceforge:voiceSettings"),
accessibilitySettings: localStorage.getItem(ACCESSIBILITY_SETTINGS_KEY),
language: localStorage.getItem(LANGUAGE_STORAGE_KEY),
calibrationXOffset: localStorage.getItem("voiceforge:calibrationXOffset"),
calibrationYOffset: localStorage.getItem("voiceforge:calibrationYOffset"),
calibrationScale: localStorage.getItem("voiceforge:calibrationScale"),
historyRetention: localStorage.getItem("vf_history_retention"),
};

const rawProfiles = await getSavedProfiles();
Expand Down Expand Up @@ -250,12 +252,14 @@ export default function Settings() {
history: "vf_history",
favorites: "vf_favorites",
quick_replies: "vf_quick_replies",
quick_reply_categories: "vf_quick_reply_categories",
voiceSettings: "voiceforge:voiceSettings",
accessibilitySettings: ACCESSIBILITY_SETTINGS_KEY,
language: LANGUAGE_STORAGE_KEY,
calibrationXOffset: "voiceforge:calibrationXOffset",
calibrationYOffset: "voiceforge:calibrationYOffset",
calibrationScale: "voiceforge:calibrationScale",
historyRetention: "vf_history_retention",
};

for (const [backupKey, storageKey] of Object.entries(keysMap)) {
Expand Down
Loading