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
4 changes: 2 additions & 2 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ let packageDirectory = URL(fileURLWithPath: #filePath)
let localCodexKitPath = packageDirectory
.appendingPathComponent("dependencies/CodexKit", isDirectory: true)
.path
let codexKitFallbackRevision = "99ef48d1306435c0bb801b1b1c233f31685421c6"
let codexKitFallbackRevision = "ab025ed970d30c7679913951bdb9fff20a9b77b1"
let codexKitDependency: Package.Dependency =
FileManager.default.fileExists(atPath: "\(localCodexKitPath)/Package.swift")
? .package(path: localCodexKitPath)
Expand Down
10 changes: 10 additions & 0 deletions Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ extension CodexReviewStore {
}
}

package func reviewRun(forReviewChatID chatID: String) -> ReviewRunRecord? {
orderedReviewRuns.first { runRecord in
runRecord.matchesReviewChatID(chatID)
}
}

package func cancellableReviewRun(forChatID chatID: String) -> ReviewRunRecord? {
orderedReviewRuns.first { runRecord in
guard isCancellableReviewRun(runRecord) else {
Expand All @@ -92,6 +98,10 @@ extension CodexReviewStore {
}

private extension ReviewRunRecord {
func matchesReviewChatID(_ chatID: String) -> Bool {
core.attempt?.threadIdentity.activeTurnThreadID.rawValue == chatID
}

func matchesChatID(_ chatID: String) -> Bool {
guard let identity = core.attempt?.threadIdentity else {
return false
Expand Down
249 changes: 212 additions & 37 deletions Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatRowView.swift
Original file line number Diff line number Diff line change
@@ -1,38 +1,180 @@
import Foundation
import SwiftUI
import CodexAppServerKit
import CodexDataKit
import CodexReviewKit

@MainActor
struct ReviewMonitorChatRowPresentation: Equatable {
enum Timing: Equatable {
case elapsed(since: Date)
case relative(to: Date)
}

enum Symbol: Equatable {
case progress
case succeeded
case failed
case cancelled
case none
}

let title: String
let statusText: String
let timing: Timing?
let symbol: Symbol

init(chat: CodexChat, reviewRun: ReviewRunRecord?) {
title = reviewRun?.targetSummary.trimmedNonEmpty
?? Self.gitLabel(chat.gitInfo)
?? chat.title

guard let reviewRun else {
if chat.status?.isActive == true {
statusText = Self.isReviewSource(chat) ? "Reviewing" : "Running"
timing = chat.activityDate.map(Timing.elapsed)
symbol = .progress
} else {
statusText = Self.sourceLabel(chat)
timing = chat.activityDate.map(Timing.relative)
symbol = .none
}
return
}

let presentation = reviewRun.presentation
switch presentation.lifecycle {
case .queued:
statusText = "Queued"
symbol = .none
case .starting:
statusText = "Starting"
symbol = .progress
case .running:
statusText = "Reviewing"
symbol = .progress
case .waitingForNetwork:
statusText = "Waiting for network"
symbol = .progress
case .preparingRestart, .restarting:
statusText = "Restarting"
symbol = .progress
case .cancelling:
statusText = "Cancelling"
symbol = .progress
case .succeeded:
statusText = "Review complete"
symbol = .succeeded
case .failed:
statusText = "Review failed"
symbol = .failed
case .cancelled:
statusText = "Cancelled"
symbol = .cancelled
}

if let endedAt = reviewRun.core.endedAt {
timing = .relative(to: endedAt)
} else if let startedAt = reviewRun.core.startedAt {
timing = .elapsed(since: startedAt)
} else {
timing = chat.activityDate.map(Timing.relative)
}
}

private static func gitLabel(_ gitInfo: CodexThreadGitInfo?) -> String? {
let branch = gitInfo?.branch?.trimmedNonEmpty
let sha = gitInfo?.sha?.trimmedNonEmpty.map { String($0.prefix(8)) }
switch (branch, sha) {
case (.some(let branch), .some(let sha)):
return "\(branch) · \(sha)"
case (.some(let branch), nil):
return branch
case (nil, .some(let sha)):
return sha
case (nil, nil):
return nil
}
}

private static func isReviewSource(_ chat: CodexChat) -> Bool {
if let source = chat.source, case .subAgent(.review) = source {
return true
}
return chat.sourceKind == .subAgentReview
}

private static func sourceLabel(_ chat: CodexChat) -> String {
if let source = chat.source {
switch source {
case .cli:
return "CLI"
case .vscode:
return "VS Code"
case .exec:
return "Exec"
case .appServer:
return "Codex"
case .custom(let value):
guard let value = value.trimmedNonEmpty else {
return "Custom"
}
switch value.lowercased() {
case "atlas":
return "Atlas"
case "chatgpt":
return "ChatGPT"
default:
return value
}
case .subAgent(.review):
return "Review"
case .subAgent(.compact):
return "Compact"
case .subAgent(.threadSpawn), .subAgent(.other):
return "Sub-agent"
case .subAgent(.memoryConsolidation):
return "Memory"
case .unknown:
return "Thread"
}
}

let sourceKind = chat.sourceKind
if sourceKind == .cli { return "CLI" }
if sourceKind == .vscode { return "VS Code" }
if sourceKind == .exec { return "Exec" }
if sourceKind == .appServer { return "Codex" }
if sourceKind == .subAgentReview { return "Review" }
if sourceKind == .subAgentCompact { return "Compact" }
if sourceKind == .subAgent || sourceKind == .subAgentThreadSpawn
|| sourceKind == .subAgentOther
{
return "Sub-agent"
}
return "Thread"
}
}

@MainActor
struct ReviewMonitorChatRowView: View {
var chat: CodexChat
var store: CodexReviewStore

var body: some View {
let isRunning = chat.status?.isActive == true
let startedAt = isRunning ? chat.activityDate : nil
let reviewRun = store.reviewRun(forReviewChatID: chat.id.rawValue)
let presentation = ReviewMonitorChatRowPresentation(chat: chat, reviewRun: reviewRun)

Label {
VStack {
HStack {
Text(chat.title)
.truncationMode(.tail)
Spacer(minLength: 0)
if let startedAt {
Text(
timerInterval: startedAt...(.distantFuture),
pauseTime: nil,
countsDown: false,
showsHours: true
)
.monospacedDigit()
.foregroundStyle(.secondary)
.layoutPriority(1)
}
}
.lineLimit(1)
HStack {
Text(chat.modelProvider?.trimmedNonEmpty ?? "")
Text(chat.preview?.trimmedNonEmpty ?? "")
Spacer(minLength: 0)
VStack(alignment: .leading) {
Text(presentation.title)
.truncationMode(.middle)
.frame(maxWidth: .infinity, alignment: .leading)
.lineLimit(1)
HStack(spacing: 4) {
Text(presentation.statusText)
Spacer(minLength: 4)
timingText(presentation.timing)
}
.textScale(.secondary)
.foregroundStyle(.secondary)
Expand All @@ -42,17 +184,54 @@ struct ReviewMonitorChatRowView: View {
ZStack {
Image(systemName: "circle.fill")
.foregroundStyle(.clear)
if isRunning {
switch presentation.symbol {
case .progress:
ProgressView()
.controlSize(.mini)
.accessibilityHidden(true)
case .succeeded:
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
.accessibilityHidden(true)
case .failed:
Image(systemName: "exclamationmark.circle.fill")
.foregroundStyle(.red)
.accessibilityHidden(true)
case .cancelled:
Image(systemName: "xmark.circle")
.foregroundStyle(.secondary)
.accessibilityHidden(true)
case .none:
EmptyView()
}
}
.animation(.default, value: isRunning)
.animation(.default, value: presentation.symbol)
.padding(.leading, SidebarLayout.disclosureGutterWidth)
}
.transaction(value: chat.id.rawValue) { transaction in
transaction.disablesAnimations = true
}
.help(presentation.title)
}

@ViewBuilder
private func timingText(_ timing: ReviewMonitorChatRowPresentation.Timing?) -> some View {
switch timing {
case .elapsed(let startedAt):
Text(
timerInterval: startedAt...(.distantFuture),
pauseTime: nil,
countsDown: false,
showsHours: true
)
.monospacedDigit()
.layoutPriority(1)
case .relative(let date):
Text(date, style: .relative)
.layoutPriority(1)
case nil:
EmptyView()
}
}
}

Expand Down Expand Up @@ -80,26 +259,22 @@ extension ReviewMonitorChatRowView {
let hostingView = NSHostingView(
rootView: Label {
VStack {
HStack {
Text("Uncommitted changes")
.truncationMode(.tail)
Spacer(minLength: 0)
Text("Uncommitted changes")
.truncationMode(.middle)
.frame(maxWidth: .infinity, alignment: .leading)
.lineLimit(1)
HStack(spacing: 4) {
Text("Waiting for network")
Spacer(minLength: 4)
Text(
timerInterval: Date(timeIntervalSince1970: 0)...(.distantFuture),
pauseTime: nil,
countsDown: false,
showsHours: true
)
.monospacedDigit()
.foregroundStyle(.secondary)
.layoutPriority(1)
}
.lineLimit(1)
HStack {
Text("gpt-5.5")
Text("Review output preview")
Spacer(minLength: 0)
}
.textScale(.secondary)
.foregroundStyle(.secondary)
.lineLimit(1)
Expand Down
Loading