Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 15 additions & 0 deletions desktop/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,28 @@ export interface Task {
shell_pane_id?: string;
pr_url: string;
pr_number?: number;
pr?: PRStatus;
summary?: string;
created_at: string;
updated_at: string;
started_at?: string;
completed_at?: string;
}

export type PRState = "open" | "draft" | "merged" | "closed";
export type PRCheckState = "passing" | "failing" | "pending" | "";

// Live PR badge payload mirrored from the cached github.PRInfo on the server.
export interface PRStatus {
number: number;
url: string;
state: PRState;
check_state: PRCheckState;
mergeable: string;
additions: number;
deletions: number;
}

export interface LogLine {
id: number;
line_type: string;
Expand Down
104 changes: 93 additions & 11 deletions desktop/src/components/Board.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { memo, useEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "motion/react";
import { GitPullRequest, Pin } from "lucide-react";
import { Check, Clock, GitMerge, GitPullRequest, GitPullRequestClosed, Pin, X } from "lucide-react";
import { api } from "../api/client";
import type { LogLine, Task } from "../api/types";
import type { LogLine, PRStatus, Task } from "../api/types";
import { ageHint, type Column } from "../lib/board";
import { store, useAppSelector } from "../store";
import { checkEnvironment, inTauri } from "../tauri";
Expand Down Expand Up @@ -35,6 +35,91 @@ const COLUMN_DOT: Record<string, string> = {
done: "bg-status-done",
};

/** Per-state visual treatment for the PR badge (icon + border/text color),
* mirroring the TUI's PRStatusBadge palette. */
const PR_STATE_STYLE: Record<
PRStatus["state"],
{ Icon: typeof GitPullRequest; className: string; label: string }
> = {
open: {
Icon: GitPullRequest,
className: "border-emerald-400/40 text-emerald-600 dark:text-emerald-300",
label: "open",
},
draft: {
Icon: GitPullRequest,
className: "border-muted-foreground/40 text-muted-foreground",
label: "draft",
},
merged: {
Icon: GitMerge,
className: "border-purple-400/40 text-purple-600 dark:text-purple-300",
label: "merged",
},
closed: {
Icon: GitPullRequestClosed,
className: "border-red-400/40 text-red-600 dark:text-red-300",
label: "closed",
},
};

/** CI check rollup indicator appended to the badge. Conflicting merges read as a
* failure (parity with the TUI, where conflicts outrank check status). */
function CheckMark({ pr }: { pr: PRStatus }) {
if (pr.state === "merged" || pr.state === "closed") return null;
if (pr.mergeable === "CONFLICTING") {
return <X className="size-2.5 text-red-500" aria-label="merge conflicts" />;
}
switch (pr.check_state) {
case "passing":
return <Check className="size-2.5 text-emerald-500" aria-label="checks passing" />;
case "failing":
return <X className="size-2.5 text-red-500" aria-label="checks failing" />;
case "pending":
return <Clock className="size-2.5 text-amber-500" aria-label="checks running" />;
default:
return null;
}
}

/** Live PR badge: state, CI checks, and diff size. Falls back to a bare PR chip
* for legacy rows that have a URL but no cached PR state yet. */
function PRBadge({ task }: { task: Task }) {
const pr = task.pr;
if (!pr) {
if (!task.pr_url) return null;
return (
<Badge
variant="outline"
className="h-4 gap-0.5 border-purple-400/40 px-1.5 text-[10px] text-purple-600 dark:text-purple-300"
>
<GitPullRequest className="size-2.5" />
{task.pr_number ? `#${task.pr_number}` : "PR"}
</Badge>
);
}
const style = PR_STATE_STYLE[pr.state] ?? PR_STATE_STYLE.open;
const { Icon } = style;
const title = `PR #${pr.number} — ${style.label}${pr.check_state ? ` · checks ${pr.check_state}` : ""}`;
return (
<Badge
variant="outline"
className={cn("h-4 gap-0.5 px-1.5 text-[10px]", style.className)}
title={title}
>
<Icon className="size-2.5" />
{pr.number ? `#${pr.number}` : "PR"}
<CheckMark pr={pr} />
{(pr.additions > 0 || pr.deletions > 0) && (
<span className="ml-0.5 font-mono text-muted-foreground">
{pr.additions > 0 && <span className="text-emerald-600 dark:text-emerald-400">+{pr.additions}</span>}
{pr.deletions > 0 && <span className="ml-0.5 text-red-600 dark:text-red-400">−{pr.deletions}</span>}
</span>
)}
</Badge>
);
}

function useSpinner(active: boolean): string {
const [frame, setFrame] = useState(0);
useEffect(() => {
Expand Down Expand Up @@ -67,6 +152,11 @@ function cardPropsEqual(prev: CardProps, next: CardProps): boolean {
a.pinned === b.pinned &&
a.pr_url === b.pr_url &&
a.pr_number === b.pr_number &&
a.pr?.state === b.pr?.state &&
a.pr?.check_state === b.pr?.check_state &&
a.pr?.mergeable === b.pr?.mergeable &&
a.pr?.additions === b.pr?.additions &&
a.pr?.deletions === b.pr?.deletions &&
a.executor === b.executor &&
a.project === b.project &&
a.updated_at === b.updated_at
Expand Down Expand Up @@ -146,15 +236,7 @@ const CardSlot = memo(function CardSlot({ task, selected, projectColor, latest }
needs input
</Badge>
)}
{task.pr_url && (
<Badge
variant="outline"
className="h-4 gap-0.5 border-purple-400/40 px-1.5 text-[10px] text-purple-600 dark:text-purple-300"
>
<GitPullRequest className="size-2.5" />
{task.pr_number ? `#${task.pr_number}` : "PR"}
</Badge>
)}
<PRBadge task={task} />
{task.executor && task.executor !== "claude" && (
<Badge variant="outline" className="h-4 px-1.5 text-[10px]">
{task.executor}
Expand Down
15 changes: 15 additions & 0 deletions internal/db/tasks.go
Original file line number Diff line number Diff line change
Expand Up @@ -710,14 +710,29 @@ func (db *DB) UpdateTask(t *Task) error {

// UpdateTaskPRInfo updates only the PR-related fields for a task.
// This is used to persist PR state from GitHub API responses without touching other fields.
//
// When the cached PR JSON actually changes, it records a board-change event so the
// HTTP API's SSE stream re-pushes the board — this is how a live PR badge reaches
// the web/desktop without a restart. We compare against the stored value first so
// idle refreshes (state unchanged) don't spam the change feed every tick.
func (db *DB) UpdateTaskPRInfo(taskID int64, prURL string, prNumber int, prInfoJSON string) error {
var prevJSON string
// Best-effort read of the prior value to detect real changes. A scan error
// (e.g. task gone) leaves prevJSON empty, so we fall through and emit — the
// UPDATE below will no-op on a missing row anyway.
_ = db.QueryRow(`SELECT pr_info_json FROM tasks WHERE id = ?`, taskID).Scan(&prevJSON)

_, err := db.Exec(`
UPDATE tasks SET pr_url = ?, pr_number = ?, pr_info_json = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`, prURL, prNumber, prInfoJSON, taskID)
if err != nil {
return fmt.Errorf("update task pr info: %w", err)
}

if prevJSON != prInfoJSON {
db.recordEvent("task.updated", taskID, "pr status")
}
return nil
}

Expand Down
56 changes: 56 additions & 0 deletions internal/db/tasks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1718,6 +1718,62 @@ func TestUpdateTaskPRInfo(t *testing.T) {
}
}

// UpdateTaskPRInfo must emit a board-change event when the PR JSON actually
// changes (so the web SSE re-pushes), and must stay quiet on a no-op update so
// idle refreshes don't spam the change feed.
func TestUpdateTaskPRInfoEmitsEventOnChange(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test.db")

db, err := Open(dbPath)
if err != nil {
t.Fatalf("failed to open database: %v", err)
}
defer db.Close()

task := &Task{Title: "PR events", Status: StatusProcessing, Type: TypeCode, Project: "personal"}
if err := db.CreateTask(task); err != nil {
t.Fatalf("failed to create task: %v", err)
}

prEvents := func() int {
var n int
if err := db.QueryRow(`SELECT COUNT(*) FROM event_log WHERE task_id = ? AND message = 'pr status'`, task.ID).Scan(&n); err != nil {
t.Fatalf("count events: %v", err)
}
return n
}

if got := prEvents(); got != 0 {
t.Fatalf("expected 0 pr events initially, got %d", got)
}

openJSON := `{"number":1,"url":"u","state":"OPEN"}`
if err := db.UpdateTaskPRInfo(task.ID, "u", 1, openJSON); err != nil {
t.Fatalf("update: %v", err)
}
if got := prEvents(); got != 1 {
t.Fatalf("expected 1 pr event after first change, got %d", got)
}

// Same payload again — no new event.
if err := db.UpdateTaskPRInfo(task.ID, "u", 1, openJSON); err != nil {
t.Fatalf("update: %v", err)
}
if got := prEvents(); got != 1 {
t.Fatalf("expected no new event on no-op update, got %d", got)
}

// Changed payload — one more event.
mergedJSON := `{"number":1,"url":"u","state":"MERGED"}`
if err := db.UpdateTaskPRInfo(task.ID, "u", 1, mergedJSON); err != nil {
t.Fatalf("update: %v", err)
}
if got := prEvents(); got != 2 {
t.Fatalf("expected 2 pr events after state change, got %d", got)
}
}

func TestTaskDangerousModeInListTasks(t *testing.T) {
// Create temporary database
tmpDir := t.TempDir()
Expand Down
91 changes: 87 additions & 4 deletions internal/executor/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -849,10 +849,11 @@ func (e *Executor) worker(ctx context.Context) {
// Check for stale worktrees to archive every 10 minutes (300 ticks)
tickCount := 0
const suspendCheckInterval = 30
const doneCleanupInterval = 150 // 5 minutes at 2 second ticks
const staleWorktreeInterval = 300 // 10 minutes at 2 second ticks
const authCheckInterval = 15 // 30 seconds at 2 second ticks
const reviewReconcileInterval = 30 // 60 seconds at 2 second ticks
const doneCleanupInterval = 150 // 5 minutes at 2 second ticks
const staleWorktreeInterval = 300 // 10 minutes at 2 second ticks
const authCheckInterval = 15 // 30 seconds at 2 second ticks
const reviewReconcileInterval = 30 // 60 seconds at 2 second ticks
const prDisplayRefreshInterval = 45 // 90 seconds at 2 second ticks

for {
select {
Expand Down Expand Up @@ -885,6 +886,12 @@ func (e *Executor) worker(ctx context.Context) {
e.reconcileReviewTasks()
}

// Periodically refresh cached PR state for actively-watched tasks so
// the board's live PR badge stays current without a TUI open.
if tickCount%prDisplayRefreshInterval == 0 {
e.refreshActivePRInfo()
}

// Periodically cleanup Claude processes for inactive done tasks
if tickCount%doneCleanupInterval == 0 {
e.cleanupInactiveDoneTasks()
Expand Down Expand Up @@ -980,6 +987,82 @@ func (e *Executor) reconcileReviewTasks() {
}
}

// refreshActivePRInfo keeps the cached PR badge fresh for tasks the human is
// actively watching — those processing or blocked with a branch — so the board
// (TUI and web) shows live PR state without waiting for completion. It runs
// daemon-side so the web/desktop stays current even when no TUI is open.
//
// It uses the batch open-PR listing (one rate-limited API call per repo), which
// carries PR state but not the CI rollup. To avoid wiping a CheckState a detail
// fetch previously learned, the prior CheckState is carried forward while the PR
// is still open. Merged/closed promotion stays the job of reconcileReviewTasks.
func (e *Executor) refreshActivePRInfo() {
if e.prCache == nil {
return
}

// Collect actively-watched tasks across the processing and blocked columns.
var candidates []*db.Task
for _, status := range []string{db.StatusProcessing, db.StatusBlocked} {
tasks, err := e.db.ListTasks(db.ListTasksOptions{Status: status, Limit: 200})
if err != nil {
continue
}
candidates = append(candidates, tasks...)
}

// Group by project so each repo's open PRs are fetched once.
byProject := make(map[string][]*db.Task)
for _, task := range candidates {
if task.BranchName == "" {
continue
}
// Skip terminal PRs — their state won't change and reconcile owns promotion.
if cached := github.UnmarshalPRInfo(task.PRInfoJSON); cached != nil {
if cached.State == github.PRStateMerged || cached.State == github.PRStateClosed {
continue
}
}
byProject[task.Project] = append(byProject[task.Project], task)
}

for project, ptasks := range byProject {
projectDir := e.getProjectDir(project)
if projectDir == "" {
continue
}

// One rate-limited API call lists the repo's OPEN PRs. nil means gh is
// unavailable or the rate-limit guard tripped — keep cached state.
openPRs := github.FetchAllPRsForRepo(projectDir)
if openPRs == nil {
continue
}
e.prCache.UpdateCacheForRepo(projectDir, openPRs)

for _, task := range ptasks {
info := openPRs[task.BranchName]
if info == nil {
// Absent from the open set → merged/closed; reconcileReviewTasks
// handles the terminal transition for blocked review tasks.
continue
}

// The batch path doesn't fetch the CI rollup; carry forward the last
// known CheckState for this same PR so the badge doesn't lose its checks.
merged := *info
if merged.CheckState == github.CheckStateNone {
if prev := github.UnmarshalPRInfo(task.PRInfoJSON); prev != nil && prev.Number == merged.Number {
merged.CheckState = prev.CheckState
}
}
if err := e.db.UpdateTaskPRInfo(task.ID, merged.URL, merged.Number, github.MarshalPRInfo(&merged)); err != nil {
e.logger.Warn("refreshActivePRInfo: failed to persist PR info", "task", task.ID, "error", err)
}
}
}
}

// suspendIdleBlockedTasks finds blocked tasks that have been idle and suspends their Claude processes.
func (e *Executor) suspendIdleBlockedTasks() {
tasks, err := e.db.ListTasks(db.ListTasksOptions{Status: db.StatusBlocked, Limit: 100})
Expand Down
14 changes: 8 additions & 6 deletions internal/web/board.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,13 @@ type BoardColumn struct {

// BoardEntry is a single task card in the board.
type BoardEntry struct {
ID int64 `json:"id"`
Title string `json:"title"`
Project string `json:"project"`
Type string `json:"type"`
Pinned bool `json:"pinned"`
AgeHint string `json:"age_hint"`
ID int64 `json:"id"`
Title string `json:"title"`
Project string `json:"project"`
Type string `json:"type"`
Pinned bool `json:"pinned"`
AgeHint string `json:"age_hint"`
PR *prStatusJSON `json:"pr,omitempty"`
}

// BuildBoardSnapshot groups tasks into kanban columns.
Expand Down Expand Up @@ -79,6 +80,7 @@ func BuildBoardSnapshot(tasks []*db.Task, limit int) BoardSnapshot {
Type: task.Type,
Pinned: task.Pinned,
AgeHint: boardAgeHint(task),
PR: toPRStatusJSON(task.PRInfoJSON),
}
column.Tasks = append(column.Tasks, entry)
}
Expand Down
Loading