From ca041b66fed1b95362a28d2c4fab591d63ce7b34 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 11 Apr 2026 00:52:53 -0700 Subject: [PATCH] Add richer in-app feedback dialog (#468) * Add richer feedback dialog * Keep anonymous submission client-side only * Use lowercase feedback API host * Fallback feedback submission when api host is unavailable --- src/main/github/client.ts | 35 +- src/main/ipc/github.test.ts | 30 +- src/main/ipc/github.ts | 2 + src/preload/api-types.d.ts | 2 + src/preload/index.d.ts | 2 + src/preload/index.ts | 2 + .../src/components/sidebar/SidebarToolbar.tsx | 312 +++++++++++++++++- src/shared/types.ts | 5 + 8 files changed, 364 insertions(+), 26 deletions(-) diff --git a/src/main/github/client.ts b/src/main/github/client.ts index bedb4409ef2..85dcea3e779 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -1,6 +1,12 @@ /* eslint-disable max-lines -- Why: co-locating all GitHub client functions keeps the concurrency acquire/release pattern and error handling consistent across operations. */ -import type { PRInfo, PRMergeableState, PRCheckDetail, PRComment } from '../../shared/types' +import type { + PRInfo, + PRMergeableState, + PRCheckDetail, + PRComment, + GitHubViewer +} from '../../shared/types' import { getPRConflictSummary } from './conflict-summary' import { execFileAsync, ghExecFileAsync, acquire, release, getOwnerRepo } from './gh-utils' export { _resetOwnerRepoCache } from './gh-utils' @@ -55,6 +61,33 @@ export async function starOrca(): Promise { } } +/** + * Get the authenticated GitHub viewer when gh is available and logged in. + * Returns null when gh is unavailable, unauthenticated, or the lookup fails. + */ +export async function getAuthenticatedViewer(): Promise { + await acquire() + try { + const { stdout } = await execFileAsync( + 'gh', + ['api', 'user', '--jq', '{login: .login, email: .email}'], + { encoding: 'utf-8' } + ) + const viewer = JSON.parse(stdout) as { login?: string; email?: string | null } + if (!viewer.login?.trim()) { + return null + } + return { + login: viewer.login.trim(), + email: viewer.email?.trim() || null + } + } catch { + return null + } finally { + release() + } +} + /** * Get PR info for a given branch using gh CLI. * Returns null if gh is not installed, or no PR exists for the branch. diff --git a/src/main/ipc/github.test.ts b/src/main/ipc/github.test.ts index 12383d0352d..3b27c64f7cb 100644 --- a/src/main/ipc/github.test.ts +++ b/src/main/ipc/github.test.ts @@ -1,11 +1,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { handleMock, getPRForBranchMock, getIssueMock, listIssuesMock } = vi.hoisted(() => ({ - handleMock: vi.fn(), - getPRForBranchMock: vi.fn(), - getIssueMock: vi.fn(), - listIssuesMock: vi.fn() -})) +const { handleMock, getPRForBranchMock, getIssueMock, listIssuesMock, getAuthenticatedViewerMock } = + vi.hoisted(() => ({ + handleMock: vi.fn(), + getPRForBranchMock: vi.fn(), + getIssueMock: vi.fn(), + listIssuesMock: vi.fn(), + getAuthenticatedViewerMock: vi.fn() + })) vi.mock('electron', () => ({ ipcMain: { @@ -16,7 +18,8 @@ vi.mock('electron', () => ({ vi.mock('../github/client', () => ({ getPRForBranch: getPRForBranchMock, getIssue: getIssueMock, - listIssues: listIssuesMock + listIssues: listIssuesMock, + getAuthenticatedViewer: getAuthenticatedViewerMock })) import { registerGitHubHandlers } from './github' @@ -46,6 +49,7 @@ describe('registerGitHubHandlers', () => { getPRForBranchMock.mockReset() getIssueMock.mockReset() listIssuesMock.mockReset() + getAuthenticatedViewerMock.mockReset() for (const key of Object.keys(handlers)) { delete handlers[key] } @@ -93,4 +97,16 @@ describe('registerGitHubHandlers', () => { expect(listIssuesMock).toHaveBeenCalledWith('/workspace/repo', 5) }) + + it('forwards the authenticated viewer lookup', async () => { + getAuthenticatedViewerMock.mockResolvedValue({ login: 'octocat', email: 'octocat@example.com' }) + + registerGitHubHandlers(store as never, stats as never) + + await expect(handlers['gh:viewer'](null, undefined)).resolves.toEqual({ + login: 'octocat', + email: 'octocat@example.com' + }) + expect(getAuthenticatedViewerMock).toHaveBeenCalled() + }) }) diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index b8858521a32..44301a3015d 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -7,6 +7,7 @@ import { getPRForBranch, getIssue, listIssues, + getAuthenticatedViewer, getPRChecks, getPRComments, resolveReviewThread, @@ -109,6 +110,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi ) // Star operations target the Orca repo itself — no repoPath validation needed + ipcMain.handle('gh:viewer', () => getAuthenticatedViewer()) ipcMain.handle('gh:checkOrcaStarred', () => checkOrcaStarred()) ipcMain.handle('gh:starOrca', () => starOrca()) } diff --git a/src/preload/api-types.d.ts b/src/preload/api-types.d.ts index 6d387fc63ad..e2be9a005d4 100644 --- a/src/preload/api-types.d.ts +++ b/src/preload/api-types.d.ts @@ -9,6 +9,7 @@ import type { GitConflictOperation, GitDiffResult, GitStatusEntry, + GitHubViewer, IssueInfo, NotificationDispatchRequest, NotificationDispatchResult, @@ -188,6 +189,7 @@ export type PreloadApi = { onExit: (callback: (data: { id: string; code: number }) => void) => () => void } gh: { + viewer: () => Promise prForBranch: (args: { repoPath: string; branch: string }) => Promise issue: (args: { repoPath: string; number: number }) => Promise listIssues: (args: { repoPath: string; limit?: number }) => Promise diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index d65d1adab7e..cd2b9ffa498 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -1,6 +1,7 @@ import type { ElectronAPI } from '@electron-toolkit/preload' import type { CreateWorktreeResult, + GitHubViewer, CreateWorktreeArgs } from '../../shared/types' import type { PreloadApi } from './api-types' @@ -52,6 +53,7 @@ type PtyApi = { } type GhApi = { + viewer: () => Promise prForBranch: (args: { repoPath: string; branch: string }) => Promise issue: (args: { repoPath: string; number: number }) => Promise listIssues: (args: { repoPath: string; limit?: number }) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 0fcd04ceece..c860e5a313f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -213,6 +213,8 @@ const api = { }, gh: { + viewer: (): Promise => ipcRenderer.invoke('gh:viewer'), + prForBranch: (args: { repoPath: string; branch: string }): Promise => ipcRenderer.invoke('gh:prForBranch', args), diff --git a/src/renderer/src/components/sidebar/SidebarToolbar.tsx b/src/renderer/src/components/sidebar/SidebarToolbar.tsx index 1e2d16ef555..fa7db8075b9 100644 --- a/src/renderer/src/components/sidebar/SidebarToolbar.tsx +++ b/src/renderer/src/components/sidebar/SidebarToolbar.tsx @@ -1,16 +1,274 @@ -import React from 'react' -import { FolderPlus, Settings } from 'lucide-react' +import React, { useState } from 'react' +import { ExternalLink, FolderPlus, Github, MessageSquareText, Settings } from 'lucide-react' import { useAppStore } from '@/store' import { Button } from '@/components/ui/button' import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { cn } from '@/lib/utils' +import { toast } from 'sonner' +import type { GitHubViewer } from '../../../../shared/types' + +const GITHUB_ISSUES_URL = 'https://github.com/stablyai/orca/issues/' +const DISCORD_URL = 'https://discord.gg/fzjDKHxv8Q' +const X_URL = 'https://x.com/orca_build' +const FEEDBACK_API_URL = 'https://api.onorca.dev/v1/feedback' +const FEEDBACK_API_FALLBACK_URL = 'https://www.onorca.dev/v1/feedback' + +type SubmitIdentity = { + githubLogin: string | null + githubEmail: string | null +} + +function openExternalUrl(url: string): void { + void window.api.shell.openUrl(url) +} + +function getSubmitIdentity(viewer: GitHubViewer | null, anonymous: boolean): SubmitIdentity { + if (anonymous || !viewer) { + return { + githubLogin: null, + githubEmail: null + } + } + + return { + githubLogin: viewer.login, + githubEmail: viewer.email + } +} + +async function submitFeedback(body: { + feedback: string + githubLogin: string | null + githubEmail: string | null +}): Promise { + try { + return await fetch(FEEDBACK_API_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + // Why: DNS for the dedicated api.onorca.dev host can lag behind a deploy. + // Falling back to the verified website-hosted versioned endpoint keeps + // feedback submission working instead of forcing users to wait on DNS. + if ( + message.includes('ERR_NAME_NOT_RESOLVED') || + message.includes('ENOTFOUND') || + message.includes('Failed to fetch') + ) { + return fetch(FEEDBACK_API_FALLBACK_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }) + } + throw error + } +} + +function FeedbackDialog({ + open, + onOpenChange +}: { + open: boolean + onOpenChange: (open: boolean) => void +}): React.JSX.Element { + const [feedback, setFeedback] = useState('') + const [isSubmitting, setIsSubmitting] = useState(false) + const [viewer, setViewer] = useState(null) + const [isViewerLoading, setIsViewerLoading] = useState(false) + const [submitAnonymously, setSubmitAnonymously] = useState(false) + + React.useEffect(() => { + if (!open) { + return + } + + let cancelled = false + setIsViewerLoading(true) + void window.api.gh + .viewer() + .then((nextViewer) => { + if (!cancelled) { + setViewer(nextViewer) + } + }) + .catch((err) => { + if (!cancelled) { + setViewer(null) + console.error('Failed to load GitHub viewer:', err) + } + }) + .finally(() => { + if (!cancelled) { + setIsViewerLoading(false) + } + }) + + return () => { + cancelled = true + } + }, [open]) + + const handleSubmit = async (): Promise => { + const trimmed = feedback.trim() + if (!trimmed) { + toast.warning('Please enter feedback before submitting.') + return + } + + setIsSubmitting(true) + try { + const identity = getSubmitIdentity(viewer, submitAnonymously) + const response = await submitFeedback({ + // Why: showing the exact GitHub identity in the dialog makes the + // default attribution explicit, and this flag lets users opt out + // without having to disconnect gh for the rest of Orca. + feedback: trimmed, + githubLogin: identity.githubLogin, + githubEmail: identity.githubEmail + }) + + if (!response.ok) { + throw new Error(`Feedback request failed with status ${response.status}`) + } + + toast.success('Thanks for the feedback.') + setFeedback('') + setSubmitAnonymously(false) + onOpenChange(false) + } catch (err) { + toast.error('Failed to submit feedback. Please try again.') + console.error('Failed to submit feedback:', err) + } finally { + setIsSubmitting(false) + } + } + + return ( + + + + Send Feedback + + Share what's working, what's broken, or what Orca should do next. + + + +
+
Other ways to reach us
+
+ + + +
+
+ +