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
This commit is contained in:
Neil
2026-04-11 00:52:53 -07:00
committed by GitHub
parent 3cb9c99743
commit ca041b66fe
8 changed files with 364 additions and 26 deletions
+34 -1
View File
@@ -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<boolean> {
}
}
/**
* 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<GitHubViewer | null> {
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.
+23 -7
View File
@@ -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()
})
})
+2
View File
@@ -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())
}
+2
View File
@@ -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<GitHubViewer | null>
prForBranch: (args: { repoPath: string; branch: string }) => Promise<PRInfo | null>
issue: (args: { repoPath: string; number: number }) => Promise<IssueInfo | null>
listIssues: (args: { repoPath: string; limit?: number }) => Promise<IssueInfo[]>
+2
View File
@@ -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<GitHubViewer | null>
prForBranch: (args: { repoPath: string; branch: string }) => Promise<PRInfo | null>
issue: (args: { repoPath: string; number: number }) => Promise<IssueInfo | null>
listIssues: (args: { repoPath: string; limit?: number }) => Promise<IssueInfo[]>
+2
View File
@@ -213,6 +213,8 @@ const api = {
},
gh: {
viewer: (): Promise<unknown> => ipcRenderer.invoke('gh:viewer'),
prForBranch: (args: { repoPath: string; branch: string }): Promise<unknown> =>
ipcRenderer.invoke('gh:prForBranch', args),
@@ -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<Response> {
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<GitHubViewer | null>(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<void> => {
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-sm">Send Feedback</DialogTitle>
<DialogDescription className="text-xs">
Share what&apos;s working, what&apos;s broken, or what Orca should do next.
</DialogDescription>
</DialogHeader>
<div className="space-y-2 rounded-md border border-border/70 bg-muted/30 p-3">
<div className="text-xs font-medium text-foreground">Other ways to reach us</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="h-8 text-xs"
onClick={() => openExternalUrl(GITHUB_ISSUES_URL)}
>
<Github className="size-3.5" />
GitHub issues
<ExternalLink className="size-3.5" />
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 text-xs"
onClick={() => openExternalUrl(DISCORD_URL)}
>
<svg viewBox="0 0 24 24" aria-hidden="true" className="size-3.5 fill-current">
<path d="M20.317 4.369A19.791 19.791 0 0 0 15.885 3c-.191.328-.403.77-.553 1.116a18.27 18.27 0 0 0-5.098 0A12.64 12.64 0 0 0 9.68 3a19.736 19.736 0 0 0-4.433 1.369C2.444 8.479 1.69 12.488 2.067 16.44a19.912 19.912 0 0 0 5.427 2.744c.438-.598.828-1.23 1.164-1.89a12.95 12.95 0 0 1-1.833-.877c.154-.113.305-.231.45-.352a14.294 14.294 0 0 0 12.45 0c.146.12.296.239.45.352-.585.34-1.2.634-1.835.878.337.659.727 1.29 1.165 1.888a19.84 19.84 0 0 0 5.43-2.744c.442-4.579-.755-8.551-3.932-12.07ZM9.955 14.005c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.418 2.157-2.418 1.211 0 2.176 1.095 2.157 2.418 0 1.334-.955 2.419-2.157 2.419Zm4.09 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.418 2.157-2.418 1.211 0 2.176 1.095 2.157 2.418 0 1.334-.946 2.419-2.157 2.419Z" />
</svg>
Join Discord
<ExternalLink className="size-3.5" />
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 text-xs"
onClick={() => openExternalUrl(X_URL)}
>
<svg viewBox="0 0 24 24" aria-hidden="true" className="size-3.5 fill-current">
<path d="M18.901 1.153h3.68l-8.041 9.19L24 22.847h-7.406l-5.8-7.584-6.64 7.584H.474l8.6-9.83L0 1.153h7.594l5.243 6.932 6.064-6.932Zm-1.29 19.493h2.04L6.486 3.24H4.298l13.313 17.406Z" />
</svg>
Follow on X
<ExternalLink className="size-3.5" />
</Button>
</div>
</div>
<textarea
autoFocus
value={feedback}
onChange={(event) => setFeedback(event.target.value)}
placeholder="What could we improve?"
rows={7}
className="min-h-32 w-full rounded-md border border-border bg-background px-3 py-2 text-sm outline-none ring-offset-background placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
/>
<div className="min-h-9 rounded-md border border-border/70 bg-muted/30 px-3 py-2">
{viewer ? (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span>
GitHub:{' '}
<span className="font-mono text-foreground">
{viewer.login}
{viewer.email ? ` (${viewer.email})` : ''}
</span>
</span>
<label className="flex cursor-pointer items-center gap-2 text-foreground">
<input
type="checkbox"
checked={submitAnonymously}
onChange={(event) => setSubmitAnonymously(event.target.checked)}
className={cn(
'size-3.5 rounded border border-border bg-background align-middle',
'accent-foreground'
)}
/>
Submit anonymously
</label>
</div>
) : isViewerLoading ? (
<div className="text-xs text-muted-foreground">Checking GitHub identity…</div>
) : (
<div className="text-xs text-muted-foreground">
Submit with your typed feedback only, or connect `gh` to include GitHub identity.
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
Cancel
</Button>
<Button onClick={() => void handleSubmit()} disabled={isSubmitting || !feedback.trim()}>
{isSubmitting ? 'Sending…' : 'Send'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
const SidebarToolbar = React.memo(function SidebarToolbar() {
const openModal = useAppStore((s) => s.openModal)
const setActiveView = useAppStore((s) => s.setActiveView)
const [feedbackOpen, setFeedbackOpen] = useState(false)
return (
<div className="mt-auto shrink-0">
<div className="flex items-center justify-between px-2 py-1.5 border-t border-sidebar-border">
<div className="flex items-center justify-between border-t border-sidebar-border px-2 py-1.5">
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -27,22 +285,40 @@ const SidebarToolbar = React.memo(function SidebarToolbar() {
Open folder picker to add a repo
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
onClick={() => setActiveView('settings')}
className="text-muted-foreground"
>
<Settings className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
Settings
</TooltipContent>
</Tooltip>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
onClick={() => setFeedbackOpen(true)}
className="text-muted-foreground"
>
<MessageSquareText className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
Send feedback
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
onClick={() => setActiveView('settings')}
className="text-muted-foreground"
>
<Settings className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
Settings
</TooltipContent>
</Tooltip>
</div>
</div>
<FeedbackDialog open={feedbackOpen} onOpenChange={setFeedbackOpen} />
</div>
)
})
+5
View File
@@ -255,6 +255,11 @@ export type IssueInfo = {
labels: string[]
}
export type GitHubViewer = {
login: string
email: string | null
}
// ─── Hooks (orca.yaml) ──────────────────────────────────────────────
export type OrcaHooks = {
scripts: {