mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 16:02:29 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
Vendored
+2
@@ -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[]>
|
||||
|
||||
Vendored
+2
@@ -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[]>
|
||||
|
||||
@@ -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's working, what'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>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -255,6 +255,11 @@ export type IssueInfo = {
|
||||
labels: string[]
|
||||
}
|
||||
|
||||
export type GitHubViewer = {
|
||||
login: string
|
||||
email: string | null
|
||||
}
|
||||
|
||||
// ─── Hooks (orca.yaml) ──────────────────────────────────────────────
|
||||
export type OrcaHooks = {
|
||||
scripts: {
|
||||
|
||||
Reference in New Issue
Block a user