Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
903022f
feat(opencode): add GitHub PR backend - VCS routes, PR creation, merg…
tamarazuk Mar 1, 2026
5ac07c2
feat(sdk): regenerate SDK types and client for PR/VCS endpoints
tamarazuk Mar 1, 2026
ccabed5
feat(app): add PR UI - create/merge/address-comments dialogs, PR butt…
tamarazuk Mar 1, 2026
be47131
fix(pr): improve type safety, error handling, and code organization
tamarazuk Mar 1, 2026
25bc63b
fix(app): improve address-comments prompt to commit, push, and reply …
tamarazuk Mar 1, 2026
1283ebe
update(app): mute sidebar PR badge colors slightly
tamarazuk Mar 1, 2026
ea18944
update(app): match PR badge style to sidebar unread badge
tamarazuk Mar 2, 2026
6f9a763
update(app): refine address-comments dialog card design
tamarazuk Mar 2, 2026
6c91f86
update(app): remove additional instructions field from address-commen…
tamarazuk Mar 2, 2026
e9396c0
fix(ui): pass class prop through to Checkbox root element
tamarazuk Mar 2, 2026
94f0a99
feat(pr): add authorIsBot field to review comments
tamarazuk Mar 2, 2026
3874683
fix(pr): fix branch deletion, dead code, and race conditions
tamarazuk Mar 2, 2026
b5e0227
update(ui): mute deselected cards in address-comments dialog
tamarazuk Mar 2, 2026
a8f24bf
fix(pr): prevent PR creation with uncommitted changes
tamarazuk Mar 2, 2026
af5de6d
feat(pr): add mark as ready for review action
tamarazuk Mar 2, 2026
95c783b
Switch to build mode when pre-filling prompt from addressed comments …
tamarazuk Mar 2, 2026
944e6f1
tweak: change the button label to let user know mode will switch
tamarazuk Mar 2, 2026
cd4cfa3
feat(pr): redesign delete branch dialog with critical styling
tamarazuk Mar 2, 2026
4abab9e
refactor(pr): wire delete branch action to confirmation dialog
tamarazuk Mar 2, 2026
02b0cd1
fix(ui): ensure dropdown menu items fill full width
tamarazuk Mar 2, 2026
a4130a5
feat(app): display a noty baddge on PR tag when associated PR has
tamarazuk Mar 3, 2026
ff96ede
feat(pr): add "ask agent to fix conflicts" action to merge dialog
tamarazuk Mar 3, 2026
fa9483f
fix(app): use else if in applyOptimisticAdd to avoid stale variable c…
tamarazuk Mar 3, 2026
0b0c319
fix: remove dead window code from backend vcs module
tamarazuk Mar 3, 2026
d1ca5e4
refactor(app): simplify applyOptimisticAdd with else instead of else if
tamarazuk Mar 3, 2026
568b4b4
fix(pr): disallow path traversal in branch name regex
tamarazuk Mar 3, 2026
97d45e9
fix(pr): sync commit message to PR title while editing
tamarazuk Mar 3, 2026
1f86f35
fix(vcs): only stage tracked files in commit()
tamarazuk Mar 3, 2026
4cb43c3
fix: use pr.url for GitHub comment links to support Enterprise
tamarazuk Mar 3, 2026
467f3c3
fix(pr): correct delete-branch route description
tamarazuk Mar 3, 2026
5962ff2
fix(vcs): use recursive setTimeout for true poll jitter
tamarazuk Mar 3, 2026
c2015b9
fix(pr): stop swallowing gh errors, make deleteBranch opt-in
tamarazuk Mar 3, 2026
607bb55
fix(pr): add log.warn to silent catch blocks in fetchForBranch and fe…
tamarazuk Mar 3, 2026
d9fbc2a
fix(pr): harden validation, merge guard, and HTTP status codes
tamarazuk Mar 3, 2026
e7f1209
fix(pr): narrow ensureGithub type, fix timer cleanup, add missing eve…
tamarazuk Mar 3, 2026
0731d65
fix(pr): refactor PrError to NamedError, add gh timeouts, surface bra…
tamarazuk Mar 3, 2026
2d6d555
fix(pr): improve error visibility, pagination bounds, and UI consistency
tamarazuk Mar 3, 2026
a6a1d87
fix(pr): refresh before create, validate base branch, fence comment b…
tamarazuk Mar 3, 2026
f711033
fix(pr): hide unread-comments badge on workspace hover
tamarazuk Mar 3, 2026
9936482
refactor(pr): extract hardcoded GitHub PR state colors into CSS custo…
tamarazuk Mar 4, 2026
cf48290
fix(vcs): clear stale state and stage untracked files
tamarazuk Mar 15, 2026
66e8958
feat(pr): generate drafts and improve create flow
tamarazuk Mar 15, 2026
29e09d2
chore: remove temporary PR review notes
tamarazuk Mar 15, 2026
61be64b
fix(vcs): ignore detached head during bootstrap
tamarazuk Mar 16, 2026
5216cf6
fix(db): preserve channel database path
tamarazuk Mar 16, 2026
e6ffbd3
fix(pr): restore rebase type safety
tamarazuk Mar 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
313 changes: 313 additions & 0 deletions packages/app/src/components/dialog-address-comments.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,313 @@
import { Button } from "@opencode-ai/ui/button"
import { Checkbox } from "@opencode-ai/ui/checkbox"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { Icon } from "@opencode-ai/ui/icon"
import { Spinner } from "@opencode-ai/ui/spinner"
import { createMemo, For, onMount, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { useLocal } from "@/context/local"
import { usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { resolveApiErrorMessage } from "@/utils/pr-errors"
import type { ReviewThread } from "@opencode-ai/sdk/v2"

export function AddressCommentsDialog() {
const dialog = useDialog()
const sdk = useSDK()
const sync = useSync()
const local = useLocal()
const prompt = usePrompt()
const language = useLanguage()

const vcs = createMemo(() => sync.data.vcs)
const pr = createMemo(() => vcs()?.pr)
const github = createMemo(() => vcs()?.github)

const [store, setStore] = createStore({
loading: true,
error: undefined as string | undefined,
threads: [] as ReviewThread[],
selected: {} as Record<string, boolean>,
})

onMount(async () => {
try {
const result = await sdk.client.vcs.pr.comments({ directory: sdk.directory })
const data = result.data
if (!data) {
setStore("error", "No response from server")
setStore("loading", false)
return
}
const selected: Record<string, boolean> = {}
for (const thread of data.threads) {
selected[thread.id] = true
}
setStore({
threads: data.threads,
selected,
loading: false,
})
} catch (e: unknown) {
if (import.meta.env.DEV) console.error("Fetch comments error:", e)
setStore(
"error",
resolveApiErrorMessage(e, "Failed to fetch review comments", (k) =>
language.t(k as Parameters<typeof language.t>[0]),
),
)
setStore("loading", false)
}
})

const selectedCount = createMemo(() => Object.values(store.selected).filter(Boolean).length)
const allSelected = createMemo(() => store.threads.length > 0 && selectedCount() === store.threads.length)

const toggleAll = () => {
const next = !allSelected()
const updated: Record<string, boolean> = {}
for (const thread of store.threads) {
updated[thread.id] = next
}
setStore("selected", updated)
}

const toggleThread = (id: string) => {
setStore("selected", id, !store.selected[id])
}

const handleSubmit = () => {
const currentPr = pr()
const repo = github()?.repo
if (!currentPr || !repo) return

const prNumber = currentPr.number
const owner = repo.owner
const repoName = repo.name

const selectedThreads = store.threads.filter((t) => store.selected[t.id])

let text = `Address the following unresolved review comments on PR #${prNumber}.\n\n`
text += `## Comments to Address\n\n`

for (const thread of selectedThreads) {
if (thread.comments.length === 0) continue
text += `### File: ${thread.path}${thread.line ? ` (line ${thread.line})` : ""}\n`
for (const comment of thread.comments) {
text += `**@${comment.author}** (comment ID: ${comment.id}):\n\`\`\`\n${comment.body}\n\`\`\`\n`
}
text += "\n"
}

text += `## Instructions\n\n`
text += `Work through each comment above **one at a time**. The comment ID for each is shown above next to the author. For each comment:\n\n`
text += `1. Read the comment and decide whether to fix it or intentionally skip it\n`
text += `2. **If fixing:**\n`
text += ` - Make the code change\n`
text += ` - Commit with a descriptive message following the repository's commit conventions\n`
text += ` - \`git push\`\n`
text += ` - Get the commit SHA with \`git rev-parse HEAD\`\n`
text += ` - Reply on GitHub referencing the commit:\n`
text += ` \`gh api repos/${owner}/${repoName}/pulls/${prNumber}/comments --method POST -f body="Fixed in <SHA>: <brief explanation>" -F in_reply_to=<comment ID>\`\n`
text += `3. **If skipping:**\n`
text += ` - Reply on GitHub explaining the design rationale:\n`
text += ` \`gh api repos/${owner}/${repoName}/pulls/${prNumber}/comments --method POST -f body="<rationale>" -F in_reply_to=<comment ID>\`\n`
text += `4. Do NOT merge, rebase, or force-push\n`

if (local.agent.current()?.name !== "build") {
local.agent.set("build")
}
dialog.close()
requestAnimationFrame(() => {
prompt.set([
{
type: "text",
content: text,
start: 0,
end: text.length,
},
])
})
}

return (
<Dialog title={language.t("pr.comments.title")} size="large" fit>
<div class="flex flex-col px-5 pb-5 w-full h-full max-h-[85vh]">
<Show
when={!store.loading}
fallback={
<div class="flex items-center justify-center py-8 shrink-0">
<Spinner class="size-5 text-icon-weak" />
</div>
}
>
<Show when={store.error}>
<div class="flex flex-col items-center gap-2 py-6 shrink-0">
<Icon name="circle-x" size="medium" class="text-icon-critical-base" />
<span class="text-13-medium text-text-strong">{language.t("pr.comments.error.title")}</span>
<span class="text-12-regular text-text-weak text-center max-w-[360px]">{store.error}</span>
</div>
</Show>
<Show when={!store.error}>
<Show
when={store.threads.length > 0}
fallback={
<div class="flex flex-col items-center gap-2 py-6 shrink-0">
<Icon name="circle-check" size="medium" class="text-icon-success-base" />
<span class="text-13-regular text-text-weak">{language.t("pr.comments.none")}</span>
</div>
}
>
{/* Header with count and select all toggle */}
<div class="flex items-center justify-between shrink-0 pb-2">
<span class="text-12-medium text-text-weak">
{store.threads.length === 1
? language.t("pr.comments.count.one")
: language.t("pr.comments.count", { count: String(store.threads.length) })}
</span>
<button
class="text-12-regular text-text-interactive-base hover:text-text-strong cursor-pointer bg-transparent border-none p-0 transition-colors"
onClick={toggleAll}
>
{allSelected() ? language.t("pr.comments.select.none") : language.t("pr.comments.select.all")}
</button>
</div>

{/* Comment thread list */}
<div class="flex flex-col gap-3 flex-1 overflow-y-auto min-h-[200px] -mx-5 px-5 py-1">
<For each={store.threads}>
{(thread) => {
const firstComment = () => thread.comments[0]
const replies = () => thread.comments.slice(1)
const isChecked = () => !!store.selected[thread.id]

return (
<div
class={`group flex gap-3.5 p-4 border rounded-lg cursor-pointer transition-all ${
isChecked()
? "border-border-weaker-base"
: "opacity-50 border-border-weaker-base hover:border-border-weak-base hover:opacity-70"
}`}
onClick={() => toggleThread(thread.id)}
>
<div class="pt-[3px] shrink-0 pointer-events-none">
<Checkbox
checked={isChecked()}
hideLabel
class="[&_[data-slot=checkbox-checkbox-control]]:rounded-full [&[data-checked]_[data-slot=checkbox-checkbox-control]]:!bg-icon-interactive-base [&[data-checked]_[data-slot=checkbox-checkbox-control]]:!border-icon-interactive-base [&[data-checked]_[data-slot=checkbox-checkbox-indicator]]:text-text-on-interactive-base"
>
&nbsp;
</Checkbox>
</div>
<div class="flex flex-col gap-2 min-w-0 flex-1">
{/* File path + line */}
<div class="flex items-center gap-1.5">
<Icon name="code-lines" size="small" class="text-icon-weak shrink-0" />
<span class="text-12-regular text-text-weak truncate">{thread.path}</span>
<Show when={thread.line}>
<span class="text-12-regular text-text-weak shrink-0">
<span class="text-text-weaker">:</span>
{thread.line}
</span>
</Show>
</div>

{/* First comment (the review comment) */}
<Show when={firstComment()}>
{(comment) => {
const isBot = comment().authorIsBot
return (
<div class="flex flex-col gap-1.5 mt-0.5">
<div class="flex items-center justify-between w-full">
<div class="flex items-center gap-1.5">
<span class="text-12-medium text-text-interactive-base">@{comment().author}</span>
{isBot && (
<span class="text-[10px] uppercase font-bold tracking-wider px-1.5 py-0.5 rounded-sm bg-surface-raised-strong text-text-weak">
Bot
</span>
)}
</div>
{(() => {
const g = github()
const p = pr()
const href =
g?.repo && p?.url ? `${p.url}#discussion_r${comment().id}` : undefined
return (
<Show when={href}>
{(url) => (
<a
href={url()}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
class="text-icon-weaker hover:text-text-weak transition-colors shrink-0 cursor-pointer"
title="View on GitHub"
>
<Icon name="square-arrow-top-right" size="small" />
</a>
)}
</Show>
)
})()}
</div>
<p class="text-13-regular text-text-strong m-0 whitespace-pre-wrap break-words leading-relaxed">
{comment().body}
</p>
</div>
)
}}
</Show>

{/* Reply count */}
<Show when={replies().length > 0}>
<div class="flex items-center gap-1.5 mt-0.5">
<span class="text-11-medium text-text-weaker font-mono">↳</span>
<span class="text-11-medium text-text-weak">
{replies().length === 1
? language.t("pr.comments.replies.one")
: language.t("pr.comments.replies.count", { count: String(replies().length) })}
</span>
</div>
</Show>
</div>
</div>
)
}}
</For>
</div>
</Show>
</Show>
</Show>

{/* Footer */}
<div class="flex items-center justify-between shrink-0 pt-4 border-t border-border-weak-base mt-2">
<div class="flex-1 flex items-center gap-3">
<Show when={store.threads.length > 0 && !store.error}>
<span class="text-12-regular text-text-weak">
{language.t("pr.comments.selected", {
selected: String(selectedCount()),
total: String(store.threads.length),
})}
</span>
</Show>
</div>
<div class="flex gap-2 shrink-0">
<Button variant="ghost" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</Button>
<Show when={store.threads.length > 0 && !store.error}>
<Button variant="primary" disabled={selectedCount() === 0} onClick={handleSubmit}>
{local.agent.current()?.name !== "build"
? language.t("pr.comments.submitWithSwitch")
: language.t("pr.comments.submit")}
</Button>
</Show>
</div>
</div>
</div>
</Dialog>
)
}
Loading
Loading