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
29 changes: 28 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions entrypoints/sidepanel/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import TodayCard from './components/TodayCard';
import UsageBudgetCard from './components/UsageBudgetCard';
import ActiveConversation from './components/ActiveConversation';
import ConversationList from './components/ConversationList';
import FeedbackWidget from './components/FeedbackWidget';

export default function App() {
const {
Expand Down Expand Up @@ -77,6 +78,8 @@ export default function App() {
<CollapsibleSection title="History" storageKey="history" defaultOpen>
<ConversationList conversations={conversations} />
</CollapsibleSection>

<FeedbackWidget />
</div>
);
}
105 changes: 105 additions & 0 deletions entrypoints/sidepanel/components/FeedbackWidget.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// entrypoints/sidepanel/components/FeedbackWidget.tsx
// Inline feedback widget for the side panel footer.
// Three states: idle (trigger link) -> open (textarea + send) -> sent (confirmation).
// POSTs to Formspree; version is read once at module load from the manifest.

import React, { useState, useEffect } from 'react';

const FORMSPREE_URL = 'https://formspree.io/f/xkokqgal';
const MAX_MESSAGE_LENGTH = 2000;
const VERSION = chrome.runtime.getManifest().version;

type WidgetState = 'idle' | 'open' | 'sending' | 'sent' | 'error';

export default function FeedbackWidget(): React.JSX.Element {
const [state, setState] = useState<WidgetState>('idle');
const [message, setMessage] = useState('');

// Auto-reset to idle 4s after a successful send. Cleans up on unmount.
useEffect(() => {
if (state !== 'sent') return;
const timer = setTimeout(() => setState('idle'), 4000);
return () => clearTimeout(timer);
}, [state]);

const trimmed = message.trim();

async function submit(): Promise<void> {
if (!trimmed || trimmed.length > MAX_MESSAGE_LENGTH) return;
setState('sending');
try {
const res = await fetch(FORMSPREE_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
message: trimmed,
version: VERSION,
_subject: `Saar Feedback v${VERSION}`,
}),
});
if (res.ok) {
setMessage('');
setState('sent');
} else {
setState('error');
}
} catch {
setState('error');
}
}

function reset(): void {
setMessage('');
setState('idle');
}

if (state === 'idle') {
return (
<div className="lco-dash-feedback">
<button className="lco-dash-feedback-trigger" onClick={() => setState('open')}>
Having an issue? Send feedback
</button>
</div>
);
}

if (state === 'sent') {
return (
<div className="lco-dash-feedback">
<p className="lco-dash-feedback-sent">Sent. We'll fix it fast.</p>
</div>
);
}

return (
<div className="lco-dash-feedback">
{state === 'error' && (
<p className="lco-dash-feedback-error">Failed to send. Try again?</p>
)}
<textarea
className="lco-dash-feedback-textarea"
placeholder="What's wrong? Describe the issue."
value={message}
onChange={e => setMessage(e.target.value)}
rows={3}
maxLength={MAX_MESSAGE_LENGTH}
autoFocus
/>
<div className="lco-dash-feedback-actions">
<button className="lco-dash-feedback-cancel" onClick={reset}>
Cancel
</button>
<button
className="lco-dash-feedback-send"
onClick={submit}
disabled={!trimmed || state === 'sending'}
>
{state === 'sending' ? 'Sending...' : 'Send'}
</button>
</div>
</div>
);
}
132 changes: 132 additions & 0 deletions entrypoints/sidepanel/dashboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -571,6 +571,132 @@ body {
font-size: 11px;
}

/* ── Feedback widget ────────────────────────────────────────────────────────── */
/* Inline feedback form in the side panel footer. Idle: single muted link. */
/* Open: textarea + cancel/send. Sent: green confirmation, auto-resets in 4s. */

.lco-dash-feedback {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--lco-border);
padding-bottom: 4px;
}

.lco-dash-feedback-trigger {
width: 100%;
background: none;
border: none;
cursor: pointer;
font-family: var(--lco-font);
font-size: 12px;
color: var(--lco-text-muted);
padding: 4px 0;
text-align: center;
border-radius: var(--lco-radius-sm);
transition: color var(--lco-transition);
}

.lco-dash-feedback-trigger:hover {
color: var(--lco-accent);
}

.lco-dash-feedback-trigger:focus-visible {
outline: 2px solid var(--lco-accent);
outline-offset: 2px;
}

.lco-dash-feedback-textarea {
width: 100%;
padding: 8px 10px;
background: var(--lco-bg-card);
border: 1px solid var(--lco-border);
border-radius: var(--lco-radius);
font-family: var(--lco-font);
font-size: 12px;
color: var(--lco-text);
line-height: 1.5;
resize: none;
display: block;
transition: border-color var(--lco-transition);
}

.lco-dash-feedback-textarea:focus {
outline: none;
border-color: var(--lco-accent);
}

.lco-dash-feedback-textarea::placeholder {
color: var(--lco-text-muted);
}

.lco-dash-feedback-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 8px;
}

.lco-dash-feedback-cancel {
background: none;
border: none;
cursor: pointer;
font-family: var(--lco-font);
font-size: 12px;
color: var(--lco-text-muted);
padding: 4px 8px;
border-radius: var(--lco-radius-sm);
transition: color var(--lco-transition);
}

.lco-dash-feedback-cancel:hover {
color: var(--lco-text);
}

.lco-dash-feedback-cancel:focus-visible {
outline: 2px solid var(--lco-accent);
outline-offset: 2px;
}

.lco-dash-feedback-send {
background: var(--lco-accent);
color: #ffffff;
border: none;
cursor: pointer;
font-family: var(--lco-font);
font-size: 12px;
font-weight: 500;
padding: 4px 12px;
border-radius: var(--lco-radius-sm);
transition: opacity var(--lco-transition);
}

.lco-dash-feedback-send:hover:not(:disabled) {
opacity: 0.85;
}

.lco-dash-feedback-send:disabled {
opacity: 0.45;
cursor: not-allowed;
}

.lco-dash-feedback-send:focus-visible {
outline: 2px solid var(--lco-accent);
outline-offset: 2px;
}

.lco-dash-feedback-sent {
font-size: 12px;
color: var(--lco-health-green);
text-align: center;
padding: 4px 0;
}

.lco-dash-feedback-error {
font-size: 12px;
color: var(--lco-health-red);
margin-bottom: 8px;
}

/* ── Animations ─────────────────────────────────────────────────────────────── */

@keyframes lco-dash-slide-in {
Expand Down Expand Up @@ -607,4 +733,10 @@ body {
.lco-dash-skeleton {
animation: none;
}
.lco-dash-feedback-trigger,
.lco-dash-feedback-textarea,
.lco-dash-feedback-cancel,
.lco-dash-feedback-send {
transition: none;
}
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
},
"devDependencies": {
"@playwright/test": "^1.59.1",
"@testing-library/react": "^16.3.2",
"@types/chrome": "^0.1.39",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
Expand Down
1 change: 1 addition & 0 deletions site/privacy.html
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ <h2>Permissions used</h2>

<h2>Third-party services</h2>
<p>Saar does not integrate with any third-party analytics, advertising, or data collection service. The BPE tokenizer vocabulary (<code>claude.json</code>) is bundled inside the extension and does not make network requests.</p>
<p><strong>Feedback submissions (optional):</strong> The side panel includes a voluntary feedback form. If you choose to submit feedback, your typed message and the extension version number are sent to <a href="https://formspree.io">Formspree</a> (formspree.io), a third-party form processing service. No other data is included. Submission is entirely opt-in and requires an explicit click. Formspree's privacy policy is at <a href="https://formspree.io/legal/privacy-policy">formspree.io/legal/privacy-policy</a>.</p>

<h2>Open source</h2>
<p>Saar is fully open source. You can inspect every line of code at <a href="https://github.com/OpenCodeIntel/lco">github.com/OpenCodeIntel/lco</a>.</p>
Expand Down
Loading
Loading