Skip to content

Commit cbc6d45

Browse files
committed
dd feedback loop prototype with anchored interactions and dashboard API
Enriches existing events (transforms, highlight-ask, form help) with CSS selector location data for heatmap-style aggregation. Adds "Leave site feedback" reply flow on the highlight-ask dialog so users can comment on specific page content. Includes a Feedback tab in the sidebar for general page feedback. Server stores interactions in memory with AI categorization (Haiku), exposes GET /dashboard/:domain aggregation endpoints for site owners. Replaces floating tooltip with a centered modal dialog featuring markdown rendering, highlighted text quote, and inline reply. Fixes tab overflow and switches sidebar to controlled Radix tabs. 18 new tests.
1 parent ab6cf7a commit cbc6d45

10 files changed

Lines changed: 1149 additions & 46 deletions

File tree

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { useState, useEffect, useRef } from "react";
2+
3+
type FeedbackStatus = "idle" | "submitting" | "done" | "error";
4+
5+
const CATEGORY_LABELS: Record<string, string> = {
6+
"confusing-language": "Confusing Language",
7+
"missing-info": "Missing Information",
8+
"broken-feature": "Broken Feature",
9+
accessibility: "Accessibility Issue",
10+
navigation: "Navigation Problem",
11+
positive: "Positive Feedback",
12+
other: "General Feedback",
13+
};
14+
15+
export function FeedbackPanel() {
16+
const [status, setStatus] = useState<FeedbackStatus>("idle");
17+
const [comment, setComment] = useState("");
18+
const [category, setCategory] = useState<string | null>(null);
19+
const [error, setError] = useState<string | null>(null);
20+
const statusRef = useRef({ setStatus, setCategory, setError });
21+
statusRef.current = { setStatus, setCategory, setError };
22+
23+
// Listen for FEEDBACK_STATUS from service worker
24+
useEffect(() => {
25+
const listener = (msg: unknown) => {
26+
const m = msg as { type?: string; payload?: Record<string, unknown> };
27+
if (m.type === "FEEDBACK_STATUS" && m.payload) {
28+
switch (m.payload.status) {
29+
case "submitting":
30+
statusRef.current.setStatus("submitting");
31+
break;
32+
case "done":
33+
statusRef.current.setStatus("done");
34+
statusRef.current.setCategory(
35+
(m.payload.category as string) ?? null
36+
);
37+
break;
38+
case "error":
39+
statusRef.current.setStatus("error");
40+
statusRef.current.setError(
41+
(m.payload.message as string) ?? "Failed to submit"
42+
);
43+
break;
44+
}
45+
}
46+
};
47+
chrome.runtime.onMessage.addListener(listener);
48+
return () => chrome.runtime.onMessage.removeListener(listener);
49+
}, []);
50+
51+
function handleSubmit() {
52+
if (!comment.trim()) return;
53+
54+
setStatus("submitting");
55+
chrome.runtime.sendMessage({
56+
type: "SUBMIT_FEEDBACK",
57+
payload: {
58+
url: "", // background.ts will fill from active tab
59+
selector: "body",
60+
comment: comment.trim(),
61+
},
62+
});
63+
}
64+
65+
if (status === "done") {
66+
return (
67+
<div className="space-y-4">
68+
<div className="rounded-xl bg-green-50 border border-green-200 p-4 text-center">
69+
<p className="text-sm font-medium text-green-800">
70+
Thanks for your feedback!
71+
</p>
72+
{category && (
73+
<p className="mt-2 text-xs text-green-600">
74+
Categorized as:{" "}
75+
<span className="font-medium">
76+
{CATEGORY_LABELS[category] ?? category}
77+
</span>
78+
</p>
79+
)}
80+
</div>
81+
<button
82+
onClick={() => {
83+
setStatus("idle");
84+
setComment("");
85+
setCategory(null);
86+
}}
87+
className="w-full py-2 px-4 border border-gray-200 text-gray-700 rounded-lg text-sm hover:bg-gray-50 transition-colors"
88+
>
89+
Submit another
90+
</button>
91+
</div>
92+
);
93+
}
94+
95+
return (
96+
<div className="space-y-4">
97+
<div className="rounded-xl bg-violet-50 p-4">
98+
<h3 className="text-sm font-medium text-violet-900">Share Feedback</h3>
99+
<p className="mt-1 text-sm text-violet-700 leading-relaxed">
100+
Tell us about your experience on this page. Your feedback helps
101+
improve websites for everyone.
102+
</p>
103+
</div>
104+
105+
{error && (
106+
<div className="rounded-xl bg-red-50 border border-red-200 p-3">
107+
<p className="text-sm text-red-700">{error}</p>
108+
</div>
109+
)}
110+
111+
<div>
112+
<textarea
113+
value={comment}
114+
onChange={(e) => setComment(e.target.value)}
115+
placeholder="What was confusing or hard to use on this page?"
116+
maxLength={500}
117+
rows={4}
118+
className="w-full border border-gray-200 rounded-lg p-3 text-sm resize-vertical focus:outline-none focus:ring-2 focus:ring-violet-300 focus:border-violet-400"
119+
/>
120+
<p className="text-xs text-gray-400 mt-1 text-right">
121+
{comment.length}/500
122+
</p>
123+
</div>
124+
125+
<button
126+
onClick={handleSubmit}
127+
disabled={!comment.trim() || status === "submitting"}
128+
className="w-full py-2 px-4 bg-violet-600 text-white rounded-lg text-sm font-medium hover:bg-violet-700 transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
129+
>
130+
{status === "submitting" ? (
131+
<>
132+
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
133+
Submitting...
134+
</>
135+
) : (
136+
"Send Feedback"
137+
)}
138+
</button>
139+
140+
<p className="text-xs text-gray-400 text-center leading-relaxed">
141+
You can also highlight text on the page and click "Reply" on the
142+
explanation to leave feedback about a specific section.
143+
</p>
144+
</div>
145+
);
146+
}

packages/extension/components/Sidebar.tsx

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
1-
import { useEffect, useRef } from "react";
1+
import { useEffect, useRef, useState } from "react";
22
import * as Tabs from "@radix-ui/react-tabs";
33
import { usePreferencesStore, useTransformStore, useBenefitsStore, useEligibilityStore, useFormGuidanceStore } from "../lib/store";
44
import { PreferenceChat } from "./PreferenceChat";
55
import { PreferencesPanel } from "./PreferencesPanel";
66
import { EligibilityForm } from "./EligibilityForm";
77
import { BenefitsResults } from "./BenefitsResults";
88
import { FormGuidancePanel } from "./FormGuidancePanel";
9+
import { FeedbackPanel } from "./FeedbackPanel";
910

1011
export function Sidebar() {
12+
const [activeTab, setActiveTab] = useState("home");
1113
const { isOnboarded, isEnabled, setEnabled } = usePreferencesStore();
1214
const { status, lastTransformMs, wasCached, transformedCount, error, setResult, setStatus, setError, reset } =
1315
useTransformStore();
@@ -140,14 +142,14 @@ export function Sidebar() {
140142
</header>
141143

142144
{/* Tabs */}
143-
<Tabs.Root defaultValue="home" className="flex-1 flex flex-col min-h-0">
144-
<Tabs.List className="flex border-b border-gray-200 px-4">
145-
{["home", "forms", "benefits", "settings"].map((tab) => (
145+
<Tabs.Root value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col min-h-0">
146+
<Tabs.List className="flex border-b border-gray-200 px-2">
147+
{["home", "forms", "benefits", "feedback", "settings"].map((tab) => (
146148
<Tabs.Trigger
147149
key={tab}
148150
value={tab}
149151
data-value={tab}
150-
className="px-3 py-2 text-sm text-gray-500 capitalize border-b-2 border-transparent data-[state=active]:border-violet-600 data-[state=active]:text-violet-700 transition-colors"
152+
className="flex-1 px-1 py-2 text-xs text-gray-500 capitalize border-b-2 border-transparent data-[state=active]:border-violet-600 data-[state=active]:text-violet-700 transition-colors text-center"
151153
>
152154
{tab}
153155
</Tabs.Trigger>
@@ -202,8 +204,7 @@ export function Sidebar() {
202204
</button>
203205
<button
204206
onClick={() => {
205-
const formsTab = document.querySelector('[data-value="forms"]') as HTMLElement | null;
206-
formsTab?.click();
207+
setActiveTab("forms");
207208
chrome.runtime.sendMessage({
208209
type: "SCAN_FOR_FORMS",
209210
payload: {},
@@ -223,14 +224,22 @@ export function Sidebar() {
223224
</button>
224225
<button
225226
onClick={() => {
226-
const benefitsTab = document.querySelector('[data-value="benefits"]') as HTMLElement | null;
227-
benefitsTab?.click();
227+
setActiveTab("benefits");
228228
}}
229229
disabled={!isEnabled}
230230
className="w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-gray-50 transition-colors disabled:opacity-50"
231231
>
232232
Find benefits for me
233233
</button>
234+
<button
235+
onClick={() => {
236+
setActiveTab("feedback");
237+
}}
238+
disabled={!isEnabled}
239+
className="w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-gray-50 transition-colors disabled:opacity-50"
240+
>
241+
Share feedback
242+
</button>
234243
</div>
235244
</div>
236245

@@ -321,6 +330,10 @@ export function Sidebar() {
321330
)}
322331
</Tabs.Content>
323332

333+
<Tabs.Content value="feedback" className="flex-1 overflow-y-auto p-4">
334+
<FeedbackPanel />
335+
</Tabs.Content>
336+
324337
<Tabs.Content value="settings" className="flex-1 overflow-y-auto">
325338
<PreferencesPanel />
326339
</Tabs.Content>

packages/extension/entrypoints/background.ts

Lines changed: 82 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,9 @@ export default defineBackground(() => {
231231
handleHighlightAsk(
232232
message.payload.selectedText,
233233
message.payload.context,
234-
tabId
234+
tabId,
235+
message.payload.selector,
236+
message.payload.url
235237
)
236238
)
237239
.then(sendResponse);
@@ -251,6 +253,11 @@ export default defineBackground(() => {
251253
return true;
252254
}
253255

256+
case "SUBMIT_FEEDBACK": {
257+
handleSubmitFeedback(message.payload).then(sendResponse);
258+
return true;
259+
}
260+
254261
case "PREFERENCES_UPDATED":
255262
broadcastToTabs(message);
256263
trackEvent("preference_changed", message.payload as unknown as Record<string, unknown>);
@@ -406,6 +413,7 @@ export default defineBackground(() => {
406413

407414
trackEvent("transform_accepted", {
408415
url: pageContent.url,
416+
selectors: cloudResult.instructions.map((i) => i.selector),
409417
instructionCount: cloudResult.instructions.length,
410418
processingMs: totalMs,
411419
});
@@ -603,6 +611,7 @@ export default defineBackground(() => {
603611

604612
trackEvent("form_guidance_used", {
605613
url: formData.url,
614+
selectors: formData.fields.map((f) => f.selector),
606615
fieldCount: formData.fields.length,
607616
guidanceCount: result.data.guidance.length,
608617
processingMs: result.data.processingMs,
@@ -621,10 +630,79 @@ export default defineBackground(() => {
621630
}
622631
}
623632

633+
async function handleSubmitFeedback(payload: {
634+
url: string;
635+
selector: string;
636+
comment: string;
637+
selectedText?: string;
638+
}) {
639+
chrome.runtime.sendMessage({
640+
type: "FEEDBACK_STATUS",
641+
payload: { status: "submitting" },
642+
}).catch(() => {});
643+
644+
try {
645+
// If no URL provided, get it from the active tab
646+
let url = payload.url;
647+
if (!url) {
648+
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
649+
url = tab?.url ?? "";
650+
}
651+
652+
const response = await fetch(`${API_BASE_URL}/api/feedback`, {
653+
method: "POST",
654+
headers: apiHeaders(),
655+
body: JSON.stringify({
656+
url,
657+
selector: payload.selector ?? "body",
658+
comment: payload.comment,
659+
selectedText: payload.selectedText,
660+
}),
661+
});
662+
663+
if (!response.ok) {
664+
chrome.runtime.sendMessage({
665+
type: "FEEDBACK_STATUS",
666+
payload: { status: "error", message: "Failed to submit feedback" },
667+
}).catch(() => {});
668+
return;
669+
}
670+
671+
const result = (await response.json()) as {
672+
success: boolean;
673+
data?: { id: string; category: string; confidence: number };
674+
};
675+
676+
if (result.success && result.data) {
677+
chrome.runtime.sendMessage({
678+
type: "FEEDBACK_STATUS",
679+
payload: {
680+
status: "done",
681+
id: result.data.id,
682+
category: result.data.category,
683+
},
684+
}).catch(() => {});
685+
686+
trackEvent("feedback_submitted", {
687+
url,
688+
selector: payload.selector,
689+
category: result.data.category,
690+
});
691+
}
692+
} catch {
693+
chrome.runtime.sendMessage({
694+
type: "FEEDBACK_STATUS",
695+
payload: { status: "error", message: "Could not connect to feedback service" },
696+
}).catch(() => {});
697+
}
698+
}
699+
624700
async function handleHighlightAsk(
625701
selectedText: string,
626702
context: string,
627-
tabId?: number
703+
tabId?: number,
704+
selector?: string,
705+
url?: string
628706
) {
629707
if (!tabId) return;
630708

@@ -640,7 +718,7 @@ export default defineBackground(() => {
640718
type: "HIGHLIGHT_ANSWER",
641719
payload: { answer: onDeviceAnswer },
642720
});
643-
trackEvent("highlight_ask", { text: selectedText.slice(0, 100), source: "on-device" });
721+
trackEvent("highlight_ask", { url, selector, text: selectedText.slice(0, 100), source: "on-device" });
644722
return;
645723
}
646724

@@ -655,7 +733,7 @@ export default defineBackground(() => {
655733
type: "HIGHLIGHT_ANSWER",
656734
payload: { answer },
657735
});
658-
trackEvent("highlight_ask", { text: selectedText.slice(0, 100), source: "cloud" });
736+
trackEvent("highlight_ask", { url, selector, text: selectedText.slice(0, 100), source: "cloud" });
659737
} else {
660738
chrome.tabs.sendMessage(tabId, {
661739
type: "ERROR",

0 commit comments

Comments
 (0)