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
169 changes: 132 additions & 37 deletions client/src/components/FavoriteMessages.jsx
Original file line number Diff line number Diff line change
@@ -1,64 +1,159 @@
import React, { useState } from "react";
import { Pin, X } from "lucide-react";
import React, { useMemo, useState } from "react";
import { Pin, X, Search, Filter } from "lucide-react";

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.

P3: Unused Filter import from lucide-react. The Filter icon is imported but never rendered anywhere in the component. Dead imports increase bundle size unnecessarily and add noise for future readers.

Prompt for AI agents
Check if this issue is valid β€” if so, understand the root cause and fix it. At client/src/components/FavoriteMessages.jsx, line 2:

<comment>Unused `Filter` import from lucide-react. The `Filter` icon is imported but never rendered anywhere in the component. Dead imports increase bundle size unnecessarily and add noise for future readers.</comment>

<file context>
@@ -1,64 +1,159 @@
-import React, { useState } from "react";
-import { Pin, X } from "lucide-react";
+import React, { useMemo, useState } from "react";
+import { Pin, X, Search, Filter } from "lucide-react";
 
-const FEW_SHOWN = 5;
</file context>
Suggested change
import { Pin, X, Search, Filter } from "lucide-react";
import { Pin, X, Search } from "lucide-react";


const FEW_SHOWN = 5;
const FEW_SHOWN = 6;

export function getPhraseCategory(text = "") {
const str = text.toLowerCase().trim();
if (str.includes("?") || /^(what|why|how|when|where|who|can|could|would|is|are|do|does)\b/.test(str)) {
return "questions";
}
if (/(help|need|want|water|food|bathroom|emergency|pain|doctor|medicine|please|urgent)/.test(str)) {
return "needs";
}
if (/(hello|hi|hey|good morning|good evening|goodbye|bye|thanks|thank|welcome|nice)/.test(str)) {
return "greetings";
}
return "social";
}

export function FavoriteMessages({ history, favorites, onReuse, onUnpin }) {
const [expanded, setExpanded] = useState(false);
const [search, setSearch] = useState("");
const [category, setCategory] = useState("all");

const totalPinnedCount = useMemo(() => {
return history.filter((message) => favorites.has(message.id)).length;
}, [history, favorites]);

const filteredPinned = useMemo(() => {
let list = history.filter((message) => favorites.has(message.id));

if (category !== "all") {
list = list.filter((msg) => getPhraseCategory(msg.text) === category);
}

if (search.trim()) {
const q = search.toLowerCase().trim();
list = list.filter((msg) => msg.text.toLowerCase().includes(q));
}

const pinned = history.filter((message) => favorites.has(message.id));
if (pinned.length === 0) return null;
return list;
}, [history, favorites, category, search]);

const displayed = expanded ? pinned : pinned.slice(0, FEW_SHOWN);
const hasMore = pinned.length > FEW_SHOWN;
if (totalPinnedCount === 0) return null;

const displayed = expanded ? filteredPinned : filteredPinned.slice(0, FEW_SHOWN);
const hasMore = filteredPinned.length > FEW_SHOWN;

const categories = [
{ key: "all", label: "All" },
{ key: "greetings", label: "Greetings" },
{ key: "needs", label: "Needs" },
{ key: "questions", label: "Questions" },
{ key: "social", label: "Social" },
];

return (
<section
aria-labelledby="fav-heading"
className="flex-shrink-0 border-b border-amber-200 bg-amber-50 px-4 py-2.5 dark:border-amber-500/25 dark:bg-black"
className="flex-shrink-0 border-b border-amber-200 bg-amber-50 px-4 py-2.5 space-y-2 dark:border-amber-500/25 dark:bg-black"
>
<div className="mb-1.5 flex items-center gap-1.5">
<Pin size={14} aria-hidden="true" className="text-amber-600 dark:text-amber-400" />
<h3
id="fav-heading"
className="text-[11px] font-semibold uppercase tracking-widest text-amber-700 dark:text-amber-400"
>
Pinned phrases
</h3>
</div>

<div className="flex flex-wrap items-center gap-1.5" role="list" aria-label="Pinned phrases">
{displayed.map((message) => (
<div
key={message.id}
role="listitem"
className="flex items-center gap-1 rounded-full border border-amber-200 bg-white py-1 pl-3 pr-1 text-xs text-amber-800 shadow-none dark:border-amber-500/30 dark:bg-surface dark:text-amber-300"
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-1.5">
<Pin size={14} aria-hidden="true" className="text-amber-600 dark:text-amber-400" />
<h3
id="fav-heading"
className="text-[11px] font-semibold uppercase tracking-widest text-amber-700 dark:text-amber-400"
>
Pinned phrases ({totalPinnedCount})
</h3>
</div>

{/* Compact Search Bar */}
<div className="relative flex items-center min-w-[140px] max-w-[200px]">
<Search size={12} className="pointer-events-none absolute left-2 text-amber-500" aria-hidden="true" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search pinned..."
className="w-full rounded-full border border-amber-200 bg-white py-1 pl-7 pr-6 text-xs text-amber-900 placeholder:text-amber-400/80 outline-none focus:ring-1 focus:ring-amber-400 dark:border-amber-500/30 dark:bg-surface dark:text-amber-200 dark:placeholder:text-amber-500/60"
/>
Comment on lines +76 to +82

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.

P2: The search <input> has no accessible label. It relies solely on the placeholder attribute for its name, which is not a reliable accessible label β€” screen readers may announce it inconsistently or skip it entirely in some browsing modes. Adding an aria-label ensures the input is always identifiable to assistive technology.

Prompt for AI agents
Check if this issue is valid β€” if so, understand the root cause and fix it. At client/src/components/FavoriteMessages.jsx, line 76:

<comment>The search `<input>` has no accessible label. It relies solely on the `placeholder` attribute for its name, which is not a reliable accessible label β€” screen readers may announce it inconsistently or skip it entirely in some browsing modes. Adding an `aria-label` ensures the input is always identifiable to assistive technology.</comment>

<file context>
@@ -1,64 +1,159 @@
+        {/* Compact Search Bar */}
+        <div className="relative flex items-center min-w-[140px] max-w-[200px]">
+          <Search size={12} className="pointer-events-none absolute left-2 text-amber-500" aria-hidden="true" />
+          <input
+            type="text"
+            value={search}
</file context>
Suggested change
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search pinned..."
className="w-full rounded-full border border-amber-200 bg-white py-1 pl-7 pr-6 text-xs text-amber-900 placeholder:text-amber-400/80 outline-none focus:ring-1 focus:ring-amber-400 dark:border-amber-500/30 dark:bg-surface dark:text-amber-200 dark:placeholder:text-amber-500/60"
/>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search pinned..."
aria-label="Search pinned phrases"
className="w-full rounded-full border border-amber-200 bg-white py-1 pl-7 pr-6 text-xs text-amber-900 placeholder:text-amber-400/80 outline-none focus:ring-1 focus:ring-amber-400 dark:border-amber-500/30 dark:bg-surface dark:text-amber-200 dark:placeholder:text-amber-500/60"
/>

{search && (
<button
onClick={() => onReuse(message.text)}
className="max-w-[150px] truncate text-left focus:outline-none focus:underline sm:max-w-[180px]"
aria-label={`Load pinned phrase: ${message.text}`}
title={message.text}
type="button"
onClick={() => setSearch("")}
aria-label="Clear pinned search"
className="absolute right-1.5 text-amber-500 hover:text-amber-700 dark:hover:text-amber-200"
>
{message.text}
<X size={12} aria-hidden="true" />
</button>
)}
</div>
</div>

{/* Category Tag Chips */}
<div className="flex items-center gap-1 overflow-x-auto pb-0.5" role="tablist" aria-label="Pinned phrase categories">
{categories.map((cat) => {
const isActive = category === cat.key;
return (
<button
onClick={() => onUnpin(message.id)}
className="ml-0.5 flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full text-amber-500 transition hover:bg-amber-100 hover:text-amber-700 focus:outline-none focus:ring-1 focus:ring-amber-400 dark:hover:bg-amber-500/15"
aria-label={`Unpin: ${message.text}`}
key={cat.key}
role="tab"
aria-selected={isActive}
onClick={() => setCategory(cat.key)}
className={`rounded-full px-2.5 py-0.5 text-[11px] font-medium transition-all ${
isActive
? "bg-amber-600 text-white shadow-sm dark:bg-amber-500 dark:text-black"
: "bg-white/80 text-amber-800 hover:bg-amber-100 dark:bg-surface dark:text-amber-300 dark:hover:bg-amber-900/40"
}`}
>
<X size={12} aria-hidden="true" />
{cat.label}
</button>
</div>
))}
);
})}
</div>

{/* Pinned Phrase List */}
<div className="flex flex-wrap items-center gap-1.5" role="list" aria-label="Pinned phrases">
{displayed.length === 0 ? (
<p className="text-xs italic text-amber-700/70 dark:text-amber-400/70 py-1">

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.

P3: The empty-state message only mentions "in this category" but the empty result could also come from the search filter (or both). If the user has the "All" category selected and types a search that matches nothing, the message "No pinned phrases found in this category" is misleading β€” the issue is the search query, not the category. Consider a dynamic message that reflects which filter(s) are active.

Prompt for AI agents
Check if this issue is valid β€” if so, understand the root cause and fix it. At client/src/components/FavoriteMessages.jsx, line 121:

<comment>The empty-state message only mentions "in this category" but the empty result could also come from the search filter (or both). If the user has the "All" category selected and types a search that matches nothing, the message "No pinned phrases found in this category" is misleading β€” the issue is the search query, not the category. Consider a dynamic message that reflects which filter(s) are active.</comment>

<file context>
@@ -1,64 +1,159 @@
+      {/* Pinned Phrase List */}
+      <div className="flex flex-wrap items-center gap-1.5" role="list" aria-label="Pinned phrases">
+        {displayed.length === 0 ? (
+          <p className="text-xs italic text-amber-700/70 dark:text-amber-400/70 py-1">
+            No pinned phrases found in this category.
+          </p>
</file context>

No pinned phrases found in this category.
</p>
) : (
displayed.map((message) => (
<div
key={message.id}
role="listitem"
className="flex items-center gap-1 rounded-full border border-amber-200 bg-white py-1 pl-3 pr-1 text-xs text-amber-800 shadow-none dark:border-amber-500/30 dark:bg-surface dark:text-amber-300"
>
<button
onClick={() => onReuse(message.text)}
className="max-w-[150px] truncate text-left focus:outline-none focus:underline sm:max-w-[180px]"
aria-label={`Load pinned phrase: ${message.text}`}
title={message.text}
>
{message.text}
</button>
<button
onClick={() => onUnpin(message.id)}
className="ml-0.5 flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full text-amber-500 transition hover:bg-amber-100 hover:text-amber-700 focus:outline-none focus:ring-1 focus:ring-amber-400 dark:hover:bg-amber-500/15"
aria-label={`Unpin: ${message.text}`}
>
<X size={12} aria-hidden="true" />
</button>
</div>
))
)}

{hasMore && (
<button
onClick={() => setExpanded((value) => !value)}
className="rounded-full border border-amber-200 bg-white px-2.5 py-1 text-xs text-amber-700 transition hover:bg-amber-100 focus:outline-none focus:ring-1 focus:ring-amber-400 dark:border-amber-500/30 dark:bg-surface dark:text-amber-300 dark:hover:bg-amber-500/15"
aria-label={expanded ? "Show fewer pinned phrases" : `Show ${pinned.length - FEW_SHOWN} more pinned phrases`}
aria-label={expanded ? "Show fewer pinned phrases" : `Show ${filteredPinned.length - FEW_SHOWN} more pinned phrases`}
>
{expanded ? "Show less" : `+${pinned.length - FEW_SHOWN} more`}
{expanded ? "Show less" : `+${filteredPinned.length - FEW_SHOWN} more`}
</button>
)}
</div>
Expand Down
27 changes: 27 additions & 0 deletions client/src/components/FavoriteMessages.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, it, expect } from "vitest";
import { getPhraseCategory } from "./FavoriteMessages";

describe("getPhraseCategory inference utility", () => {
it("categorizes question phrases with a question mark or interrogative lead word", () => {
expect(getPhraseCategory("What time is it?")).toBe("questions");
expect(getPhraseCategory("How can I help you")).toBe("questions");
expect(getPhraseCategory("Where are we going")).toBe("questions");
});

it("categorizes urgent needs and medical request phrases", () => {
expect(getPhraseCategory("I need water please")).toBe("needs");
expect(getPhraseCategory("Emergency doctor help")).toBe("needs");
expect(getPhraseCategory("I have pain in my arm")).toBe("needs");
});

it("categorizes greetings and thank you phrases", () => {
expect(getPhraseCategory("Hello good morning!")).toBe("greetings");
expect(getPhraseCategory("Thank you so much")).toBe("greetings");
expect(getPhraseCategory("Goodbye see you later")).toBe("greetings");
});

it("categorizes general phrases under social category", () => {
expect(getPhraseCategory("That sounds great!")).toBe("social");
expect(getPhraseCategory("Working on VoiceForge project")).toBe("social");
});
});
Loading