From c4c38ba20fc311ad2c89b7016301cabb08c3a4b9 Mon Sep 17 00:00:00 2001 From: Just_Anniee <145839789+Annieeeee11@users.noreply.github.com> Date: Sat, 18 Jul 2026 18:03:00 +0530 Subject: [PATCH 01/19] feat: revamp global settings page and remove PR board Replace the settings cards with a Figma-aligned settings layout (theme, Connect Mobile, updates, feedback), move sidebar entry points into Settings, and drop the standalone /prs board in favor of session inspector PRs. --- frontend/e2e/multi-pr.spec.ts | 21 +- .../src/renderer/components/ConfirmDialog.tsx | 2 +- .../components/ConnectMobileModal.tsx | 253 ++++++++++++------ .../components/CreateProjectAgentSheet.tsx | 2 +- .../renderer/components/CreateProjectFlow.tsx | 4 +- .../components/GlobalSettingsForm.test.tsx | 247 ++++++++++++----- .../components/GlobalSettingsForm.tsx | 118 +++++--- .../src/renderer/components/NewTaskDialog.tsx | 2 +- .../OrchestratorReplacementDialog.tsx | 2 +- .../components/PullRequestsPage.test.tsx | 95 ------- .../renderer/components/PullRequestsPage.tsx | 185 ------------- .../components/ReportProblemDialog.tsx | 220 --------------- .../components/RestoreUnavailableDialog.tsx | 2 +- .../src/renderer/components/SessionView.tsx | 4 +- .../src/renderer/components/SessionsBoard.tsx | 4 +- .../src/renderer/components/Sidebar.test.tsx | 135 +--------- frontend/src/renderer/components/Sidebar.tsx | 166 ++---------- .../renderer/components/UpdatesSection.tsx | 193 ------------- .../renderer/components/WindowTitlebar.tsx | 4 +- .../settings/GeneralSettingsSection.tsx | 31 +++ .../settings/ReportProblemDialog.tsx | 223 +++++++++++++++ .../settings/SettingsOptionMenu.tsx | 72 +++++ .../components/settings/SettingsPageShell.tsx | 12 + .../components/settings/SettingsPanel.tsx | 27 ++ .../components/settings/SettingsRow.tsx | 53 ++++ .../components/settings/SettingsSection.tsx | 11 + .../components/settings/UpdatesSection.tsx | 189 +++++++++++++ .../src/renderer/components/ui/dialog.tsx | 2 +- frontend/src/renderer/components/ui/sheet.tsx | 2 +- frontend/src/renderer/lib/theme.ts | 15 +- frontend/src/renderer/routeTree.gen.ts | 21 -- frontend/src/renderer/routes/_shell.index.tsx | 8 +- frontend/src/renderer/routes/_shell.prs.tsx | 6 - frontend/src/renderer/routes/_shell.tsx | 30 ++- frontend/src/renderer/stores/ui-store.ts | 45 +++- frontend/src/renderer/styles.css | 148 +++++++++- frontend/src/styles/tokens.css | 95 ++++++- 37 files changed, 1393 insertions(+), 1256 deletions(-) delete mode 100644 frontend/src/renderer/components/PullRequestsPage.test.tsx delete mode 100644 frontend/src/renderer/components/PullRequestsPage.tsx delete mode 100644 frontend/src/renderer/components/ReportProblemDialog.tsx delete mode 100644 frontend/src/renderer/components/UpdatesSection.tsx create mode 100644 frontend/src/renderer/components/settings/GeneralSettingsSection.tsx create mode 100644 frontend/src/renderer/components/settings/ReportProblemDialog.tsx create mode 100644 frontend/src/renderer/components/settings/SettingsOptionMenu.tsx create mode 100644 frontend/src/renderer/components/settings/SettingsPageShell.tsx create mode 100644 frontend/src/renderer/components/settings/SettingsPanel.tsx create mode 100644 frontend/src/renderer/components/settings/SettingsRow.tsx create mode 100644 frontend/src/renderer/components/settings/SettingsSection.tsx create mode 100644 frontend/src/renderer/components/settings/UpdatesSection.tsx delete mode 100644 frontend/src/renderer/routes/_shell.prs.tsx diff --git a/frontend/e2e/multi-pr.spec.ts b/frontend/e2e/multi-pr.spec.ts index de8eeac809..7d5891b194 100644 --- a/frontend/e2e/multi-pr.spec.ts +++ b/frontend/e2e/multi-pr.spec.ts @@ -3,7 +3,7 @@ import { expect, test } from "@playwright/test"; // dev:web (VITE_NO_ELECTRON=1) serves lib/mock-data.ts. The api-gateway // workspace owns a "stacked-auth" session ("auth stack") carrying three PRs: // #41 open, #42 draft, #40 merged — the multi-PR-per-session case this suite -// guards across the inspector rail and the PR board. +// guards across the inspector rail. test("the inspector rail stacks every PR a session owns, actionable-first", async ({ page }) => { await page.goto("/"); @@ -22,22 +22,3 @@ test("the inspector rail stacks every PR a session owns, actionable-first", asyn const cards = prSection.locator("text=/^PR #\\d+$/"); await expect(cards).toHaveText(["PR #41", "PR #42", "PR #40"]); }); - -test("the PR board lists one row per attributed PR, actionable PRs first", async ({ page }) => { - await page.goto("/#/prs"); - - await expect(page.getByRole("heading", { name: "Pull requests" })).toBeVisible(); - - // stacked-auth's three PRs keep actionable-first order across the whole board: - // open #41 before draft #42, and the lone merged PR (#40) sinks to the bottom. - const numbers = await page.locator("tbody tr td:first-child").allTextContents(); - expect(numbers.indexOf("#41")).toBeLessThan(numbers.indexOf("#42")); - expect(numbers.indexOf("#42")).toBeLessThan(numbers.indexOf("#40")); - expect(numbers.indexOf("#40")).toBe(numbers.length - 1); - - // Open/draft rows are actionable; the merged row is not. - const mergedRow = page.locator("tbody tr", { hasText: "#40" }); - await expect(mergedRow.getByRole("button", { name: "Merge" })).toHaveCount(0); - const openRow = page.locator("tbody tr", { hasText: "#41" }); - await expect(openRow.getByRole("button", { name: "Merge" })).toBeVisible(); -}); diff --git a/frontend/src/renderer/components/ConfirmDialog.tsx b/frontend/src/renderer/components/ConfirmDialog.tsx index 99467da2e7..553b99acc5 100644 --- a/frontend/src/renderer/components/ConfirmDialog.tsx +++ b/frontend/src/renderer/components/ConfirmDialog.tsx @@ -30,7 +30,7 @@ export function ConfirmDialog({ return ( - +
diff --git a/frontend/src/renderer/components/ConnectMobileModal.tsx b/frontend/src/renderer/components/ConnectMobileModal.tsx index 7be7de0f68..9cbeadef56 100644 --- a/frontend/src/renderer/components/ConnectMobileModal.tsx +++ b/frontend/src/renderer/components/ConnectMobileModal.tsx @@ -1,14 +1,24 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useState } from "react"; -import { Loader2 } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { Check, Copy, Info, Loader2, X } from "lucide-react"; import { QRCodeSVG } from "qrcode.react"; import { apiClient, apiErrorMessage } from "../lib/api-client"; -import { Button } from "./ui/button"; -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog"; +import { cn } from "../lib/utils"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "./ui/dialog"; import { Switch } from "./ui/switch"; export const mobileStatusQueryKey = ["mobile-status"] as const; +/** Matches `--size-settings-mobile-qr-code`; qrcode.react needs a px number. */ +const QR_CODE_SIZE = 204; + interface MobileStatus { enabled: boolean; host: string; @@ -45,6 +55,13 @@ interface ConnectMobileModalProps { export function ConnectMobileModal({ open, onOpenChange }: ConnectMobileModalProps) { const queryClient = useQueryClient(); const [copied, setCopied] = useState(false); + const copiedTimeoutRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (copiedTimeoutRef.current) clearTimeout(copiedTimeoutRef.current); + }; + }, []); const query = useQuery({ queryKey: mobileStatusQueryKey, @@ -87,15 +104,27 @@ export function ConnectMobileModal({ open, onOpenChange }: ConnectMobileModalPro const enabled = status?.enabled ?? false; const busy = enable.isPending || disable.isPending || regenerate.isPending; + const clearActionErrors = () => { + enable.reset(); + disable.reset(); + regenerate.reset(); + }; + const copyPassword = async () => { if (!status?.password) return; - await navigator.clipboard.writeText(status.password); - setCopied(true); - setTimeout(() => setCopied(false), 1500); + try { + await navigator.clipboard.writeText(status.password); + setCopied(true); + if (copiedTimeoutRef.current) clearTimeout(copiedTimeoutRef.current); + copiedTimeoutRef.current = setTimeout(() => setCopied(false), 1500); + } catch { + // Clipboard can reject (permissions / non-secure context). + } }; const onToggle = (next: boolean) => { if (busy) return; + clearActionErrors(); if (next) enable.mutate(); else disable.mutate(); }; @@ -108,85 +137,155 @@ export function ConnectMobileModal({ open, onOpenChange }: ConnectMobileModalPro return ( - - - Connect Mobile - Pair the Agent Orchestrator mobile app with this desktop over your LAN. - - - {query.isLoading ? ( -

Checking status…

- ) : query.isError ? ( -

- {query.error instanceof Error ? query.error.message : "Failed to load mobile status."} -

- ) : status ? ( -
- {/* Toggle row — always visible. Flipping it starts/stops the bridge. */} -
-
- Enable mobile - - Open a password-protected port on your local network so your phone can connect. - -
-
- {busy && } - -
-
- - {actionError &&

{actionError}

} + + + + +
+ + + Connect Mobile + + + Pair the Agent Orchestrator mobile app with this desktop over your LAN. + + - {/* Pairing details — revealed below the toggle only when enabled. */} - {enabled && ( -
-
- + {query.isLoading ? ( +

Checking status…

+ ) : query.isError ? ( +

+ {query.error instanceof Error ? query.error.message : "Failed to load mobile status."} +

+ ) : status ? ( +
+ {/* Toggle row — always visible. Flipping it starts/stops the bridge. */} +
+
+ + Enable mobile + + + Open a password-protected port on your local network so your phone can connect. +
- -
- - - {status.host}:{status.port} - - - -
- {status.password} - -
-
+
+ {busy &&
+
- {status.warning && ( -

- {status.warning} -

+ {actionError &&

{actionError}

} + + {/* Pairing details — expand/collapse with the enable toggle. */} +
+
+
+
+
+ +
+

Scan to pair

+
+ + {status.warning && ( +

+

+ )} + +
+
+ Address + + {status.host}:{status.port} + +
+
+ Password +
+ {status.password} + +
+
+
-
- + +
- )} -
- ) : null} +
+ ) : null} +
); } - -function Row({ label, children }: { label: string; children: React.ReactNode }) { - return ( -
- {label} - {children} -
- ); -} diff --git a/frontend/src/renderer/components/CreateProjectAgentSheet.tsx b/frontend/src/renderer/components/CreateProjectAgentSheet.tsx index f49431f7f3..45c25fafa9 100644 --- a/frontend/src/renderer/components/CreateProjectAgentSheet.tsx +++ b/frontend/src/renderer/components/CreateProjectAgentSheet.tsx @@ -148,7 +148,7 @@ export function CreateProjectAgentSheet({ return ( !isBusy && onOpenChange(next)}> - +
diff --git a/frontend/src/renderer/components/CreateProjectFlow.tsx b/frontend/src/renderer/components/CreateProjectFlow.tsx index c41c33adc4..d567a33ede 100644 --- a/frontend/src/renderer/components/CreateProjectFlow.tsx +++ b/frontend/src/renderer/components/CreateProjectFlow.tsx @@ -239,7 +239,7 @@ function CreateProjectModeDialog({ return ( - +
@@ -365,7 +365,7 @@ function CreateProjectFolderDialog({ return ( - +
- } - /> -
-
+ <> + + navigate({ to: "/" })}> + setMobileOpen(true)} /> - -
-
-
+ + setFeedbackOpen(true)} /> + + + + + + + + + + ); +} + +function LinkedInIcon({ className, ...props }: SVGProps) { + return ( + + + + ); +} + +function XSocialIcon({ className, ...props }: SVGProps) { + return ( + + + ); } diff --git a/frontend/src/renderer/components/NewTaskDialog.tsx b/frontend/src/renderer/components/NewTaskDialog.tsx index a88d7b4154..94c0514648 100644 --- a/frontend/src/renderer/components/NewTaskDialog.tsx +++ b/frontend/src/renderer/components/NewTaskDialog.tsx @@ -119,7 +119,7 @@ export function NewTaskDialog({ open, projectId, onCreated, onOpenChange }: NewT return ( - +
diff --git a/frontend/src/renderer/components/OrchestratorReplacementDialog.tsx b/frontend/src/renderer/components/OrchestratorReplacementDialog.tsx index 8bfedf0b54..a67a1f1292 100644 --- a/frontend/src/renderer/components/OrchestratorReplacementDialog.tsx +++ b/frontend/src/renderer/components/OrchestratorReplacementDialog.tsx @@ -35,7 +35,7 @@ export function OrchestratorReplacementDialog({ return ( - +
diff --git a/frontend/src/renderer/components/PullRequestsPage.test.tsx b/frontend/src/renderer/components/PullRequestsPage.test.tsx deleted file mode 100644 index 90a770bb63..0000000000 --- a/frontend/src/renderer/components/PullRequestsPage.test.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen, waitFor, within } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { PullRequestsPage } from "./PullRequestsPage"; -import type { PRState, PullRequestFacts, WorkspaceSession, WorkspaceSummary } from "../types/workspace"; - -const { navigateMock, postMock, useWorkspaceQueryMock } = vi.hoisted(() => ({ - navigateMock: vi.fn(), - postMock: vi.fn(), - useWorkspaceQueryMock: vi.fn(), -})); - -vi.mock("@tanstack/react-router", () => ({ useNavigate: () => navigateMock })); -vi.mock("../hooks/useWorkspaceQuery", () => ({ - useWorkspaceQuery: () => useWorkspaceQueryMock(), - workspaceQueryKey: ["workspaces"], -})); -vi.mock("../lib/api-client", () => ({ - apiClient: { POST: (...args: unknown[]) => postMock(...args) }, - apiErrorMessage: (e: unknown) => (e instanceof Error ? e.message : "error"), -})); - -const pr = (n: number, state: PRState): PullRequestFacts => ({ - url: `https://example.com/pr/${n}`, - number: n, - state, - ci: "passing", - review: "approved", - mergeability: "mergeable", - reviewComments: false, - updatedAt: "2026-06-15T00:00:00Z", -}); - -const session = (id: string, prs: PullRequestFacts[]): WorkspaceSession => ({ - id, - workspaceId: "proj-1", - workspaceName: "my-app", - title: id, - provider: "claude-code", - kind: "worker", - branch: "feat/ns", - status: "review_pending", - updatedAt: "2026-06-15T00:00:00Z", - prs, -}); - -function setWorkspaces(sessions: WorkspaceSession[]) { - const data: WorkspaceSummary[] = [{ id: "proj-1", name: "my-app", path: "/p", sessions }]; - useWorkspaceQueryMock.mockReturnValue({ data, isError: false, isLoading: false }); -} - -function renderPage() { - render( - - - , - ); -} - -beforeEach(() => { - navigateMock.mockReset(); - postMock.mockReset().mockResolvedValue({ data: { method: "squash" }, error: undefined }); -}); - -afterEach(() => vi.restoreAllMocks()); - -describe("PullRequestsPage", () => { - it("renders one row per PR across sessions, actionable PRs first", () => { - setWorkspaces([session("auth", [pr(41, "open"), pr(42, "draft"), pr(40, "merged")])]); - renderPage(); - - const rows = screen.getAllByRole("row").slice(1); // drop header - const numbers = rows.map((r) => within(r).getByText(/^#\d+$/).textContent); - expect(numbers).toEqual(["#41", "#42", "#40"]); - }); - - it("merges the PR by its own number, not the session's", async () => { - setWorkspaces([session("auth", [pr(41, "open"), pr(42, "draft")])]); - renderPage(); - const user = userEvent.setup(); - - const childRow = screen.getByText("#42").closest("tr")!; - await user.click(within(childRow).getByRole("button", { name: "Merge" })); - - await waitFor(() => expect(postMock).toHaveBeenCalledTimes(1)); - expect(postMock).toHaveBeenCalledWith("/api/v1/prs/{id}/merge", { params: { path: { id: "42" } } }); - }); - - it("shows an empty state when no session has a PR", () => { - setWorkspaces([session("idle", [])]); - renderPage(); - expect(screen.getByText("No open pull requests.")).toBeInTheDocument(); - }); -}); diff --git a/frontend/src/renderer/components/PullRequestsPage.tsx b/frontend/src/renderer/components/PullRequestsPage.tsx deleted file mode 100644 index 09bf1b9719..0000000000 --- a/frontend/src/renderer/components/PullRequestsPage.tsx +++ /dev/null @@ -1,185 +0,0 @@ -import { useNavigate } from "@tanstack/react-router"; -import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query"; -import { useState } from "react"; -import { apiClient, apiErrorMessage } from "../lib/api-client"; -import { useWorkspaceQuery, workspaceQueryKey } from "../hooks/useWorkspaceQuery"; -import { - sessionScmSummaryQueryKey, - sessionScmSummaryQueryOptions, - type SessionPRSummary, -} from "../hooks/useSessionScmSummary"; -import { comparePRDisplaySummaries, prDiffSummary, sessionPRDisplaySummaries } from "../lib/pr-display"; -import type { WorkspaceSession } from "../types/workspace"; -import { DashboardSubhead } from "./DashboardSubhead"; -import { Badge } from "./ui/badge"; -import { Button } from "./ui/button"; -import { PRSummaryParts } from "./PRSummaryDisplay"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./ui/table"; -import { cn } from "../lib/utils"; - -type PRState = SessionPRSummary["state"]; - -const stateTone: Record = { - open: "border-success/40 bg-success/10 text-success", - draft: "border-border bg-raised text-muted-foreground", - merged: "border-accent/40 bg-accent-weak text-accent", - closed: "border-error/40 bg-error/10 text-error", -}; - -type PRRow = { - pr: SessionPRSummary; - session: WorkspaceSession; -}; - -// The PR board, ported from agent-orchestrator's PullRequestsPage. One row per -// attributed PR — a session can own several (a stack or independent PRs), so we -// flatMap the session's prs list rather than assuming one. Actions hit -// /prs/{number}/merge and /resolve-comments. Per-PR CI/review facts also live on -// the session route's inspector. -export function PullRequestsPage() { - const navigate = useNavigate(); - const workspaceQuery = useWorkspaceQuery(); - const sessions = (workspaceQuery.data ?? []).flatMap((w) => w.sessions); - const prQueries = useQueries({ - queries: sessions.map((session) => sessionScmSummaryQueryOptions(session.id)), - }); - const rows: PRRow[] = sessions - .flatMap((session, index) => - sessionPRDisplaySummaries(session, prQueries[index]?.data).map((pr) => ({ pr, session })), - ) - .sort((a, b) => comparePRDisplaySummaries(a.pr, b.pr)); - - return ( -
- - -
- {rows.length === 0 ? ( -

No open pull requests.

- ) : ( - - - - PR - Worker - State - Actions - - - - {rows.map((row) => ( - - void navigate({ - to: "/projects/$projectId/sessions/$sessionId", - params: { projectId: row.session.workspaceId, sessionId: row.session.id }, - }) - } - /> - ))} - -
- )} -
-
- ); -} - -function PRRowView({ row, onOpen }: { row: PRRow; onOpen: () => void }) { - const queryClient = useQueryClient(); - const [note, setNote] = useState<{ ok: boolean; text: string } | null>(null); - const refresh = () => { - void queryClient.invalidateQueries({ queryKey: workspaceQueryKey }); - void queryClient.invalidateQueries({ queryKey: sessionScmSummaryQueryKey() }); - }; - - const merge = useMutation({ - mutationFn: async () => { - const { data, error } = await apiClient.POST("/api/v1/prs/{id}/merge", { - params: { path: { id: String(row.pr.number) } }, - }); - if (error) throw new Error(apiErrorMessage(error)); - return data; - }, - onSuccess: (data) => { - setNote({ ok: true, text: `merged (${data?.method ?? "squash"})` }); - refresh(); - }, - onError: (e) => setNote({ ok: false, text: e instanceof Error ? e.message : "merge failed" }), - }); - - const resolve = useMutation({ - mutationFn: async () => { - const { error } = await apiClient.POST("/api/v1/prs/{id}/resolve-comments", { - params: { path: { id: String(row.pr.number) } }, - }); - if (error) throw new Error(apiErrorMessage(error)); - }, - onSuccess: () => { - setNote({ ok: true, text: "comments resolved" }); - refresh(); - }, - onError: (e) => setNote({ ok: false, text: e instanceof Error ? e.message : "resolve failed" }), - }); - - const actionable = row.pr.state === "open" || row.pr.state === "draft"; - - return ( - - #{row.pr.number} - -
{row.pr.title || row.session.title}
-
- {[ - row.session.workspaceName, - row.pr.sourceBranch || row.session.branch, - row.pr.targetBranch ? `-> ${row.pr.targetBranch}` : "", - prDiffSummary(row.pr), - ] - .filter(Boolean) - .join(" · ")} -
- -
- - - {row.pr.state} - - - e.stopPropagation()}> - {note ? ( - {note.text} - ) : actionable ? ( -
- - -
- ) : ( - - )} -
-
- ); -} diff --git a/frontend/src/renderer/components/ReportProblemDialog.tsx b/frontend/src/renderer/components/ReportProblemDialog.tsx deleted file mode 100644 index bc88e2a391..0000000000 --- a/frontend/src/renderer/components/ReportProblemDialog.tsx +++ /dev/null @@ -1,220 +0,0 @@ -import * as Dialog from "@radix-ui/react-dialog"; -import { ChevronDown, GitPullRequest, Mail, MessageSquare, Send, X } from "lucide-react"; -import { useEffect, useId, useMemo, useState } from "react"; -import { - collectReportProblemDiagnostics, - formatReportProblemDraft, - reportProblemDestinationUrl, - type ReportProblemDiagnostics, - type ReportProblemInput, - type ReportProblemOutput, -} from "../lib/report-problem"; -import { aoBridge } from "../lib/bridge"; -import { Button } from "./ui/button"; -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu"; -import { Input } from "./ui/input"; - -type ReportProblemDialogProps = { - open: boolean; - onOpenChange: (open: boolean) => void; -}; - -const DEFAULT_DIAGNOSTICS: ReportProblemDiagnostics = { - appVersion: "unknown", - buildMode: "unknown", - daemonState: "unknown", - generatedAt: "unknown", - platform: "unknown", - routeSurface: "unknown", -}; - -const OUTPUT_LABELS: Record = { - github: "GitHub", - discord: "Discord", - email: "Email", -}; - -const OUTPUT_ACTION_LABELS: Record = { - github: "Copy and raise GitHub issue", - discord: "Copy and open Discord", - email: "Copy and open email", -}; - -const OUTPUT_DESTINATION_LABELS: Record = { - github: "GitHub issue", - discord: "Discord", - email: "Email support", -}; - -export function ReportProblemDialog({ open, onOpenChange }: ReportProblemDialogProps) { - const summaryId = useId(); - const detailsId = useId(); - const [selectedOutput, setSelectedOutput] = useState("github"); - const [summary, setSummary] = useState(""); - const [details, setDetails] = useState(""); - const [copiedOutput, setCopiedOutput] = useState(null); - const [copyError, setCopyError] = useState(null); - const [diagnostics, setDiagnostics] = useState(DEFAULT_DIAGNOSTICS); - - useEffect(() => { - if (!open) { - setSummary(""); - setDetails(""); - setSelectedOutput("github"); - setCopiedOutput(null); - setCopyError(null); - return; - } - let active = true; - void collectReportProblemDiagnostics().then((nextDiagnostics) => { - if (active) setDiagnostics(nextDiagnostics); - }); - return () => { - active = false; - }; - }, [open]); - - const input = useMemo( - () => ({ - summary, - details, - }), - [summary, details], - ); - - const draft = useMemo( - () => formatReportProblemDraft(input, diagnostics, selectedOutput), - [input, diagnostics, selectedOutput], - ); - - const copyDraft = async () => { - setCopyError(null); - const output = selectedOutput; - try { - await aoBridge.clipboard.writeText(draft); - const destinationUrl = reportProblemDestinationUrl(input, diagnostics, output); - if (destinationUrl) { - await aoBridge.app.openExternal(destinationUrl); - } - setCopiedOutput(output); - setSummary(""); - setDetails(""); - setSelectedOutput("github"); - } catch (err) { - setCopyError(err instanceof Error ? err.message : "Could not copy report draft"); - setCopiedOutput(null); - } - }; - - const selectOutput = (output: ReportProblemOutput) => { - setSelectedOutput(output); - setCopiedOutput(null); - setCopyError(null); - }; - - return ( - - - - -
-
- Report a problem - - Write a short note, then copy it to GitHub, Discord, or email. - -
- - - -
- -
-
- - setSummary(event.target.value)} - placeholder="Brief title" - /> -
- -
- -