mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(feedback): route failed-run Report issue through the offline handoff (#429)
Apply the always-on handoff to the failed-run Report issue button too. Un-gate the two buttons in tool-page.tsx from the analytics toggle, and extend the dialog offline handoff to source=failed_job, prefilling the GitHub issue with the tool id and error category so it is actionable even with an empty message. Follows #428. Claude-Session: https://claude.ai/code/session_01XVrHKXwzZDWBWgkGQdPZ3A
This commit is contained in:
@@ -118,6 +118,14 @@ export function FeedbackDialog({
|
|||||||
const isAdminInstall = source === "admin_installer";
|
const isAdminInstall = source === "admin_installer";
|
||||||
const isSearchMiss = source === "search_miss";
|
const isSearchMiss = source === "search_miss";
|
||||||
const isGlobal = source === "global";
|
const isGlobal = source === "global";
|
||||||
|
const isFailedJob = source === "failed_job";
|
||||||
|
// For a failed run, thread the tool and error into the offline handoff so the
|
||||||
|
// GitHub issue is actionable even if the message box is left empty.
|
||||||
|
const handoffMessage = isFailedJob
|
||||||
|
? [`Tool: ${toolId ?? "unknown"}`, `Error category: ${errorCategory ?? "unknown"}`, "", message]
|
||||||
|
.join("\n")
|
||||||
|
.trim()
|
||||||
|
: message;
|
||||||
const canSubmit = Boolean(
|
const canSubmit = Boolean(
|
||||||
message.trim() || sentiment || feedbackType !== "other" || isAdminInstall,
|
message.trim() || sentiment || feedbackType !== "other" || isAdminInstall,
|
||||||
);
|
);
|
||||||
@@ -206,13 +214,13 @@ export function FeedbackDialog({
|
|||||||
|
|
||||||
{submitted ? (
|
{submitted ? (
|
||||||
<div className="p-6 space-y-4">
|
<div className="p-6 space-y-4">
|
||||||
{isGlobal && !accepted ? (
|
{(isGlobal || isFailedJob) && !accepted ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="text-sm text-foreground">{t.feedback.offlineDescription}</p>
|
<p className="text-sm text-foreground">{t.feedback.offlineDescription}</p>
|
||||||
<p className="text-xs text-muted-foreground">{t.feedback.offlinePublicNote}</p>
|
<p className="text-xs text-muted-foreground">{t.feedback.offlinePublicNote}</p>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<a
|
<a
|
||||||
href={buildFeedbackGithubUrl(message)}
|
href={buildFeedbackGithubUrl(handoffMessage)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="w-full text-center py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90"
|
className="w-full text-center py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90"
|
||||||
@@ -220,7 +228,7 @@ export function FeedbackDialog({
|
|||||||
{t.feedback.offlineGithubButton}
|
{t.feedback.offlineGithubButton}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href={buildFeedbackMailtoUrl(message)}
|
href={buildFeedbackMailtoUrl(handoffMessage)}
|
||||||
className="w-full text-center py-2 rounded-lg border border-border text-sm text-muted-foreground hover:bg-muted hover:text-foreground"
|
className="w-full text-center py-2 rounded-lg border border-border text-sm text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
>
|
>
|
||||||
{t.feedback.offlineEmailButton}
|
{t.feedback.offlineEmailButton}
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ import { ICON_MAP } from "@/lib/icon-map";
|
|||||||
import { MULTI_FILE_TOOLS } from "@/lib/tool-display-modes";
|
import { MULTI_FILE_TOOLS } from "@/lib/tool-display-modes";
|
||||||
import { getToolName } from "@/lib/tool-i18n";
|
import { getToolName } from "@/lib/tool-i18n";
|
||||||
import { getToolRegistryEntry } from "@/lib/tool-registry";
|
import { getToolRegistryEntry } from "@/lib/tool-registry";
|
||||||
import { useAnalyticsStore } from "@/stores/analytics-store";
|
|
||||||
import { useBase64Store } from "@/stores/base64-store";
|
import { useBase64Store } from "@/stores/base64-store";
|
||||||
import { useCollageStore } from "@/stores/collage-store";
|
import { useCollageStore } from "@/stores/collage-store";
|
||||||
import { useDuplicateStore } from "@/stores/duplicate-store";
|
import { useDuplicateStore } from "@/stores/duplicate-store";
|
||||||
@@ -327,8 +326,6 @@ export function ToolPage() {
|
|||||||
);
|
);
|
||||||
const [mobileSettingsOpen, setMobileSettingsOpen] = useState(false);
|
const [mobileSettingsOpen, setMobileSettingsOpen] = useState(false);
|
||||||
const [failedFeedbackOpen, setFailedFeedbackOpen] = useState(false);
|
const [failedFeedbackOpen, setFailedFeedbackOpen] = useState(false);
|
||||||
const analyticsConfig = useAnalyticsStore((s) => s.config);
|
|
||||||
const feedbackEnabled = Boolean(analyticsConfig?.enabled);
|
|
||||||
const [previewTransform, setPreviewTransform] = useState<PreviewTransform | null>(null);
|
const [previewTransform, setPreviewTransform] = useState<PreviewTransform | null>(null);
|
||||||
const [previewFilter, setPreviewFilter] = useState<string>("");
|
const [previewFilter, setPreviewFilter] = useState<string>("");
|
||||||
const [imageWrapperStyle, setImageWrapperStyle] = useState<React.CSSProperties | null>(null);
|
const [imageWrapperStyle, setImageWrapperStyle] = useState<React.CSSProperties | null>(null);
|
||||||
@@ -791,7 +788,6 @@ export function ToolPage() {
|
|||||||
>
|
>
|
||||||
{t.toolPage.tryDifferentFile}
|
{t.toolPage.tryDifferentFile}
|
||||||
</button>
|
</button>
|
||||||
{feedbackEnabled && (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setFailedFeedbackOpen(true)}
|
onClick={() => setFailedFeedbackOpen(true)}
|
||||||
@@ -800,7 +796,6 @@ export function ToolPage() {
|
|||||||
<MessageSquare className="h-3.5 w-3.5" />
|
<MessageSquare className="h-3.5 w-3.5" />
|
||||||
{t.feedback.reportIssue}
|
{t.feedback.reportIssue}
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1120,7 +1115,7 @@ export function ToolPage() {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{feedbackEnabled && currentEntry?.status === "failed" && (
|
{currentEntry?.status === "failed" && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setFailedFeedbackOpen(true)}
|
onClick={() => setFailedFeedbackOpen(true)}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ afterEach(() => {
|
|||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("FeedbackDialog global off-state handoff", () => {
|
describe("FeedbackDialog off-state handoff", () => {
|
||||||
it("reveals GitHub and email handoff when the server does not record the feedback", async () => {
|
it("reveals GitHub and email handoff when the server does not record the feedback", async () => {
|
||||||
submitFeedback.mockResolvedValue({ ok: true, accepted: false });
|
submitFeedback.mockResolvedValue({ ok: true, accepted: false });
|
||||||
render(<FeedbackDialog open source="global" onClose={vi.fn()} />);
|
render(<FeedbackDialog open source="global" onClose={vi.fn()} />);
|
||||||
@@ -50,6 +50,33 @@ describe("FeedbackDialog global off-state handoff", () => {
|
|||||||
expect(screen.queryByText("Thanks for the feedback.")).toBeNull();
|
expect(screen.queryByText("Thanks for the feedback.")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("threads tool and error into the handoff for a failed run", async () => {
|
||||||
|
submitFeedback.mockResolvedValue({ ok: true, accepted: false });
|
||||||
|
render(
|
||||||
|
<FeedbackDialog
|
||||||
|
open
|
||||||
|
source="failed_job"
|
||||||
|
toolId="pdf-compress"
|
||||||
|
jobStatus="failed"
|
||||||
|
errorCategory="timeout"
|
||||||
|
onClose={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(MESSAGE_PLACEHOLDER), {
|
||||||
|
target: { value: "It hung on a 50MB file" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Send feedback" }));
|
||||||
|
|
||||||
|
const githubLink = await screen.findByRole("link", { name: "Open a GitHub issue" });
|
||||||
|
const details =
|
||||||
|
new URL(githubLink.getAttribute("href") ?? "").searchParams.get("details") ?? "";
|
||||||
|
expect(details).toContain("pdf-compress");
|
||||||
|
expect(details).toContain("timeout");
|
||||||
|
expect(details).toContain("It hung on a 50MB file");
|
||||||
|
expect(screen.queryByText("Thanks for the feedback.")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("shows the normal thanks when the feedback is recorded", async () => {
|
it("shows the normal thanks when the feedback is recorded", async () => {
|
||||||
submitFeedback.mockResolvedValue({ ok: true, accepted: true });
|
submitFeedback.mockResolvedValue({ ok: true, accepted: true });
|
||||||
render(<FeedbackDialog open source="global" onClose={vi.fn()} />);
|
render(<FeedbackDialog open source="global" onClose={vi.fn()} />);
|
||||||
|
|||||||
Reference in New Issue
Block a user