From 6e2e42ff1684a0b361cfe3847b324d80c65485b8 Mon Sep 17 00:00:00 2001 From: whoisasx Date: Wed, 17 Jun 2026 00:48:36 +0530 Subject: [PATCH 1/5] feat: surface scm summaries in desktop --- backend/internal/cli/dto_drift_e2e_test.go | 2 +- backend/internal/domain/session.go | 2 +- backend/internal/httpd/apispec/openapi.yaml | 210 ++++++++++++- .../internal/httpd/apispec/specgen/build.go | 72 +++-- backend/internal/httpd/controllers/dto.go | 128 +++++++- .../internal/httpd/controllers/sessions.go | 14 +- .../httpd/controllers/sessions_test.go | 93 +++++- .../internal/service/session/pr_summary.go | 287 ++++++++++++++++++ backend/internal/service/session/service.go | 2 + .../internal/service/session/service_test.go | 94 +++++- docs/STATUS.md | 13 +- frontend/src/api/schema.ts | 79 ++++- .../renderer/components/PullRequestsPage.tsx | 38 ++- .../renderer/components/SessionInspector.tsx | 178 ++++++++--- .../src/renderer/components/SessionsBoard.tsx | 33 +- .../src/renderer/components/ShellTopbar.tsx | 1 + .../src/renderer/components/Sidebar.test.tsx | 2 +- frontend/src/renderer/components/Sidebar.tsx | 2 +- .../renderer/hooks/useSessionScmSummary.ts | 36 +++ .../renderer/hooks/useWorkspaceQuery.test.tsx | 33 +- .../src/renderer/lib/event-transport.test.ts | 5 +- frontend/src/renderer/lib/event-transport.ts | 2 + frontend/src/renderer/types/workspace.test.ts | 21 +- frontend/src/renderer/types/workspace.ts | 18 +- 24 files changed, 1232 insertions(+), 133 deletions(-) create mode 100644 backend/internal/service/session/pr_summary.go create mode 100644 frontend/src/renderer/hooks/useSessionScmSummary.ts diff --git a/backend/internal/cli/dto_drift_e2e_test.go b/backend/internal/cli/dto_drift_e2e_test.go index 2488d8f4..d8085209 100644 --- a/backend/internal/cli/dto_drift_e2e_test.go +++ b/backend/internal/cli/dto_drift_e2e_test.go @@ -94,7 +94,7 @@ func (f *fakeSessionService) Send(context.Context, domain.SessionID, string) err return nil } -func (f *fakeSessionService) ListPRs(context.Context, domain.SessionID) ([]domain.PRFacts, error) { +func (f *fakeSessionService) ListPRSummaries(context.Context, domain.SessionID) ([]sessionsvc.PRSummary, error) { return nil, nil } diff --git a/backend/internal/domain/session.go b/backend/internal/domain/session.go index 6cc639e5..19d777ac 100644 --- a/backend/internal/domain/session.go +++ b/backend/internal/domain/session.go @@ -59,7 +59,7 @@ type SessionRecord struct { // plus the derived display Status. type Session struct { SessionRecord - Status SessionStatus `json:"status"` + Status SessionStatus `json:"status" enum:"working,pr_open,draft,ci_failed,review_pending,changes_requested,approved,mergeable,merged,needs_input,idle,terminated,no_signal"` TerminalHandleID string `json:"terminalHandleId,omitempty"` // PRs are the session's attributed pull requests (one session can own many). // They feed status derivation and are surfaced on the API read model. Not diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index a279d460..a09095d2 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -1283,6 +1283,20 @@ components: $ref: '#/components/schemas/SessionPRFacts' type: array status: + enum: + - working + - pr_open + - draft + - ci_failed + - review_pending + - changes_requested + - approved + - mergeable + - merged + - needs_input + - idle + - terminated + - no_signal type: string terminalHandleId: type: string @@ -1383,7 +1397,7 @@ components: properties: prs: items: - $ref: '#/components/schemas/SessionPRFacts' + $ref: '#/components/schemas/SessionPRSummary' type: array sessionId: type: string @@ -1730,15 +1744,57 @@ components: - sessionId - message type: object + SessionPRCISummary: + properties: + failingChecks: + items: + $ref: '#/components/schemas/SessionPRFailingCheck' + type: array + state: + enum: + - unknown + - pending + - passing + - failing + type: string + required: + - state + - failingChecks + type: object + SessionPRConflictFile: + properties: + path: + type: string + url: + type: string + required: + - path + type: object SessionPRFacts: properties: ci: + enum: + - unknown + - pending + - passing + - failing type: string mergeability: + enum: + - unknown + - mergeable + - conflicting + - blocked + - unstable type: string number: type: integer review: + enum: + - none + - approved + - changes_requested + - review_required type: string reviewComments: type: boolean @@ -1764,6 +1820,158 @@ components: - reviewComments - updatedAt type: object + SessionPRFailingCheck: + properties: + conclusion: + type: string + name: + type: string + status: + enum: + - failed + - cancelled + type: string + url: + type: string + required: + - name + - status + - conclusion + type: object + SessionPRMergeabilitySummary: + properties: + conflictFiles: + items: + $ref: '#/components/schemas/SessionPRConflictFile' + type: array + prUrl: + type: string + reasons: + items: + type: string + type: array + state: + enum: + - unknown + - mergeable + - conflicting + - blocked + - unstable + type: string + required: + - state + - reasons + - prUrl + type: object + SessionPRReviewCommentLink: + properties: + file: + type: string + line: + type: integer + url: + type: string + type: object + SessionPRReviewSummary: + properties: + decision: + enum: + - none + - approved + - changes_requested + - review_required + type: string + hasUnresolvedHumanComments: + type: boolean + unresolvedBy: + items: + $ref: '#/components/schemas/SessionPRUnresolvedReviewer' + type: array + required: + - decision + - hasUnresolvedHumanComments + - unresolvedBy + type: object + SessionPRSummary: + properties: + author: + type: string + ci: + $ref: '#/components/schemas/SessionPRCISummary' + ciObservedAt: + format: date-time + type: string + headSha: + type: string + htmlUrl: + type: string + mergeability: + $ref: '#/components/schemas/SessionPRMergeabilitySummary' + number: + type: integer + observedAt: + format: date-time + type: string + provider: + enum: + - github + type: string + repo: + type: string + review: + $ref: '#/components/schemas/SessionPRReviewSummary' + reviewObservedAt: + format: date-time + type: string + sourceBranch: + type: string + state: + enum: + - draft + - open + - merged + - closed + type: string + targetBranch: + type: string + title: + type: string + updatedAt: + format: date-time + type: string + url: + type: string + required: + - url + - number + - title + - state + - provider + - repo + - author + - sourceBranch + - targetBranch + - headSha + - ci + - review + - mergeability + - updatedAt + type: object + SessionPRUnresolvedReviewer: + properties: + count: + type: integer + links: + items: + $ref: '#/components/schemas/SessionPRReviewCommentLink' + type: array + reviewerId: + type: string + required: + - reviewerId + - count + - links + type: object SessionResponse: properties: session: diff --git a/backend/internal/httpd/apispec/specgen/build.go b/backend/internal/httpd/apispec/specgen/build.go index 2aeca734..ddc2c24e 100644 --- a/backend/internal/httpd/apispec/specgen/build.go +++ b/backend/internal/httpd/apispec/specgen/build.go @@ -130,38 +130,46 @@ var schemaNames = map[string]string{ "DomainAgentConfig": "AgentConfig", "DomainRoleOverride": "RoleOverride", // httpd/controllers (wire envelopes) - "ControllersListProjectsResponse": "ListProjectsResponse", - "ControllersProjectResponse": "ProjectResponse", - "ControllersGetProjectResponse": "ProjectGetResponse", - "ControllersProjectOrDegraded": "ProjectOrDegraded", - "ControllersListSessionsQuery": "ListSessionsQuery", - "ControllersCleanupSessionsQuery": "CleanupSessionsQuery", - "ControllersListSessionsResponse": "ListSessionsResponse", - "ControllersSpawnSessionRequest": "SpawnSessionRequest", - "ControllersSessionResponse": "SessionResponse", - "ControllersRenameSessionRequest": "RenameSessionRequest", - "ControllersRenameSessionResponse": "RenameSessionResponse", - "ControllersRestoreSessionResponse": "RestoreSessionResponse", - "ControllersCleanupSessionsResponse": "CleanupSessionsResponse", - "ControllersCleanupSkippedSession": "CleanupSkippedSession", - "ControllersKillSessionResponse": "KillSessionResponse", - "ControllersRollbackSessionResponse": "RollbackSessionResponse", - "ControllersSendSessionMessageRequest": "SendSessionMessageRequest", - "ControllersSendSessionMessageResponse": "SendSessionMessageResponse", - "ControllersClaimPRResponse": "ClaimPRResponse", - "ControllersClaimPRRequest": "ClaimPRRequest", - "ControllersSessionPRFacts": "SessionPRFacts", - "ControllersListSessionPRsResponse": "ListSessionPRsResponse", - "ControllersSetActivityRequest": "SetActivityRequest", - "ControllersSetActivityResponse": "SetActivityResponse", - "ControllersSpawnOrchestratorRequest": "SpawnOrchestratorRequest", - "ControllersSpawnOrchestratorResponse": "SpawnOrchestratorResponse", - "ControllersOrchestratorResponse": "OrchestratorResponse", - "ControllersListNotificationsQuery": "ListNotificationsQuery", - "ControllersNotificationStreamQuery": "NotificationStreamQuery", - "ControllersNotificationTarget": "NotificationTarget", - "ControllersNotificationResponse": "NotificationResponse", - "ControllersListNotificationsResponse": "ListNotificationsResponse", + "ControllersListProjectsResponse": "ListProjectsResponse", + "ControllersProjectResponse": "ProjectResponse", + "ControllersGetProjectResponse": "ProjectGetResponse", + "ControllersProjectOrDegraded": "ProjectOrDegraded", + "ControllersListSessionsQuery": "ListSessionsQuery", + "ControllersCleanupSessionsQuery": "CleanupSessionsQuery", + "ControllersListSessionsResponse": "ListSessionsResponse", + "ControllersSpawnSessionRequest": "SpawnSessionRequest", + "ControllersSessionResponse": "SessionResponse", + "ControllersRenameSessionRequest": "RenameSessionRequest", + "ControllersRenameSessionResponse": "RenameSessionResponse", + "ControllersRestoreSessionResponse": "RestoreSessionResponse", + "ControllersCleanupSessionsResponse": "CleanupSessionsResponse", + "ControllersCleanupSkippedSession": "CleanupSkippedSession", + "ControllersKillSessionResponse": "KillSessionResponse", + "ControllersRollbackSessionResponse": "RollbackSessionResponse", + "ControllersSendSessionMessageRequest": "SendSessionMessageRequest", + "ControllersSendSessionMessageResponse": "SendSessionMessageResponse", + "ControllersClaimPRResponse": "ClaimPRResponse", + "ControllersClaimPRRequest": "ClaimPRRequest", + "ControllersSessionPRFacts": "SessionPRFacts", + "ControllersSessionPRSummary": "SessionPRSummary", + "ControllersSessionPRCISummary": "SessionPRCISummary", + "ControllersSessionPRFailingCheck": "SessionPRFailingCheck", + "ControllersSessionPRReviewSummary": "SessionPRReviewSummary", + "ControllersSessionPRUnresolvedReviewer": "SessionPRUnresolvedReviewer", + "ControllersSessionPRReviewCommentLink": "SessionPRReviewCommentLink", + "ControllersSessionPRMergeabilitySummary": "SessionPRMergeabilitySummary", + "ControllersSessionPRConflictFile": "SessionPRConflictFile", + "ControllersListSessionPRsResponse": "ListSessionPRsResponse", + "ControllersSetActivityRequest": "SetActivityRequest", + "ControllersSetActivityResponse": "SetActivityResponse", + "ControllersSpawnOrchestratorRequest": "SpawnOrchestratorRequest", + "ControllersSpawnOrchestratorResponse": "SpawnOrchestratorResponse", + "ControllersOrchestratorResponse": "OrchestratorResponse", + "ControllersListNotificationsQuery": "ListNotificationsQuery", + "ControllersNotificationStreamQuery": "NotificationStreamQuery", + "ControllersNotificationTarget": "NotificationTarget", + "ControllersNotificationResponse": "NotificationResponse", + "ControllersListNotificationsResponse": "ListNotificationsResponse", // httpd/controllers — PR wire envelopes "ControllersMergePRResponse": "MergePRResponse", "ControllersResolveCommentsRequest": "ResolveCommentsRequest", diff --git a/backend/internal/httpd/controllers/dto.go b/backend/internal/httpd/controllers/dto.go index 2da2fe91..6ae2ea87 100644 --- a/backend/internal/httpd/controllers/dto.go +++ b/backend/internal/httpd/controllers/dto.go @@ -7,6 +7,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/domain" projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project" + sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session" ) // HTTP response envelopes for the projects surface — the SINGLE definition of @@ -208,17 +209,134 @@ type SessionPRFacts struct { URL string `json:"url"` Number int `json:"number"` State string `json:"state" enum:"draft,open,merged,closed"` - CI domain.CIState `json:"ci"` - Review domain.ReviewDecision `json:"review"` - Mergeability domain.Mergeability `json:"mergeability"` + CI domain.CIState `json:"ci" enum:"unknown,pending,passing,failing"` + Review domain.ReviewDecision `json:"review" enum:"none,approved,changes_requested,review_required"` + Mergeability domain.Mergeability `json:"mergeability" enum:"unknown,mergeable,conflicting,blocked,unstable"` ReviewComments bool `json:"reviewComments"` UpdatedAt time.Time `json:"updatedAt"` } +// SessionPRSummary is the concise desktop SCM read model returned by GET +// /sessions/{sessionId}/pr. It intentionally omits CI log tails and review +// comment bodies. +type SessionPRSummary struct { + URL string `json:"url"` + HTMLURL string `json:"htmlUrl,omitempty"` + Number int `json:"number"` + Title string `json:"title"` + State domain.PRState `json:"state" enum:"draft,open,merged,closed"` + Provider string `json:"provider" enum:"github"` + Repo string `json:"repo"` + Author string `json:"author"` + SourceBranch string `json:"sourceBranch"` + TargetBranch string `json:"targetBranch"` + HeadSHA string `json:"headSha"` + CI SessionPRCISummary `json:"ci"` + Review SessionPRReviewSummary `json:"review"` + Mergeability SessionPRMergeabilitySummary `json:"mergeability"` + UpdatedAt time.Time `json:"updatedAt"` + ObservedAt time.Time `json:"observedAt,omitempty"` + CIObservedAt time.Time `json:"ciObservedAt,omitempty"` + ReviewObservedAt time.Time `json:"reviewObservedAt,omitempty"` +} + +type SessionPRCISummary struct { + State domain.CIState `json:"state" enum:"unknown,pending,passing,failing"` + FailingChecks []SessionPRFailingCheck `json:"failingChecks"` +} + +type SessionPRFailingCheck struct { + Name string `json:"name"` + Status domain.PRCheckStatus `json:"status" enum:"failed,cancelled"` + Conclusion string `json:"conclusion"` + URL string `json:"url,omitempty"` +} + +type SessionPRReviewSummary struct { + Decision domain.ReviewDecision `json:"decision" enum:"none,approved,changes_requested,review_required"` + HasUnresolvedHumanComments bool `json:"hasUnresolvedHumanComments"` + UnresolvedBy []SessionPRUnresolvedReviewer `json:"unresolvedBy"` +} + +type SessionPRUnresolvedReviewer struct { + ReviewerID string `json:"reviewerId"` + Count int `json:"count"` + Links []SessionPRReviewCommentLink `json:"links"` +} + +type SessionPRReviewCommentLink struct { + URL string `json:"url,omitempty"` + File string `json:"file,omitempty"` + Line int `json:"line,omitempty"` +} + +type SessionPRMergeabilitySummary struct { + State domain.Mergeability `json:"state" enum:"unknown,mergeable,conflicting,blocked,unstable"` + Reasons []string `json:"reasons"` + PRURL string `json:"prUrl"` + ConflictFiles []SessionPRConflictFile `json:"conflictFiles,omitempty"` +} + +type SessionPRConflictFile struct { + Path string `json:"path"` + URL string `json:"url,omitempty"` +} + // ListSessionPRsResponse is the body of GET /sessions/{sessionId}/pr. type ListSessionPRsResponse struct { - SessionID domain.SessionID `json:"sessionId"` - PRs []SessionPRFacts `json:"prs"` + SessionID domain.SessionID `json:"sessionId"` + PRs []SessionPRSummary `json:"prs"` +} + +func NewSessionPRSummary(in sessionsvc.PRSummary) SessionPRSummary { + return SessionPRSummary{ + URL: in.URL, + HTMLURL: in.HTMLURL, + Number: in.Number, + Title: in.Title, + State: in.State, + Provider: in.Provider, + Repo: in.Repo, + Author: in.Author, + SourceBranch: in.SourceBranch, + TargetBranch: in.TargetBranch, + HeadSHA: in.HeadSHA, + CI: newSessionPRCISummary(in.CI), + Review: newSessionPRReviewSummary(in.Review), + Mergeability: newSessionPRMergeabilitySummary(in.Mergeability), + UpdatedAt: in.UpdatedAt, + ObservedAt: in.ObservedAt, + CIObservedAt: in.CIObservedAt, + ReviewObservedAt: in.ReviewObservedAt, + } +} + +func newSessionPRCISummary(in sessionsvc.PRCISummary) SessionPRCISummary { + checks := make([]SessionPRFailingCheck, 0, len(in.FailingChecks)) + for _, ch := range in.FailingChecks { + checks = append(checks, SessionPRFailingCheck{Name: ch.Name, Status: ch.Status, Conclusion: ch.Conclusion, URL: ch.URL}) + } + return SessionPRCISummary{State: in.State, FailingChecks: checks} +} + +func newSessionPRReviewSummary(in sessionsvc.PRReviewSummary) SessionPRReviewSummary { + reviewers := make([]SessionPRUnresolvedReviewer, 0, len(in.UnresolvedBy)) + for _, reviewer := range in.UnresolvedBy { + links := make([]SessionPRReviewCommentLink, 0, len(reviewer.Links)) + for _, link := range reviewer.Links { + links = append(links, SessionPRReviewCommentLink{URL: link.URL, File: link.File, Line: link.Line}) + } + reviewers = append(reviewers, SessionPRUnresolvedReviewer{ReviewerID: reviewer.ReviewerID, Count: reviewer.Count, Links: links}) + } + return SessionPRReviewSummary{Decision: in.Decision, HasUnresolvedHumanComments: in.HasUnresolvedHumanComments, UnresolvedBy: reviewers} +} + +func newSessionPRMergeabilitySummary(in sessionsvc.PRMergeabilitySummary) SessionPRMergeabilitySummary { + files := make([]SessionPRConflictFile, 0, len(in.ConflictFiles)) + for _, file := range in.ConflictFiles { + files = append(files, SessionPRConflictFile{Path: file.Path, URL: file.URL}) + } + return SessionPRMergeabilitySummary{State: in.State, Reasons: in.Reasons, PRURL: in.PRURL, ConflictFiles: files} } // ClaimPRRequest is the body of POST /sessions/{sessionId}/pr/claim. diff --git a/backend/internal/httpd/controllers/sessions.go b/backend/internal/httpd/controllers/sessions.go index b59d2d0a..967e00fc 100644 --- a/backend/internal/httpd/controllers/sessions.go +++ b/backend/internal/httpd/controllers/sessions.go @@ -33,7 +33,7 @@ type SessionService interface { Cleanup(ctx context.Context, project domain.ProjectID) (sessionsvc.CleanupOutcome, error) Rename(ctx context.Context, id domain.SessionID, displayName string) error Send(ctx context.Context, id domain.SessionID, message string) error - ListPRs(ctx context.Context, id domain.SessionID) ([]domain.PRFacts, error) + ListPRSummaries(ctx context.Context, id domain.SessionID) ([]sessionsvc.PRSummary, error) ClaimPR(ctx context.Context, id domain.SessionID, ref string, opts sessionsvc.ClaimPROptions) (sessionsvc.ClaimPRResult, error) } @@ -137,12 +137,12 @@ func (c *SessionsController) listPRs(w http.ResponseWriter, r *http.Request) { apispec.NotImplemented(w, r, "GET", "/api/v1/sessions/{sessionId}/pr") return } - prs, err := c.Svc.ListPRs(r.Context(), sessionID(r)) + prs, err := c.Svc.ListPRSummaries(r.Context(), sessionID(r)) if err != nil { envelope.WriteError(w, r, err) return } - envelope.WriteJSON(w, http.StatusOK, ListSessionPRsResponse{SessionID: sessionID(r), PRs: sessionPRFacts(prs)}) + envelope.WriteJSON(w, http.StatusOK, ListSessionPRsResponse{SessionID: sessionID(r), PRs: sessionPRSummaries(prs)}) } func (c *SessionsController) claimPR(w http.ResponseWriter, r *http.Request) { @@ -446,6 +446,14 @@ func sessionPRFacts(prs []domain.PRFacts) []SessionPRFacts { return out } +func sessionPRSummaries(prs []sessionsvc.PRSummary) []SessionPRSummary { + out := make([]SessionPRSummary, 0, len(prs)) + for _, pr := range prs { + out = append(out, NewSessionPRSummary(pr)) + } + return out +} + func prState(pr domain.PRFacts) string { switch { case pr.Merged: diff --git a/backend/internal/httpd/controllers/sessions_test.go b/backend/internal/httpd/controllers/sessions_test.go index 1b588680..2fa2415b 100644 --- a/backend/internal/httpd/controllers/sessions_test.go +++ b/backend/internal/httpd/controllers/sessions_test.go @@ -139,6 +139,49 @@ func (f *fakeSessionService) ListPRs(_ context.Context, id domain.SessionID) ([] return []domain.PRFacts{{URL: "https://github.com/aoagents/agent-orchestrator/pull/142", Number: 142, CI: domain.CIPassing, Review: domain.ReviewRequired, Mergeability: domain.MergeMergeable, UpdatedAt: time.Date(2026, 6, 4, 12, 0, 0, 0, time.UTC)}}, nil } +func (f *fakeSessionService) ListPRSummaries(_ context.Context, id domain.SessionID) ([]sessionsvc.PRSummary, error) { + if f.listPRErr != nil { + return nil, f.listPRErr + } + if _, ok := f.sessions[id]; !ok { + return nil, apierr.NotFound("SESSION_NOT_FOUND", "Unknown session") + } + return []sessionsvc.PRSummary{{ + URL: "https://github.com/aoagents/agent-orchestrator/pull/142", + HTMLURL: "https://github.com/aoagents/agent-orchestrator/pull/142", + Number: 142, + Title: "Wire SCM summaries", + State: domain.PRStateOpen, + Provider: "github", + Repo: "aoagents/agent-orchestrator", + Author: "ada", + SourceBranch: "codex/scm-observer-v1", + TargetBranch: "main", + HeadSHA: "abc123", + CI: sessionsvc.PRCISummary{State: domain.CIFailing, FailingChecks: []sessionsvc.PRFailingCheck{{ + Name: "unit", + Status: domain.PRCheckFailed, + Conclusion: "failure", + URL: "https://github.com/aoagents/agent-orchestrator/actions/runs/1", + }}}, + Review: sessionsvc.PRReviewSummary{ + Decision: domain.ReviewChangesRequest, + HasUnresolvedHumanComments: true, + UnresolvedBy: []sessionsvc.PRUnresolvedReviewer{{ + ReviewerID: "reviewer-a", + Count: 1, + Links: []sessionsvc.PRReviewCommentLink{{URL: "https://github.com/aoagents/agent-orchestrator/pull/142#discussion_r1", File: "main.go", Line: 12}}, + }}, + }, + Mergeability: sessionsvc.PRMergeabilitySummary{ + State: domain.MergeConflicting, + Reasons: []string{"conflicts"}, + PRURL: "https://github.com/aoagents/agent-orchestrator/pull/142", + }, + UpdatedAt: time.Date(2026, 6, 4, 12, 0, 0, 0, time.UTC), + }}, nil +} + func (f *fakeSessionService) ClaimPR(_ context.Context, id domain.SessionID, ref string, opts sessionsvc.ClaimPROptions) (sessionsvc.ClaimPRResult, error) { if f.claimErr != nil { return sessionsvc.ClaimPRResult{}, f.claimErr @@ -402,16 +445,56 @@ func TestSessionsAPI_PRRoutes(t *testing.T) { var listed struct { SessionID string `json:"sessionId"` PRs []struct { - URL string `json:"url"` - Number int `json:"number"` - State string `json:"state"` - UpdatedAt string `json:"updatedAt"` + URL string `json:"url"` + Number int `json:"number"` + Title string `json:"title"` + State string `json:"state"` + CI struct { + State string `json:"state"` + FailingChecks []struct { + Name string `json:"name"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + URL string `json:"url"` + LogTail string `json:"logTail"` + } `json:"failingChecks"` + } `json:"ci"` + Review struct { + Decision string `json:"decision"` + UnresolvedBy []struct { + ReviewerID string `json:"reviewerId"` + Count int `json:"count"` + Links []struct { + URL string `json:"url"` + File string `json:"file"` + Line int `json:"line"` + Body string `json:"body"` + } `json:"links"` + } `json:"unresolvedBy"` + } `json:"review"` + Mergeability struct { + State string `json:"state"` + Reasons []string `json:"reasons"` + PRURL string `json:"prUrl"` + ConflictFiles []struct { + Path string `json:"path"` + } `json:"conflictFiles"` + } `json:"mergeability"` } `json:"prs"` } mustJSON(t, body, &listed) - if listed.SessionID != "ao-1" || len(listed.PRs) != 1 || listed.PRs[0].State != "open" { + if listed.SessionID != "ao-1" || len(listed.PRs) != 1 || listed.PRs[0].State != "open" || listed.PRs[0].Title == "" { t.Fatalf("GET shape = %#v", listed) } + if checks := listed.PRs[0].CI.FailingChecks; len(checks) != 1 || checks[0].Name != "unit" || checks[0].LogTail != "" { + t.Fatalf("failing checks = %#v", checks) + } + if reviewers := listed.PRs[0].Review.UnresolvedBy; len(reviewers) != 1 || reviewers[0].ReviewerID != "reviewer-a" || reviewers[0].Links[0].Body != "" { + t.Fatalf("reviewers = %#v", reviewers) + } + if merge := listed.PRs[0].Mergeability; merge.State != "conflicting" || len(merge.ConflictFiles) != 0 || merge.PRURL == "" { + t.Fatalf("mergeability = %#v", merge) + } body, status, _ = doRequest(t, srv, "POST", "/api/v1/sessions/ao-1/pr/claim", `{"pr":"142"}`) if status != http.StatusOK { diff --git a/backend/internal/service/session/pr_summary.go b/backend/internal/service/session/pr_summary.go new file mode 100644 index 00000000..277c2fdf --- /dev/null +++ b/backend/internal/service/session/pr_summary.go @@ -0,0 +1,287 @@ +package session + +import ( + "context" + "fmt" + "sort" + "strings" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apierr" +) + +// PRSummary is the user-facing SCM read model for one PR owned by a session. +type PRSummary struct { + URL string + HTMLURL string + Number int + Title string + State domain.PRState + Provider string + Repo string + Author string + SourceBranch string + TargetBranch string + HeadSHA string + CI PRCISummary + Review PRReviewSummary + Mergeability PRMergeabilitySummary + UpdatedAt time.Time + ObservedAt time.Time + CIObservedAt time.Time + ReviewObservedAt time.Time +} + +type PRCISummary struct { + State domain.CIState + FailingChecks []PRFailingCheck +} + +type PRFailingCheck struct { + Name string + Status domain.PRCheckStatus + Conclusion string + URL string +} + +type PRReviewSummary struct { + Decision domain.ReviewDecision + HasUnresolvedHumanComments bool + UnresolvedBy []PRUnresolvedReviewer +} + +type PRUnresolvedReviewer struct { + ReviewerID string + Count int + Links []PRReviewCommentLink +} + +type PRReviewCommentLink struct { + URL string + File string + Line int +} + +type PRMergeabilitySummary struct { + State domain.Mergeability + Reasons []string + PRURL string + ConflictFiles []PRConflictFile +} + +type PRConflictFile struct { + Path string + URL string +} + +// ListPRSummaries returns all PRs owned by a session with concise SCM details +// assembled from persisted PR/check/review facts. +func (s *Service) ListPRSummaries(ctx context.Context, id domain.SessionID) ([]PRSummary, error) { + if _, ok, err := s.store.GetSession(ctx, id); err != nil { + return nil, fmt.Errorf("get %s: %w", id, err) + } else if !ok { + return nil, apierr.NotFound("SESSION_NOT_FOUND", "Unknown session") + } + prs, err := s.store.ListPRsBySession(ctx, id) + if err != nil { + return nil, err + } + out := make([]PRSummary, 0, len(prs)) + for _, pr := range prs { + checks, err := s.store.ListChecks(ctx, pr.URL) + if err != nil { + return nil, err + } + threads, err := s.store.ListPRReviewThreads(ctx, pr.URL) + if err != nil { + return nil, err + } + comments, err := s.store.ListPRComments(ctx, pr.URL) + if err != nil { + return nil, err + } + out = append(out, summarizePR(pr, checks, threads, comments)) + } + sortPRSummaries(out) + return out, nil +} + +func summarizePR(pr domain.PullRequest, checks []domain.PullRequestCheck, threads []domain.PullRequestReviewThread, comments []domain.PullRequestComment) PRSummary { + return PRSummary{ + URL: pr.URL, + HTMLURL: firstNonEmpty(pr.HTMLURL, pr.URL), + Number: pr.Number, + Title: pr.Title, + State: pullRequestState(pr), + Provider: firstNonEmpty(pr.Provider, "github"), + Repo: pr.Repo, + Author: pr.Author, + SourceBranch: pr.SourceBranch, + TargetBranch: pr.TargetBranch, + HeadSHA: pr.HeadSHA, + CI: summarizeCI(pr.CI, checks), + Review: summarizeReview(pr.Review, comments), + Mergeability: summarizeMergeability(pr, threads), + UpdatedAt: pr.UpdatedAt, + ObservedAt: pr.ObservedAt, + CIObservedAt: pr.CIObservedAt, + ReviewObservedAt: pr.ReviewObservedAt, + } +} + +func summarizeCI(state domain.CIState, checks []domain.PullRequestCheck) PRCISummary { + out := PRCISummary{State: ciOrUnknown(state)} + for _, ch := range checks { + if ch.Status != domain.PRCheckFailed && ch.Status != domain.PRCheckCancelled { + continue + } + out.FailingChecks = append(out.FailingChecks, PRFailingCheck{ + Name: ch.Name, + Status: ch.Status, + Conclusion: ch.Conclusion, + URL: ch.URL, + }) + } + return out +} + +func summarizeReview(decision domain.ReviewDecision, comments []domain.PullRequestComment) PRReviewSummary { + out := PRReviewSummary{Decision: reviewOrNone(decision)} + byReviewer := map[string]int{} + order := []string{} + links := map[string][]PRReviewCommentLink{} + for _, c := range comments { + if c.Resolved || c.IsBot { + continue + } + reviewer := strings.TrimSpace(c.Author) + if reviewer == "" { + reviewer = "unknown" + } + if _, ok := byReviewer[reviewer]; !ok { + order = append(order, reviewer) + } + byReviewer[reviewer]++ + links[reviewer] = append(links[reviewer], PRReviewCommentLink{ + URL: c.URL, + File: c.File, + Line: c.Line, + }) + } + sort.Strings(order) + for _, reviewer := range order { + out.UnresolvedBy = append(out.UnresolvedBy, PRUnresolvedReviewer{ + ReviewerID: reviewer, + Count: byReviewer[reviewer], + Links: links[reviewer], + }) + } + out.HasUnresolvedHumanComments = len(out.UnresolvedBy) > 0 + return out +} + +func summarizeMergeability(pr domain.PullRequest, _ []domain.PullRequestReviewThread) PRMergeabilitySummary { + return PRMergeabilitySummary{ + State: mergeabilityOrUnknown(pr.Mergeability), + Reasons: mergeabilityReasons(pr), + PRURL: firstNonEmpty(pr.HTMLURL, pr.URL), + } +} + +func mergeabilityReasons(pr domain.PullRequest) []string { + reasons := map[string]bool{} + add := func(reason string) { + if reason != "" { + reasons[reason] = true + } + } + if pr.Mergeability == domain.MergeConflicting || containsAny(pr.ProviderMergeable, "conflict", "dirty") || containsAny(pr.ProviderMergeStateStatus, "conflict", "dirty") { + add("conflicts") + } + if containsAny(pr.ProviderMergeStateStatus, "behind") { + add("behind_base") + } + if pr.Draft { + add("draft") + } + if pr.CI == domain.CIFailing { + add("ci_failing") + } + if pr.Review == domain.ReviewChangesRequest { + add("changes_requested") + } + if pr.Review == domain.ReviewRequired { + add("review_required") + } + if pr.Mergeability == domain.MergeBlocked && len(reasons) == 0 { + add("blocked_by_provider") + } + if pr.Mergeability == domain.MergeUnstable && len(reasons) == 0 { + add("blocked_by_provider") + } + out := make([]string, 0, len(reasons)) + for reason := range reasons { + out = append(out, reason) + } + sort.Strings(out) + return out +} + +func containsAny(s string, needles ...string) bool { + s = strings.ToLower(s) + for _, needle := range needles { + if strings.Contains(s, needle) { + return true + } + } + return false +} + +func sortPRSummaries(prs []PRSummary) { + sort.SliceStable(prs, func(i, j int) bool { + ia, ja := prSummaryActive(prs[i]), prSummaryActive(prs[j]) + if ia != ja { + return ia + } + return prs[i].UpdatedAt.After(prs[j].UpdatedAt) + }) +} + +func prSummaryActive(pr PRSummary) bool { + return pr.State != domain.PRStateMerged && pr.State != domain.PRStateClosed +} + +func pullRequestState(pr domain.PullRequest) domain.PRState { + switch { + case pr.Merged: + return domain.PRStateMerged + case pr.Closed: + return domain.PRStateClosed + case pr.Draft: + return domain.PRStateDraft + default: + return domain.PRStateOpen + } +} + +func ciOrUnknown(state domain.CIState) domain.CIState { + if state == "" { + return domain.CIUnknown + } + return state +} + +func reviewOrNone(decision domain.ReviewDecision) domain.ReviewDecision { + if decision == "" { + return domain.ReviewNone + } + return decision +} + +func mergeabilityOrUnknown(state domain.Mergeability) domain.Mergeability { + if state == "" { + return domain.MergeUnknown + } + return state +} diff --git a/backend/internal/service/session/service.go b/backend/internal/service/session/service.go index bbcb32c1..8250b3db 100644 --- a/backend/internal/service/session/service.go +++ b/backend/internal/service/session/service.go @@ -22,6 +22,8 @@ type Store interface { GetDisplayPRFactsForSession(ctx context.Context, id domain.SessionID) (domain.PRFacts, bool, error) ListPRFactsForSession(ctx context.Context, id domain.SessionID) ([]domain.PRFacts, error) ListPRsBySession(ctx context.Context, sessionID domain.SessionID) ([]domain.PullRequest, error) + ListChecks(ctx context.Context, prURL string) ([]domain.PullRequestCheck, error) + ListPRReviewThreads(ctx context.Context, prURL string) ([]domain.PullRequestReviewThread, error) ListPRComments(ctx context.Context, prURL string) ([]domain.PullRequestComment, error) GetProject(ctx context.Context, id string) (domain.ProjectRecord, bool, error) } diff --git a/backend/internal/service/session/service_test.go b/backend/internal/service/session/service_test.go index ee114108..80a2f308 100644 --- a/backend/internal/service/session/service_test.go +++ b/backend/internal/service/session/service_test.go @@ -24,11 +24,21 @@ type fakeStore struct { sessions map[domain.SessionID]domain.SessionRecord pr map[domain.SessionID]domain.PRFacts projects map[string]domain.ProjectRecord + checks map[string][]domain.PullRequestCheck + threads map[string][]domain.PullRequestReviewThread + comments map[string][]domain.PullRequestComment num int } func newFakeStore() *fakeStore { - return &fakeStore{sessions: map[domain.SessionID]domain.SessionRecord{}, pr: map[domain.SessionID]domain.PRFacts{}, projects: map[string]domain.ProjectRecord{}} + return &fakeStore{ + sessions: map[domain.SessionID]domain.SessionRecord{}, + pr: map[domain.SessionID]domain.PRFacts{}, + projects: map[string]domain.ProjectRecord{}, + checks: map[string][]domain.PullRequestCheck{}, + threads: map[string][]domain.PullRequestReviewThread{}, + comments: map[string][]domain.PullRequestComment{}, + } } func (f *fakeStore) CreateSession(_ context.Context, rec domain.SessionRecord) (domain.SessionRecord, error) { @@ -93,8 +103,16 @@ func (f *fakeStore) ListPRFactsForSession(_ context.Context, id domain.SessionID return []domain.PRFacts{pr}, nil } -func (f *fakeStore) ListPRComments(context.Context, string) ([]domain.PullRequestComment, error) { - return nil, nil +func (f *fakeStore) ListChecks(_ context.Context, prURL string) ([]domain.PullRequestCheck, error) { + return append([]domain.PullRequestCheck(nil), f.checks[prURL]...), nil +} + +func (f *fakeStore) ListPRReviewThreads(_ context.Context, prURL string) ([]domain.PullRequestReviewThread, error) { + return append([]domain.PullRequestReviewThread(nil), f.threads[prURL]...), nil +} + +func (f *fakeStore) ListPRComments(_ context.Context, prURL string) ([]domain.PullRequestComment, error) { + return append([]domain.PullRequestComment(nil), f.comments[prURL]...), nil } func (f *fakeStore) GetProject(_ context.Context, id string) (domain.ProjectRecord, bool, error) { @@ -557,6 +575,67 @@ func TestListPRsOrdersActiveBeforeClosedThenUpdatedDesc(t *testing.T) { } } +func TestListPRSummariesOmitsRawLogsAndReviewBodies(t *testing.T) { + st := newFakeStore() + now := time.Date(2026, 6, 4, 12, 0, 0, 0, time.UTC) + st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Kind: domain.KindWorker} + prURL := "https://github.com/acme/repo/pull/7" + stList := &multiPRFakeStore{fakeStore: st, prs: []domain.PullRequest{{ + URL: prURL, + HTMLURL: prURL, + SessionID: "mer-1", + Number: 7, + CI: domain.CIFailing, + Review: domain.ReviewChangesRequest, + Mergeability: domain.MergeConflicting, + Provider: "github", + Repo: "acme/repo", + Title: "Fix dashboard", + Author: "ada", + SourceBranch: "fix/dashboard", + TargetBranch: "main", + HeadSHA: "abc123", + ProviderMergeStateStatus: "dirty", + UpdatedAt: now, + ObservedAt: now.Add(-time.Minute), + CIObservedAt: now.Add(-time.Minute), + ReviewObservedAt: now.Add(-time.Minute), + }}} + stList.checks[prURL] = []domain.PullRequestCheck{ + {Name: "unit", Status: domain.PRCheckFailed, Conclusion: "failure", URL: "https://github.com/acme/repo/actions/runs/1", LogTail: "panic: secret"}, + {Name: "lint", Status: domain.PRCheckPassed, Conclusion: "success", URL: "https://github.com/acme/repo/actions/runs/2"}, + } + stList.comments[prURL] = []domain.PullRequestComment{ + {Author: "reviewer-a", File: "main.go", Line: 12, Body: "raw body must stay private", URL: "https://github.com/acme/repo/pull/7#discussion_r1"}, + {Author: "ci-bot", File: "main.go", Line: 13, Body: "bot body", URL: "https://github.com/acme/repo/pull/7#discussion_r2", IsBot: true}, + {Author: "reviewer-a", File: "test.go", Line: 22, Body: "another raw body", URL: "https://github.com/acme/repo/pull/7#discussion_r3"}, + } + + got, err := (&Service{store: stList}).ListPRSummaries(context.Background(), "mer-1") + if err != nil { + t.Fatal(err) + } + if len(got) != 1 { + t.Fatalf("summaries = %+v", got) + } + pr := got[0] + if pr.Title != "Fix dashboard" || pr.State != domain.PRStateOpen || pr.Provider != "github" || pr.Repo != "acme/repo" || pr.HeadSHA != "abc123" { + t.Fatalf("metadata = %+v", pr) + } + if len(pr.CI.FailingChecks) != 1 || pr.CI.FailingChecks[0].Name != "unit" || pr.CI.FailingChecks[0].URL == "" { + t.Fatalf("failing checks = %+v", pr.CI.FailingChecks) + } + if pr.Review.Decision != domain.ReviewChangesRequest || !pr.Review.HasUnresolvedHumanComments || len(pr.Review.UnresolvedBy) != 1 { + t.Fatalf("review = %+v", pr.Review) + } + if reviewer := pr.Review.UnresolvedBy[0]; reviewer.ReviewerID != "reviewer-a" || reviewer.Count != 2 || len(reviewer.Links) != 2 { + t.Fatalf("reviewer = %+v", reviewer) + } + if pr.Mergeability.State != domain.MergeConflicting || len(pr.Mergeability.ConflictFiles) != 0 || !containsString(pr.Mergeability.Reasons, "conflicts") { + t.Fatalf("mergeability = %+v", pr.Mergeability) + } +} + type multiPRFakeStore struct { *fakeStore prs []domain.PullRequest @@ -565,3 +644,12 @@ type multiPRFakeStore struct { func (f *multiPRFakeStore) ListPRsBySession(context.Context, domain.SessionID) ([]domain.PullRequest, error) { return f.prs, nil } + +func containsString(values []string, want string) bool { + for _, got := range values { + if got == want { + return true + } + } + return false +} diff --git a/docs/STATUS.md b/docs/STATUS.md index 13cbbd37..9d0ec703 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -63,6 +63,14 @@ surface (`npm run sqlc`, `npm run api`). - Shell: sidebar (projects + sessions, add/remove project), sessions board, session view + inspector, project settings, pull-requests page, spawn-orchestrator flow. +- Desktop status and SCM summary V1: session status comes from + `GET /api/v1/sessions`; visible/active PR context comes from + `GET /api/v1/sessions/{sessionId}/pr`; `GET /api/v1/events` is kept open as + an invalidation stream rather than a full PR payload stream. +- Concise PR summaries include PR identity, CI state with failing check names + and links, human reviewer IDs/counts/links for unresolved review comments, + and mergeability reasons. Raw CI logs and review comment bodies are + intentionally not part of the desktop V1 API/UI. - Terminal pane (xterm) over the mux WebSocket, with a live SSE events connection and port-rebind on daemon restart. @@ -73,8 +81,9 @@ surface (`npm run sqlc`, `npm run api`). nothing at runtime ([#112](https://github.com/aoagents/agent-orchestrator/issues/112)). - **Notifications**: design/in-flight only; no shipped backend notifier or UI center. -- **Live PR/tracker fact surfacing**: the observer writes facts, but exposing - the full `pr_*` / `tracker_*` CDC events to live consumers +- **Full raw PR/tracker fact surfacing**: the SCM observer writes facts and the + desktop consumes concise PR summaries, but exposing the full raw `pr_*` / + `tracker_*` CDC events to live consumers ([#110](https://github.com/aoagents/agent-orchestrator/issues/110)) and in `ao session get` ([#111](https://github.com/aoagents/agent-orchestrator/issues/111)) is still open. diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 0bb8bd4a..658fa0bd 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -456,7 +456,8 @@ export interface components { kind: string; projectId: string; prs: components["schemas"]["SessionPRFacts"][]; - status: string; + /** @enum {string} */ + status: "working" | "pr_open" | "draft" | "ci_failed" | "review_pending" | "changes_requested" | "approved" | "mergeable" | "merged" | "needs_input" | "idle" | "terminated" | "no_signal"; terminalHandleId?: string; /** Format: date-time */ updatedAt: string; @@ -492,7 +493,7 @@ export interface components { reviews: components["schemas"]["ReviewRun"][]; }; ListSessionPRsResponse: { - prs: components["schemas"]["SessionPRFacts"][]; + prs: components["schemas"]["SessionPRSummary"][]; sessionId: string; }; ListSessionsResponse: { @@ -626,11 +627,23 @@ export interface components { ok: boolean; sessionId: string; }; + SessionPRCISummary: { + failingChecks: components["schemas"]["SessionPRFailingCheck"][]; + /** @enum {string} */ + state: "unknown" | "pending" | "passing" | "failing"; + }; + SessionPRConflictFile: { + path: string; + url?: string; + }; SessionPRFacts: { - ci: string; - mergeability: string; + /** @enum {string} */ + ci: "unknown" | "pending" | "passing" | "failing"; + /** @enum {string} */ + mergeability: "unknown" | "mergeable" | "conflicting" | "blocked" | "unstable"; number: number; - review: string; + /** @enum {string} */ + review: "none" | "approved" | "changes_requested" | "review_required"; reviewComments: boolean; /** @enum {string} */ state: "draft" | "open" | "merged" | "closed"; @@ -638,6 +651,62 @@ export interface components { updatedAt: string; url: string; }; + SessionPRFailingCheck: { + conclusion: string; + name: string; + /** @enum {string} */ + status: "failed" | "cancelled"; + url?: string; + }; + SessionPRMergeabilitySummary: { + conflictFiles?: components["schemas"]["SessionPRConflictFile"][]; + prUrl: string; + reasons: string[]; + /** @enum {string} */ + state: "unknown" | "mergeable" | "conflicting" | "blocked" | "unstable"; + }; + SessionPRReviewCommentLink: { + file?: string; + line?: number; + url?: string; + }; + SessionPRReviewSummary: { + /** @enum {string} */ + decision: "none" | "approved" | "changes_requested" | "review_required"; + hasUnresolvedHumanComments: boolean; + unresolvedBy: components["schemas"]["SessionPRUnresolvedReviewer"][]; + }; + SessionPRSummary: { + author: string; + ci: components["schemas"]["SessionPRCISummary"]; + /** Format: date-time */ + ciObservedAt?: string; + headSha: string; + htmlUrl?: string; + mergeability: components["schemas"]["SessionPRMergeabilitySummary"]; + number: number; + /** Format: date-time */ + observedAt?: string; + /** @enum {string} */ + provider: "github"; + repo: string; + review: components["schemas"]["SessionPRReviewSummary"]; + /** Format: date-time */ + reviewObservedAt?: string; + sourceBranch: string; + /** @enum {string} */ + state: "draft" | "open" | "merged" | "closed"; + targetBranch: string; + title: string; + /** Format: date-time */ + updatedAt: string; + url: string; + }; + SessionPRUnresolvedReviewer: { + count: number; + links: components["schemas"]["SessionPRReviewCommentLink"][]; + reviewerId: string; + }; SessionResponse: { session: components["schemas"]["ControllersSessionView"]; }; diff --git a/frontend/src/renderer/components/PullRequestsPage.tsx b/frontend/src/renderer/components/PullRequestsPage.tsx index bded8cf0..b71b3086 100644 --- a/frontend/src/renderer/components/PullRequestsPage.tsx +++ b/frontend/src/renderer/components/PullRequestsPage.tsx @@ -1,15 +1,22 @@ import { useNavigate } from "@tanstack/react-router"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; import { apiClient, apiErrorMessage } from "../lib/api-client"; import { useWorkspaceQuery, workspaceQueryKey } from "../hooks/useWorkspaceQuery"; -import { type PRState, type PullRequestFacts, type WorkspaceSession } from "../types/workspace"; +import { + sessionScmSummaryQueryKey, + sessionScmSummaryQueryOptions, + type SessionPRSummary, +} from "../hooks/useSessionScmSummary"; +import type { WorkspaceSession } from "../types/workspace"; import { DashboardSubhead } from "./DashboardSubhead"; import { Badge } from "./ui/badge"; import { Button } from "./ui/button"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./ui/table"; import { cn } from "../lib/utils"; +type PRState = SessionPRSummary["state"]; + const stateTone: Record = { open: "border-success/40 bg-success/10 text-success", draft: "border-border bg-raised text-muted-foreground", @@ -21,7 +28,7 @@ const stateTone: Record = { const stateRank: Record = { open: 0, draft: 1, merged: 2, closed: 3 }; type PRRow = { - pr: PullRequestFacts; + pr: SessionPRSummary; session: WorkspaceSession; }; @@ -34,8 +41,11 @@ export function PullRequestsPage() { const navigate = useNavigate(); const workspaceQuery = useWorkspaceQuery(); const sessions = (workspaceQuery.data ?? []).flatMap((w) => w.sessions); + const prQueries = useQueries({ + queries: sessions.map((session) => sessionScmSummaryQueryOptions(session.id)), + }); const rows: PRRow[] = sessions - .flatMap((s) => s.prs.map((pr) => ({ pr, session: s }))) + .flatMap((session, index) => (prQueries[index]?.data ?? []).map((pr) => ({ pr, session }))) .sort((a, b) => stateRank[a.pr.state] - stateRank[b.pr.state] || a.pr.number - b.pr.number); return ( @@ -83,7 +93,10 @@ export function PullRequestsPage() { function PRRowView({ row, onOpen }: { row: PRRow; onOpen: () => void }) { const queryClient = useQueryClient(); const [note, setNote] = useState<{ ok: boolean; text: string } | null>(null); - const refresh = () => void queryClient.invalidateQueries({ queryKey: workspaceQueryKey }); + const refresh = () => { + void queryClient.invalidateQueries({ queryKey: workspaceQueryKey }); + void queryClient.invalidateQueries({ queryKey: sessionScmSummaryQueryKey() }); + }; const merge = useMutation({ mutationFn: async () => { @@ -120,10 +133,21 @@ function PRRowView({ row, onOpen }: { row: PRRow; onOpen: () => void }) { #{row.pr.number} -
{row.session.title}
+
{row.pr.title || row.session.title}
- {[row.session.workspaceName, row.session.branch].filter(Boolean).join(" · ")} + {[row.session.workspaceName, row.pr.sourceBranch || row.session.branch, `CI ${row.pr.ci.state}`] + .filter(Boolean) + .join(" · ")}
+ {row.pr.ci.failingChecks.length > 0 ? ( +
+ {row.pr.ci.failingChecks.map((check) => check.name).join(", ")} +
+ ) : row.pr.review.unresolvedBy.length > 0 ? ( +
+ {row.pr.review.unresolvedBy.map((reviewer) => reviewer.reviewerId).join(", ")} +
+ ) : null}
diff --git a/frontend/src/renderer/components/SessionInspector.tsx b/frontend/src/renderer/components/SessionInspector.tsx index 87edce1d..2e13f206 100644 --- a/frontend/src/renderer/components/SessionInspector.tsx +++ b/frontend/src/renderer/components/SessionInspector.tsx @@ -5,7 +5,8 @@ import type { components } from "../../api/schema"; import { apiClient, apiErrorMessage } from "../lib/api-client"; import { workspaceQueryKey } from "../hooks/useWorkspaceQuery"; import { formatTimeCompact } from "../lib/format-time"; -import type { PRState, PullRequestFacts, SessionStatus, WorkspaceSession } from "../types/workspace"; +import { useSessionScmSummary, type SessionPRSummary } from "../hooks/useSessionScmSummary"; +import type { SessionStatus, WorkspaceSession } from "../types/workspace"; import { sortedPRs, workerDisplayStatus } from "../types/workspace"; import { Badge } from "./ui/badge"; import { cn } from "../lib/utils"; @@ -54,7 +55,7 @@ const VIEWS: { id: InspectorView; label: string; icon: ReactNode }[] = [ }, ]; -const prStateTone: Record = { +const prStateTone: Record = { open: "border-success/40 bg-success/10 text-success", draft: "border-border bg-raised text-muted-foreground", merged: "border-accent/40 bg-accent-weak text-accent", @@ -123,23 +124,156 @@ function Section({ title, action, children }: { title: string; action?: ReactNod } function SummaryView({ session }: { session: WorkspaceSession }) { - const prs = sortedPRs(session); + const query = useSessionScmSummary(session.id); + const prFacts = query.data?.[0]; const branchLabel = session.branch || `session/${session.id}`; return (
-
1 ? `Pull requests (${prs.length})` : "Pull request"}> - {prs.length === 0 ? ( +
+ Open + + ) : undefined + } + > + {query.isLoading ? ( +

Loading pull request...

+ ) : query.isError ? ( +

Could not load pull request summary.

+ ) : !prFacts ? (

No pull request opened yet.

) : ( -
- {prs.map((pr) => ( - - ))} +
+
+
+
{prFacts.title || "Untitled PR"}
+
+ + ${prFacts.targetBranch || "-"}`} mono /> + +
)}
+ {prFacts ? ( + <> +
+
+ + +
+ {prFacts.ci.failingChecks.length > 0 ? ( +
+ {prFacts.ci.failingChecks.map((check) => + check.url ? ( + + {check.name} - {check.status} + + ) : ( + + {check.name} - {check.status} + + ), + )} +
+ ) : null} +
+ +
+
+ + +
+ {prFacts.review.unresolvedBy.length > 0 ? ( +
+ {prFacts.review.unresolvedBy.map((reviewer) => ( +
+ {reviewer.reviewerId} + - {reviewer.count} +
+ {reviewer.links.map((link, index) => + link.url ? ( + + {link.file || "comment"} + {link.line ? `:${link.line}` : ""} + + ) : ( + + {link.file || "comment"} + {link.line ? `:${link.line}` : ""} + + ), + )} +
+
+ ))} +
+ ) : null} +
+ +
+ GitHub + + ) : undefined + } + > +
+ + +
+ {prFacts.mergeability.conflictFiles?.length ? ( +
+ {prFacts.mergeability.conflictFiles.map((file) => + file.url ? ( + + {file.path} + + ) : ( + + {file.path} + + ), + )} +
+ ) : null} +
+ + ) : null} +
@@ -156,32 +290,6 @@ function SummaryView({ session }: { session: WorkspaceSession }) { ); } -// One PR per card; a session's PRs stack vertically. Mirrors the minimal -// single-PR rail the parallel-agent tools converged on (emdash, conductor), -// repeated per PR rather than collapsed into one aggregate widget. -function PRCard({ pr }: { pr: PullRequestFacts }) { - return ( -
-
-
-
- - - -
-
- ); -} type TimelineTone = "now" | "good" | "warn" | "neutral"; diff --git a/frontend/src/renderer/components/SessionsBoard.tsx b/frontend/src/renderer/components/SessionsBoard.tsx index 673380d4..651f9a3c 100644 --- a/frontend/src/renderer/components/SessionsBoard.tsx +++ b/frontend/src/renderer/components/SessionsBoard.tsx @@ -2,7 +2,8 @@ import { useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; import { Plus } from "lucide-react"; -import { type AttentionZone, type WorkspaceSession, attentionZone, openPRs, workerSessions } from "../types/workspace"; +import { type AttentionZone, type WorkspaceSession, attentionZone, workerSessions } from "../types/workspace"; +import { useSessionScmSummary } from "../hooks/useSessionScmSummary"; import { useWorkspaceQuery, workspaceQueryKey } from "../hooks/useWorkspaceQuery"; import { DashboardSubhead } from "./DashboardSubhead"; import { OrchestratorIcon } from "./icons"; @@ -255,23 +256,14 @@ function ZoneColumn({ ); } -// One-line PR summary for the card footer. A session can own several PRs, so -// collapse to a count once past one; detail lives in the inspector stack. -function prSummary(session: WorkspaceSession): string { - const total = session.prs.length; - if (total === 0) return "no PR yet"; - if (total === 1) { - const pr = session.prs[0]; - return `PR #${pr.number} · ${pr.state}`; - } - const open = openPRs(session).length; - return open > 0 ? `${total} PRs · ${open} open` : `${total} PRs`; -} function SessionCard({ session, onOpen }: { session: WorkspaceSession; onOpen: () => void }) { const badge = sessionBadge(session); const branch = session.branch || ""; const showBranch = branch !== "" && !sameLabel(branch, session.title) && !sameLabel(branch, session.id); + const prSummary = useSessionScmSummary(session.id).data?.[0]; + const failingChecks = prSummary?.ci.failingChecks.slice(0, 2) ?? []; + const reviewers = prSummary?.review.unresolvedBy.slice(0, 2).map((reviewer) => reviewer.reviewerId) ?? []; return ( ); diff --git a/frontend/src/renderer/components/ShellTopbar.tsx b/frontend/src/renderer/components/ShellTopbar.tsx index ff8285de..14831886 100644 --- a/frontend/src/renderer/components/ShellTopbar.tsx +++ b/frontend/src/renderer/components/ShellTopbar.tsx @@ -32,6 +32,7 @@ const STATUS_PILL: Record diff --git a/frontend/src/renderer/hooks/useSessionScmSummary.ts b/frontend/src/renderer/hooks/useSessionScmSummary.ts new file mode 100644 index 00000000..5b26eb8f --- /dev/null +++ b/frontend/src/renderer/hooks/useSessionScmSummary.ts @@ -0,0 +1,36 @@ +import { useQuery } from "@tanstack/react-query"; +import type { components } from "../../api/schema"; +import { apiClient } from "../lib/api-client"; + +export type SessionPRSummary = components["schemas"]["SessionPRSummary"]; + +export const sessionScmSummaryQueryKey = (sessionId?: string) => + sessionId ? (["session-scm-summary", sessionId] as const) : (["session-scm-summary"] as const); + +const usePreviewData = import.meta.env.VITE_NO_ELECTRON === "1"; + +export async function fetchSessionScmSummary(sessionId: string): Promise { + const { data, error } = await apiClient.GET("/api/v1/sessions/{sessionId}/pr", { + params: { path: { sessionId } }, + }); + if (error) throw error; + return data?.prs ?? []; +} + +export function sessionScmSummaryQueryOptions(sessionId: string) { + return { + queryKey: sessionScmSummaryQueryKey(sessionId), + enabled: Boolean(sessionId) && !usePreviewData, + queryFn: () => fetchSessionScmSummary(sessionId), + retry: 1, + }; +} + +export function useSessionScmSummary(sessionId?: string) { + return useQuery({ + queryKey: sessionScmSummaryQueryKey(sessionId), + enabled: Boolean(sessionId) && !usePreviewData, + queryFn: () => fetchSessionScmSummary(sessionId!), + retry: 1, + }); +} diff --git a/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx b/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx index 309e60ec..cdbf4eb1 100644 --- a/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx +++ b/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx @@ -54,7 +54,7 @@ describe("useWorkspaceQuery", () => { }, { // Unknown harness/status and no displayName/issueId: falls back - // to codex / working / the session id. + // to codex / unknown / the session id. id: "sess-2", projectId: "proj-1", harness: "mystery-agent", @@ -87,7 +87,7 @@ describe("useWorkspaceQuery", () => { id: "sess-2", title: "sess-2", provider: "codex", - status: "working", + status: "unknown", }); }); @@ -149,7 +149,7 @@ describe("useWorkspaceQuery", () => { expect(sessions[1].prs).toEqual([]); }); - it("marks terminated sessions regardless of their reported status", async () => { + it("preserves backend merged status for terminated merged sessions", async () => { respondWith({ projects: { data: { projects: [{ id: "proj-1", name: "my-app", path: "/p" }] }, error: undefined }, sessions: { @@ -158,7 +158,32 @@ describe("useWorkspaceQuery", () => { { id: "sess-1", projectId: "proj-1", - status: "working", + status: "merged", + isTerminated: true, + updatedAt: "2026-06-10T16:15:04Z", + }, + ], + }, + error: undefined, + }, + }); + + const { result } = renderHook(() => useWorkspaceQuery(), { wrapper }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data?.[0].sessions[0].status).toBe("merged"); + }); + + it("falls back to terminated for terminated sessions without a known backend status", async () => { + respondWith({ + projects: { data: { projects: [{ id: "proj-1", name: "my-app", path: "/p" }] }, error: undefined }, + sessions: { + data: { + sessions: [ + { + id: "sess-1", + projectId: "proj-1", + status: "bogus", isTerminated: true, updatedAt: "2026-06-10T16:15:04Z", }, diff --git a/frontend/src/renderer/lib/event-transport.test.ts b/frontend/src/renderer/lib/event-transport.test.ts index f69f4186..3673e1e9 100644 --- a/frontend/src/renderer/lib/event-transport.test.ts +++ b/frontend/src/renderer/lib/event-transport.test.ts @@ -97,7 +97,7 @@ describe("createEventTransport", () => { expect(EventSourceStub.instances[1].url).toBe("http://127.0.0.1:3099/api/v1/events"); }); - it("debounces a workspace invalidation after a status change", () => { + it("debounces workspace and SCM summary invalidation after a status change", () => { vi.useFakeTimers(); try { const queryClient = fakeQueryClient(); @@ -107,7 +107,8 @@ describe("createEventTransport", () => { onStatusHandler(); expect(queryClient.invalidateQueries).not.toHaveBeenCalled(); vi.advanceTimersByTime(200); - expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(1); + expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ queryKey: ["workspaces"] }); + expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ queryKey: ["session-scm-summary"] }); } finally { vi.useRealTimers(); } diff --git a/frontend/src/renderer/lib/event-transport.ts b/frontend/src/renderer/lib/event-transport.ts index 6607b66f..5c31fd10 100644 --- a/frontend/src/renderer/lib/event-transport.ts +++ b/frontend/src/renderer/lib/event-transport.ts @@ -3,6 +3,7 @@ import { aoBridge } from "./bridge"; import { getApiBaseUrl, subscribeApiBaseUrl } from "./api-client"; import { setEventsConnectionState } from "./events-connection"; import { workspaceQueryKey } from "../hooks/useWorkspaceQuery"; +import { sessionScmSummaryQueryKey } from "../hooks/useSessionScmSummary"; export type EventTransport = { connect: () => () => void; @@ -50,6 +51,7 @@ export function createEventTransport(queryClient: QueryClient): EventTransport { if (debounce) clearTimeout(debounce); debounce = setTimeout(() => { void queryClient.invalidateQueries({ queryKey: workspaceQueryKey }); + void queryClient.invalidateQueries({ queryKey: sessionScmSummaryQueryKey() }); }, INVALIDATE_DEBOUNCE_MS); }; diff --git a/frontend/src/renderer/types/workspace.test.ts b/frontend/src/renderer/types/workspace.test.ts index 729c88c9..9da69aa9 100644 --- a/frontend/src/renderer/types/workspace.test.ts +++ b/frontend/src/renderer/types/workspace.test.ts @@ -48,18 +48,20 @@ const pr = (overrides: Partial & { number: number; state: PRSt describe("toSessionStatus", () => { it("passes through a known status", () => { expect(toSessionStatus("mergeable")).toBe("mergeable"); + expect(toSessionStatus("no_signal")).toBe("no_signal"); }); - it("overrides to terminated when the session is terminated", () => { - expect(toSessionStatus("working", true)).toBe("terminated"); + it("keeps a backend merged status even when the session is terminated", () => { + expect(toSessionStatus("merged", true)).toBe("merged"); }); - it("falls back to working for an unknown status", () => { - expect(toSessionStatus("bogus")).toBe("working"); + it("uses terminated only as a fallback when a terminated session has no known status", () => { + expect(toSessionStatus(undefined, true)).toBe("terminated"); }); - it("falls back to working when status is undefined", () => { - expect(toSessionStatus(undefined)).toBe("working"); + it("falls back to unknown for an unknown live status", () => { + expect(toSessionStatus("bogus")).toBe("unknown"); + expect(toSessionStatus(undefined)).toBe("unknown"); }); }); @@ -70,6 +72,7 @@ describe("workerDisplayStatus", () => { it.each([ ["needs_input", "needs_you"], + ["no_signal", "needs_you"], ["changes_requested", "needs_you"], ["review_pending", "needs_you"], ["ci_failed", "ci_failed"], @@ -77,6 +80,7 @@ describe("workerDisplayStatus", () => { ["mergeable", "mergeable"], ["merged", "done"], ["terminated", "done"], + ["unknown", "unknown"], ["working", "working"], ["idle", "working"], ] as const)("maps %s to %s", (status, expected) => { @@ -128,7 +132,7 @@ describe("findProjectOrchestrator", () => { }); describe("sessionNeedsAttention", () => { - it.each(["needs_input", "changes_requested", "review_pending", "ci_failed"] as const)("is true for %s", (status) => { + it.each(["needs_input", "no_signal", "changes_requested", "review_pending", "ci_failed"] as const)("is true for %s", (status) => { expect(sessionNeedsAttention(sessionWith({ status }))).toBe(true); }); @@ -144,6 +148,7 @@ describe("workerStatusPulses", () => { expect(workerStatusPulses("needs_you")).toBe(true); expect(workerStatusPulses("mergeable")).toBe(false); expect(workerStatusPulses("done")).toBe(false); + expect(workerStatusPulses("unknown")).toBe(false); }); }); @@ -198,11 +203,13 @@ describe("attentionZone", () => { ["mergeable", "merge"], ["approved", "merge"], ["needs_input", "action"], + ["no_signal", "action"], ["ci_failed", "action"], ["changes_requested", "action"], ["review_pending", "pending"], ["pr_open", "pending"], ["draft", "pending"], + ["unknown", "pending"], ["working", "working"], ["idle", "working"], ["merged", "done"], diff --git a/frontend/src/renderer/types/workspace.ts b/frontend/src/renderer/types/workspace.ts index a5f1b466..02e0a0fe 100644 --- a/frontend/src/renderer/types/workspace.ts +++ b/frontend/src/renderer/types/workspace.ts @@ -10,7 +10,9 @@ export type SessionStatus = | "merged" | "needs_input" | "idle" - | "terminated"; + | "terminated" + | "no_signal" + | "unknown"; const sessionStatuses = new Set([ "working", @@ -25,11 +27,12 @@ const sessionStatuses = new Set([ "needs_input", "idle", "terminated", + "no_signal", ]); export function toSessionStatus(status?: string, isTerminated = false): SessionStatus { - if (isTerminated) return "terminated"; - return status && sessionStatuses.has(status as SessionStatus) ? (status as SessionStatus) : "working"; + if (status && sessionStatuses.has(status as SessionStatus)) return status as SessionStatus; + return isTerminated ? "terminated" : "unknown"; } export type AgentProvider = @@ -119,12 +122,13 @@ export type WorkspaceSession = { }; /** Glanceable worker status. Maps 1:1 to the accent colors in DESIGN.md. */ -export type WorkerDisplayStatus = "working" | "needs_you" | "mergeable" | "ci_failed" | "done"; +export type WorkerDisplayStatus = "working" | "needs_you" | "mergeable" | "ci_failed" | "done" | "unknown"; export function workerDisplayStatus(session: WorkspaceSession): WorkerDisplayStatus { if (session.displayStatus) return session.displayStatus; switch (session.status) { case "needs_input": + case "no_signal": case "changes_requested": case "review_pending": return "needs_you"; @@ -136,6 +140,8 @@ export function workerDisplayStatus(session: WorkspaceSession): WorkerDisplaySta case "merged": case "terminated": return "done"; + case "unknown": + return "unknown"; default: return "working"; } @@ -194,6 +200,7 @@ export function sessionIsActive(session: WorkspaceSession): boolean { export function sessionNeedsAttention(session: WorkspaceSession): boolean { return ( session.status === "needs_input" || + session.status === "no_signal" || session.status === "changes_requested" || session.status === "review_pending" || session.status === "ci_failed" @@ -206,6 +213,7 @@ export const workerStatusLabel: Record = { mergeable: "mergeable", ci_failed: "ci failed", done: "done", + unknown: "unknown", }; /** Whether a status should breathe (alive/working). */ @@ -246,6 +254,7 @@ export function attentionZone(session: WorkspaceSession): AttentionZone { // Agent waiting on a human (respond) or a problem to investigate (review); // agent-orchestrator collapses these into one "action" zone by default. case "needs_input": + case "no_signal": case "ci_failed": case "changes_requested": return "action"; @@ -253,6 +262,7 @@ export function attentionZone(session: WorkspaceSession): AttentionZone { case "review_pending": case "pr_open": case "draft": + case "unknown": return "pending"; // Agents doing their thing — don't interrupt. case "working": From 1d99eec4585c0661b1e362219e7dd64b830a7e85 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 16 Jun 2026 19:19:14 +0000 Subject: [PATCH 2/5] chore: format with prettier [skip ci] --- .../renderer/components/SessionInspector.tsx | 25 ++++++++++++++++--- .../src/renderer/components/SessionsBoard.tsx | 4 ++- frontend/src/renderer/types/workspace.test.ts | 9 ++++--- 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/frontend/src/renderer/components/SessionInspector.tsx b/frontend/src/renderer/components/SessionInspector.tsx index 2e13f206..47064a1c 100644 --- a/frontend/src/renderer/components/SessionInspector.tsx +++ b/frontend/src/renderer/components/SessionInspector.tsx @@ -134,7 +134,12 @@ function SummaryView({ session }: { session: WorkspaceSession }) { title="Pull request" action={ prFacts?.htmlUrl || prFacts?.url ? ( - + Open ) : undefined @@ -223,7 +228,10 @@ function SummaryView({ session }: { session: WorkspaceSession }) { {link.line ? `:${link.line}` : ""} ) : ( - + {link.file || "comment"} {link.line ? `:${link.line}` : ""} @@ -253,13 +261,21 @@ function SummaryView({ session }: { session: WorkspaceSession }) { >
- +
{prFacts.mergeability.conflictFiles?.length ? (
{prFacts.mergeability.conflictFiles.map((file) => file.url ? ( - + {file.path} ) : ( @@ -371,6 +387,7 @@ const STATUS_PILL: Record< ci_failed: { label: "CI failed", tone: "var(--red)", breathe: false }, mergeable: { label: "Ready", tone: "var(--green)", breathe: false }, done: { label: "Done", tone: "var(--fg-muted)", breathe: false }, + unknown: { label: "Unknown", tone: "var(--fg-muted)", breathe: false }, idle: { label: "Idle", tone: "var(--fg-muted)", breathe: false }, }; diff --git a/frontend/src/renderer/components/SessionsBoard.tsx b/frontend/src/renderer/components/SessionsBoard.tsx index 651f9a3c..0e5130fb 100644 --- a/frontend/src/renderer/components/SessionsBoard.tsx +++ b/frontend/src/renderer/components/SessionsBoard.tsx @@ -298,7 +298,9 @@ function SessionCard({ session, onOpen }: { session: WorkspaceSession; onOpen: ( ) : null} {reviewers.length > 0 ? review: {reviewers.join(", ")} : null} {prSummary.mergeability.state === "conflicting" || prSummary.mergeability.state === "blocked" ? ( - merge: {prSummary.mergeability.reasons.join(", ") || prSummary.mergeability.state} + + merge: {prSummary.mergeability.reasons.join(", ") || prSummary.mergeability.state} + ) : null}
) : ( diff --git a/frontend/src/renderer/types/workspace.test.ts b/frontend/src/renderer/types/workspace.test.ts index 9da69aa9..313b0f60 100644 --- a/frontend/src/renderer/types/workspace.test.ts +++ b/frontend/src/renderer/types/workspace.test.ts @@ -132,9 +132,12 @@ describe("findProjectOrchestrator", () => { }); describe("sessionNeedsAttention", () => { - it.each(["needs_input", "no_signal", "changes_requested", "review_pending", "ci_failed"] as const)("is true for %s", (status) => { - expect(sessionNeedsAttention(sessionWith({ status }))).toBe(true); - }); + it.each(["needs_input", "no_signal", "changes_requested", "review_pending", "ci_failed"] as const)( + "is true for %s", + (status) => { + expect(sessionNeedsAttention(sessionWith({ status }))).toBe(true); + }, + ); it("is false for statuses that don't need the user", () => { expect(sessionNeedsAttention(sessionWith({ status: "working" }))).toBe(false); From 80c5d684f6d9f00aef233e2bc64567f0d9272867 Mon Sep 17 00:00:00 2001 From: whoisasx Date: Sun, 21 Jun 2026 16:32:10 +0530 Subject: [PATCH 3/5] fix: hydrate PR views from session facts --- .../renderer/components/PullRequestsPage.tsx | 10 +- .../components/SessionInspector.test.tsx | 10 +- .../renderer/components/SessionInspector.tsx | 117 ++++++++++-------- .../src/renderer/components/SessionsBoard.tsx | 8 +- frontend/src/renderer/lib/pr-display.ts | 94 ++++++++++++++ 5 files changed, 175 insertions(+), 64 deletions(-) create mode 100644 frontend/src/renderer/lib/pr-display.ts diff --git a/frontend/src/renderer/components/PullRequestsPage.tsx b/frontend/src/renderer/components/PullRequestsPage.tsx index b71b3086..a7e4efc6 100644 --- a/frontend/src/renderer/components/PullRequestsPage.tsx +++ b/frontend/src/renderer/components/PullRequestsPage.tsx @@ -8,6 +8,7 @@ import { sessionScmSummaryQueryOptions, type SessionPRSummary, } from "../hooks/useSessionScmSummary"; +import { comparePRDisplaySummaries, sessionPRDisplaySummaries } from "../lib/pr-display"; import type { WorkspaceSession } from "../types/workspace"; import { DashboardSubhead } from "./DashboardSubhead"; import { Badge } from "./ui/badge"; @@ -24,9 +25,6 @@ const stateTone: Record = { closed: "border-error/40 bg-error/10 text-error", }; -// Order open PRs (actionable) above merged/closed. -const stateRank: Record = { open: 0, draft: 1, merged: 2, closed: 3 }; - type PRRow = { pr: SessionPRSummary; session: WorkspaceSession; @@ -45,8 +43,10 @@ export function PullRequestsPage() { queries: sessions.map((session) => sessionScmSummaryQueryOptions(session.id)), }); const rows: PRRow[] = sessions - .flatMap((session, index) => (prQueries[index]?.data ?? []).map((pr) => ({ pr, session }))) - .sort((a, b) => stateRank[a.pr.state] - stateRank[b.pr.state] || a.pr.number - b.pr.number); + .flatMap((session, index) => + sessionPRDisplaySummaries(session, prQueries[index]?.data).map((pr) => ({ pr, session })), + ) + .sort((a, b) => comparePRDisplaySummaries(a.pr, b.pr)); return (
diff --git a/frontend/src/renderer/components/SessionInspector.test.tsx b/frontend/src/renderer/components/SessionInspector.test.tsx index bb517969..03ecf785 100644 --- a/frontend/src/renderer/components/SessionInspector.test.tsx +++ b/frontend/src/renderer/components/SessionInspector.test.tsx @@ -108,7 +108,7 @@ describe("SessionInspector PR section", () => { within(screen.getByText(title).closest("section.inspector-section") as HTMLElement); it("renders one card per PR, ordered actionable-first, when a session owns a stack", () => { - render(); + renderWithQuery(); expect(screen.getByText("Pull requests (3)")).toBeInTheDocument(); const cards = prSection("Pull requests (3)") @@ -119,7 +119,7 @@ describe("SessionInspector PR section", () => { }); it("uses the singular heading and shows enriched facts for a single PR", () => { - render(); + renderWithQuery(); expect(screen.getByText("Pull request")).toBeInTheDocument(); expect(screen.queryByText(/Pull requests \(/)).not.toBeInTheDocument(); @@ -129,12 +129,12 @@ describe("SessionInspector PR section", () => { }); it("shows the empty state when there are no PRs", () => { - render(); + renderWithQuery(); expect(screen.getByText("No pull request opened yet.")).toBeInTheDocument(); }); it("links each PR to its url", () => { - render(); + renderWithQuery(); const links = screen.getAllByRole("link", { name: /Open/ }); expect(links.map((a) => a.getAttribute("href"))).toEqual([ "https://example.com/pr/41", @@ -145,7 +145,7 @@ describe("SessionInspector PR section", () => { describe("SessionInspector tabs", () => { it("exposes Summary, Reviews, and Browser as the three inspector tabs", () => { - render(); + renderWithQuery(); const tabs = screen.getAllByRole("tab").map((el) => el.textContent?.trim()); expect(tabs).toEqual(["Summary", "Reviews", "Browser"]); }); diff --git a/frontend/src/renderer/components/SessionInspector.tsx b/frontend/src/renderer/components/SessionInspector.tsx index 47064a1c..2db9fae2 100644 --- a/frontend/src/renderer/components/SessionInspector.tsx +++ b/frontend/src/renderer/components/SessionInspector.tsx @@ -6,6 +6,7 @@ import { apiClient, apiErrorMessage } from "../lib/api-client"; import { workspaceQueryKey } from "../hooks/useWorkspaceQuery"; import { formatTimeCompact } from "../lib/format-time"; import { useSessionScmSummary, type SessionPRSummary } from "../hooks/useSessionScmSummary"; +import { sessionPRDisplaySummaries } from "../lib/pr-display"; import type { SessionStatus, WorkspaceSession } from "../types/workspace"; import { sortedPRs, workerDisplayStatus } from "../types/workspace"; import { Badge } from "./ui/badge"; @@ -125,64 +126,40 @@ function Section({ title, action, children }: { title: string; action?: ReactNod function SummaryView({ session }: { session: WorkspaceSession }) { const query = useSessionScmSummary(session.id); - const prFacts = query.data?.[0]; + const prSummaries = sessionPRDisplaySummaries(session, query.data); + const primaryPRSummary = prSummaries[0]; + const prSectionTitle = + prSummaries.length > 1 ? `Pull requests (${prSummaries.length})` : "Pull request"; const branchLabel = session.branch || `session/${session.id}`; return (
-
- Open - - ) : undefined - } - > - {query.isLoading ? ( -

Loading pull request...

- ) : query.isError ? ( -

Could not load pull request summary.

- ) : !prFacts ? ( +
+ {prSummaries.length === 0 ? (

No pull request opened yet.

) : (
-
-
-
{prFacts.title || "Untitled PR"}
-
- - ${prFacts.targetBranch || "-"}`} mono /> - -
+ {prSummaries.map((pr) => ( + + ))}
)}
- {prFacts ? ( + {primaryPRSummary ? ( <>
- - + +
- {prFacts.ci.failingChecks.length > 0 ? ( + {primaryPRSummary.ci.failingChecks.length > 0 ? (
- {prFacts.ci.failingChecks.map((check) => + {primaryPRSummary.ci.failingChecks.map((check) => check.url ? (
- - + +
- {prFacts.review.unresolvedBy.length > 0 ? ( + {primaryPRSummary.review.unresolvedBy.length > 0 ? (
- {prFacts.review.unresolvedBy.map((reviewer) => ( + {primaryPRSummary.review.unresolvedBy.map((reviewer) => (
{reviewer.reviewerId} - {reviewer.count} @@ -247,9 +228,9 @@ function SummaryView({ session }: { session: WorkspaceSession }) {
- +
- {prFacts.mergeability.conflictFiles?.length ? ( + {primaryPRSummary.mergeability.conflictFiles?.length ? (
+ ); +} + type TimelineTone = "now" | "good" | "warn" | "neutral"; diff --git a/frontend/src/renderer/components/SessionsBoard.tsx b/frontend/src/renderer/components/SessionsBoard.tsx index 0e5130fb..296eb807 100644 --- a/frontend/src/renderer/components/SessionsBoard.tsx +++ b/frontend/src/renderer/components/SessionsBoard.tsx @@ -9,6 +9,7 @@ import { DashboardSubhead } from "./DashboardSubhead"; import { OrchestratorIcon } from "./icons"; import { NewTaskDialog } from "./NewTaskDialog"; import { spawnOrchestrator } from "../lib/spawn-orchestrator"; +import { sessionPRDisplaySummaries } from "../lib/pr-display"; import { cn } from "../lib/utils"; type SessionsBoardProps = { @@ -261,7 +262,7 @@ function SessionCard({ session, onOpen }: { session: WorkspaceSession; onOpen: ( const badge = sessionBadge(session); const branch = session.branch || ""; const showBranch = branch !== "" && !sameLabel(branch, session.title) && !sameLabel(branch, session.id); - const prSummary = useSessionScmSummary(session.id).data?.[0]; + const prSummary = sessionPRDisplaySummaries(session, useSessionScmSummary(session.id).data)[0]; const failingChecks = prSummary?.ci.failingChecks.slice(0, 2) ?? []; const reviewers = prSummary?.review.unresolvedBy.slice(0, 2).map((reviewer) => reviewer.reviewerId) ?? []; return ( @@ -292,7 +293,10 @@ function SessionCard({ session, onOpen }: { session: WorkspaceSession; onOpen: (
{prSummary ? (
- PR #{prSummary.number} - {prSummary.state} - CI {prSummary.ci.state} + + PR #{prSummary.number} · {prSummary.state} + + CI {prSummary.ci.state} {failingChecks.length > 0 ? ( {failingChecks.map((check) => check.name).join(", ")} ) : null} diff --git a/frontend/src/renderer/lib/pr-display.ts b/frontend/src/renderer/lib/pr-display.ts new file mode 100644 index 00000000..f1e8d176 --- /dev/null +++ b/frontend/src/renderer/lib/pr-display.ts @@ -0,0 +1,94 @@ +import type { SessionPRSummary } from "../hooks/useSessionScmSummary"; +import { + sortedPRs, + type PRState, + type PullRequestFacts, + type WorkspaceSession, +} from "../types/workspace"; + +const prStateRank: Record = { open: 0, draft: 1, merged: 2, closed: 3 }; +const ciStates = new Set(["unknown", "pending", "passing", "failing"]); +const reviewDecisions = new Set([ + "none", + "approved", + "changes_requested", + "review_required", +]); +const mergeabilityStates = new Set([ + "unknown", + "mergeable", + "conflicting", + "blocked", + "unstable", +]); + +export function comparePRDisplaySummaries(a: SessionPRSummary, b: SessionPRSummary): number { + return prStateRank[a.state] - prStateRank[b.state] || a.number - b.number; +} + +export function sessionPRDisplaySummaries( + session: WorkspaceSession, + summaries: SessionPRSummary[] = [], +): SessionPRSummary[] { + const summariesByNumber = new Map(summaries.map((summary) => [summary.number, summary])); + const seen = new Set(); + const fromFacts = sortedPRs(session).map((pr) => { + seen.add(pr.number); + return summariesByNumber.get(pr.number) ?? sessionPRFactToSummary(session, pr); + }); + const summaryOnly = summaries.filter((summary) => !seen.has(summary.number)); + return [...fromFacts, ...summaryOnly].sort(comparePRDisplaySummaries); +} + +function sessionPRFactToSummary(session: WorkspaceSession, pr: PullRequestFacts): SessionPRSummary { + return { + url: pr.url, + htmlUrl: pr.url, + number: pr.number, + title: session.title, + state: pr.state, + provider: "github", + repo: session.workspaceName, + author: "", + sourceBranch: session.branch, + targetBranch: "", + headSha: "", + ci: { + state: toCIState(pr.ci), + failingChecks: [], + }, + review: { + decision: toReviewDecision(pr.review), + hasUnresolvedHumanComments: pr.reviewComments, + unresolvedBy: [], + }, + mergeability: { + state: toMergeabilityState(pr.mergeability), + reasons: [], + prUrl: pr.url, + conflictFiles: [], + }, + updatedAt: pr.updatedAt, + observedAt: pr.updatedAt, + ciObservedAt: pr.updatedAt, + reviewObservedAt: pr.updatedAt, + }; +} + +function toCIState(value: string): SessionPRSummary["ci"]["state"] { + return ciStates.has(value as SessionPRSummary["ci"]["state"]) + ? (value as SessionPRSummary["ci"]["state"]) + : "unknown"; +} + +function toReviewDecision(value: string): SessionPRSummary["review"]["decision"] { + return reviewDecisions.has(value as SessionPRSummary["review"]["decision"]) + ? (value as SessionPRSummary["review"]["decision"]) + : "none"; +} + +function toMergeabilityState(value: string): SessionPRSummary["mergeability"]["state"] { + return mergeabilityStates.has(value as SessionPRSummary["mergeability"]["state"]) + ? (value as SessionPRSummary["mergeability"]["state"]) + : "unknown"; +} From 9ba4c04d5596026fc8ba5f385ff64eea0204486a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 21 Jun 2026 11:02:33 +0000 Subject: [PATCH 4/5] chore: format with prettier [skip ci] --- frontend/src/renderer/components/SessionInspector.tsx | 8 ++------ frontend/src/renderer/components/SessionsBoard.tsx | 1 - frontend/src/renderer/lib/pr-display.ts | 7 +------ 3 files changed, 3 insertions(+), 13 deletions(-) diff --git a/frontend/src/renderer/components/SessionInspector.tsx b/frontend/src/renderer/components/SessionInspector.tsx index 2db9fae2..89be950d 100644 --- a/frontend/src/renderer/components/SessionInspector.tsx +++ b/frontend/src/renderer/components/SessionInspector.tsx @@ -128,8 +128,7 @@ function SummaryView({ session }: { session: WorkspaceSession }) { const query = useSessionScmSummary(session.id); const prSummaries = sessionPRDisplaySummaries(session, query.data); const primaryPRSummary = prSummaries[0]; - const prSectionTitle = - prSummaries.length > 1 ? `Pull requests (${prSummaries.length})` : "Pull request"; + const prSectionTitle = prSummaries.length > 1 ? `Pull requests (${prSummaries.length})` : "Pull request"; const branchLabel = session.branch || `session/${session.id}`; return ( @@ -245,9 +244,7 @@ function SummaryView({ session }: { session: WorkspaceSession }) { @@ -319,7 +316,6 @@ function PRSummaryCard({ pr }: { pr: SessionPRSummary }) { ); } - type TimelineTone = "now" | "good" | "warn" | "neutral"; function ActivityTimeline({ session }: { session: WorkspaceSession }) { diff --git a/frontend/src/renderer/components/SessionsBoard.tsx b/frontend/src/renderer/components/SessionsBoard.tsx index 296eb807..186ce0b9 100644 --- a/frontend/src/renderer/components/SessionsBoard.tsx +++ b/frontend/src/renderer/components/SessionsBoard.tsx @@ -257,7 +257,6 @@ function ZoneColumn({ ); } - function SessionCard({ session, onOpen }: { session: WorkspaceSession; onOpen: () => void }) { const badge = sessionBadge(session); const branch = session.branch || ""; diff --git a/frontend/src/renderer/lib/pr-display.ts b/frontend/src/renderer/lib/pr-display.ts index f1e8d176..320896db 100644 --- a/frontend/src/renderer/lib/pr-display.ts +++ b/frontend/src/renderer/lib/pr-display.ts @@ -1,10 +1,5 @@ import type { SessionPRSummary } from "../hooks/useSessionScmSummary"; -import { - sortedPRs, - type PRState, - type PullRequestFacts, - type WorkspaceSession, -} from "../types/workspace"; +import { sortedPRs, type PRState, type PullRequestFacts, type WorkspaceSession } from "../types/workspace"; const prStateRank: Record = { open: 0, draft: 1, merged: 2, closed: 3 }; const ciStates = new Set(["unknown", "pending", "passing", "failing"]); From bbf7d80fd94d2a6eb85cf96c9913a8cf21d23db3 Mon Sep 17 00:00:00 2001 From: whoisasx Date: Sun, 21 Jun 2026 17:47:53 +0530 Subject: [PATCH 5/5] fix: document PR summary DTOs --- backend/internal/httpd/controllers/dto.go | 8 ++++++++ backend/internal/service/session/pr_summary.go | 7 +++++++ 2 files changed, 15 insertions(+) diff --git a/backend/internal/httpd/controllers/dto.go b/backend/internal/httpd/controllers/dto.go index 722f5fff..00aaa9af 100644 --- a/backend/internal/httpd/controllers/dto.go +++ b/backend/internal/httpd/controllers/dto.go @@ -242,11 +242,13 @@ type SessionPRSummary struct { ReviewObservedAt time.Time `json:"reviewObservedAt,omitempty"` } +// SessionPRCISummary is the CI status block for a session PR summary. type SessionPRCISummary struct { State domain.CIState `json:"state" enum:"unknown,pending,passing,failing"` FailingChecks []SessionPRFailingCheck `json:"failingChecks"` } +// SessionPRFailingCheck is one failed or cancelled CI check for a PR. type SessionPRFailingCheck struct { Name string `json:"name"` Status domain.PRCheckStatus `json:"status" enum:"failed,cancelled"` @@ -254,24 +256,28 @@ type SessionPRFailingCheck struct { URL string `json:"url,omitempty"` } +// SessionPRReviewSummary is the review state block for a session PR summary. type SessionPRReviewSummary struct { Decision domain.ReviewDecision `json:"decision" enum:"none,approved,changes_requested,review_required"` HasUnresolvedHumanComments bool `json:"hasUnresolvedHumanComments"` UnresolvedBy []SessionPRUnresolvedReviewer `json:"unresolvedBy"` } +// SessionPRUnresolvedReviewer groups unresolved human comments by reviewer. type SessionPRUnresolvedReviewer struct { ReviewerID string `json:"reviewerId"` Count int `json:"count"` Links []SessionPRReviewCommentLink `json:"links"` } +// SessionPRReviewCommentLink points to one unresolved review comment. type SessionPRReviewCommentLink struct { URL string `json:"url,omitempty"` File string `json:"file,omitempty"` Line int `json:"line,omitempty"` } +// SessionPRMergeabilitySummary is the mergeability block for a session PR summary. type SessionPRMergeabilitySummary struct { State domain.Mergeability `json:"state" enum:"unknown,mergeable,conflicting,blocked,unstable"` Reasons []string `json:"reasons"` @@ -279,6 +285,7 @@ type SessionPRMergeabilitySummary struct { ConflictFiles []SessionPRConflictFile `json:"conflictFiles,omitempty"` } +// SessionPRConflictFile is one file involved in a PR merge conflict. type SessionPRConflictFile struct { Path string `json:"path"` URL string `json:"url,omitempty"` @@ -290,6 +297,7 @@ type ListSessionPRsResponse struct { PRs []SessionPRSummary `json:"prs"` } +// NewSessionPRSummary maps the service PR summary model to its HTTP DTO. func NewSessionPRSummary(in sessionsvc.PRSummary) SessionPRSummary { return SessionPRSummary{ URL: in.URL, diff --git a/backend/internal/service/session/pr_summary.go b/backend/internal/service/session/pr_summary.go index 277c2fdf..588162c1 100644 --- a/backend/internal/service/session/pr_summary.go +++ b/backend/internal/service/session/pr_summary.go @@ -33,11 +33,13 @@ type PRSummary struct { ReviewObservedAt time.Time } +// PRCISummary describes the latest CI status and failing checks for a PR. type PRCISummary struct { State domain.CIState FailingChecks []PRFailingCheck } +// PRFailingCheck is one failed or cancelled CI check for a PR. type PRFailingCheck struct { Name string Status domain.PRCheckStatus @@ -45,24 +47,28 @@ type PRFailingCheck struct { URL string } +// PRReviewSummary describes the latest review decision and unresolved comments. type PRReviewSummary struct { Decision domain.ReviewDecision HasUnresolvedHumanComments bool UnresolvedBy []PRUnresolvedReviewer } +// PRUnresolvedReviewer groups unresolved human comments by reviewer. type PRUnresolvedReviewer struct { ReviewerID string Count int Links []PRReviewCommentLink } +// PRReviewCommentLink points to one unresolved review comment. type PRReviewCommentLink struct { URL string File string Line int } +// PRMergeabilitySummary describes whether a PR can be merged and why. type PRMergeabilitySummary struct { State domain.Mergeability Reasons []string @@ -70,6 +76,7 @@ type PRMergeabilitySummary struct { ConflictFiles []PRConflictFile } +// PRConflictFile is one file involved in a PR merge conflict. type PRConflictFile struct { Path string URL string