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
58 changes: 58 additions & 0 deletions Packages/Sources/RxCodeCore/Hooks/HookController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,45 @@ public protocol HookController: AnyObject {
func project(for id: UUID) -> Project?
/// The display title of a session (from its summary), if known.
func sessionTitle(sessionId: String) -> String?

// MARK: Thread linkage / cross-thread sends (code-review & commit hooks)

/// Whether the thread should skip all lifecycle hooks (e.g. a review thread).
func threadSkipsHooks(sessionId: String) -> Bool
/// The model id stored on a thread, if any (used as the review thread's
/// default model).
func threadModel(sessionId: String) -> String?
/// Distinct paths of files edited during the thread.
func changedFilePaths(sessionId: String) -> [String]
/// The first user prompt text of a thread, if any.
func firstUserPrompt(sessionId: String) -> String?
/// A readable transcript of a thread's assistant activity (text + tool-call
/// summaries), used to fold a review thread's full content into a card on the
/// parent thread. Excludes the injected instruction prompt.
func threadTranscript(sessionId: String) -> String
/// Spawn a new linked thread (e.g. `[Code Review]`) that runs no hooks, and
/// wait for its first response. Returns the resolved thread id + assistant
/// text, or `nil` if it could not be sent.
func spawnLinkedThread(
projectId: UUID,
parentThreadId: String,
label: String,
model: String?,
prompt: String,
timeoutSeconds: TimeInterval
) async -> HookLinkedThreadResult?
/// Send a follow-up prompt into an existing thread without waiting.
func sendThreadMessage(sessionId: String, prompt: String)
/// Record the latest review verdict for a session (shared between the
/// code-review and commit hooks within one stop cycle).
func setReviewPassed(_ passed: Bool, sessionId: String)
/// The latest recorded review verdict for a session, or `nil` if none.
func reviewPassed(sessionId: String) -> Bool?
/// Number of failed-review re-prompt rounds so far for a session (bounds the
/// review→fix→review loop).
func reviewRound(sessionId: String) -> Int
/// Set the failed-review round counter for a session.
func setReviewRound(_ round: Int, sessionId: String)
/// First-sentence fallback body for a response-complete notification.
func responseNotificationFallback(from responseText: String) -> String
/// Optionally generate an AI summary of the response for a notification body.
Expand Down Expand Up @@ -191,6 +230,25 @@ public protocol HookController: AnyObject {
public enum HookSetupKind {
public static let release = "release"
public static let docs = "docs"
/// Marks the follow-up turn the Commit & Push hook itself triggered, so the
/// hook short-circuits on that turn instead of looping forever.
public static let commitPush = "commitPush"
}

/// Result of spawning a linked thread via `spawnLinkedThread`.
public struct HookLinkedThreadResult: Sendable {
/// The resolved (non-`pending-`) thread id of the spawned thread.
public let threadId: String
/// The reviewer/agent's first response text (empty on timeout).
public let assistantText: String
/// A transport/agent error, if any.
public let error: String?

public init(threadId: String, assistantText: String, error: String?) {
self.threadId = threadId
self.assistantText = assistantText
self.error = error
}
}

// MARK: - Banner surfaces
Expand Down
45 changes: 40 additions & 5 deletions Packages/Sources/RxCodeCore/Models/ChatSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,17 @@ public struct ChatSession: Identifiable, Codable, Sendable {
public var isArchived: Bool
public var archivedAt: Date?

/// Id of the thread this thread was spawned from (e.g. a `[Code Review]`
/// thread links back to the thread whose change it reviews). `nil` for
/// ordinary user-started threads.
public var parentThreadId: String?
/// Short label shown as a chip in the UI (e.g. `"Code Review"`). `nil` for
/// ordinary threads.
public var threadLabel: String?
/// When true, lifecycle hooks are skipped for this thread (review threads
/// must not themselves trigger reviews/commits and loop).
public var skipHooks: Bool

public init(
id: String,
projectId: UUID,
Expand All @@ -36,7 +47,10 @@ public struct ChatSession: Identifiable, Codable, Sendable {
worktreePath: String? = nil,
worktreeBranch: String? = nil,
isArchived: Bool = false,
archivedAt: Date? = nil
archivedAt: Date? = nil,
parentThreadId: String? = nil,
threadLabel: String? = nil,
skipHooks: Bool = false
) {
self.id = id
self.projectId = projectId
Expand All @@ -54,10 +68,13 @@ public struct ChatSession: Identifiable, Codable, Sendable {
self.worktreeBranch = worktreeBranch
self.isArchived = isArchived
self.archivedAt = archivedAt
self.parentThreadId = parentThreadId
self.threadLabel = threadLabel
self.skipHooks = skipHooks
}

private enum CodingKeys: String, CodingKey {
case id, projectId, title, messages, createdAt, updatedAt, isPinned, agentProvider, model, effort, permissionMode, origin, worktreePath, worktreeBranch, isArchived, archivedAt
case id, projectId, title, messages, createdAt, updatedAt, isPinned, agentProvider, model, effort, permissionMode, origin, worktreePath, worktreeBranch, isArchived, archivedAt, parentThreadId, threadLabel, skipHooks
}

public init(from decoder: Decoder) throws {
Expand All @@ -78,6 +95,9 @@ public struct ChatSession: Identifiable, Codable, Sendable {
worktreeBranch = try container.decodeIfPresent(String.self, forKey: .worktreeBranch)
isArchived = try container.decodeIfPresent(Bool.self, forKey: .isArchived) ?? false
archivedAt = try container.decodeIfPresent(Date.self, forKey: .archivedAt)
parentThreadId = try container.decodeIfPresent(String.self, forKey: .parentThreadId)
threadLabel = try container.decodeIfPresent(String.self, forKey: .threadLabel)
skipHooks = try container.decodeIfPresent(Bool.self, forKey: .skipHooks) ?? false
}

public struct Summary: Identifiable, Codable, Sendable, Equatable {
Expand All @@ -96,6 +116,9 @@ public struct ChatSession: Identifiable, Codable, Sendable {
public var worktreeBranch: String?
public var isArchived: Bool
public var archivedAt: Date?
public var parentThreadId: String?
public var threadLabel: String?
public var skipHooks: Bool

public init(
id: String,
Expand All @@ -112,7 +135,10 @@ public struct ChatSession: Identifiable, Codable, Sendable {
worktreePath: String? = nil,
worktreeBranch: String? = nil,
isArchived: Bool = false,
archivedAt: Date? = nil
archivedAt: Date? = nil,
parentThreadId: String? = nil,
threadLabel: String? = nil,
skipHooks: Bool = false
) {
self.id = id
self.projectId = projectId
Expand All @@ -129,10 +155,13 @@ public struct ChatSession: Identifiable, Codable, Sendable {
self.worktreeBranch = worktreeBranch
self.isArchived = isArchived
self.archivedAt = archivedAt
self.parentThreadId = parentThreadId
self.threadLabel = threadLabel
self.skipHooks = skipHooks
}

private enum CodingKeys: String, CodingKey {
case id, projectId, title, createdAt, updatedAt, isPinned, agentProvider, model, effort, permissionMode, origin, worktreePath, worktreeBranch, isArchived, archivedAt
case id, projectId, title, createdAt, updatedAt, isPinned, agentProvider, model, effort, permissionMode, origin, worktreePath, worktreeBranch, isArchived, archivedAt, parentThreadId, threadLabel, skipHooks
}

public init(from decoder: Decoder) throws {
Expand All @@ -152,6 +181,9 @@ public struct ChatSession: Identifiable, Codable, Sendable {
worktreeBranch = try container.decodeIfPresent(String.self, forKey: .worktreeBranch)
isArchived = try container.decodeIfPresent(Bool.self, forKey: .isArchived) ?? false
archivedAt = try container.decodeIfPresent(Date.self, forKey: .archivedAt)
parentThreadId = try container.decodeIfPresent(String.self, forKey: .parentThreadId)
threadLabel = try container.decodeIfPresent(String.self, forKey: .threadLabel)
skipHooks = try container.decodeIfPresent(Bool.self, forKey: .skipHooks) ?? false
}
}

Expand All @@ -171,7 +203,10 @@ public struct ChatSession: Identifiable, Codable, Sendable {
worktreePath: worktreePath,
worktreeBranch: worktreeBranch,
isArchived: isArchived,
archivedAt: archivedAt
archivedAt: archivedAt,
parentThreadId: parentThreadId,
threadLabel: threadLabel,
skipHooks: skipHooks
)
}

Expand Down
29 changes: 26 additions & 3 deletions Packages/Sources/RxCodeCore/Models/ChatThread.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ public final class ChatThread {
public var isArchived: Bool = false
public var archivedAt: Date?

/// Id of the thread this thread was spawned from (e.g. a `[Code Review]`
/// thread links back to the reviewed thread). Defaulted for clean migration.
public var parentThreadId: String? = nil
/// Short label chip shown in the UI (e.g. `"Code Review"`).
public var threadLabel: String? = nil
/// When true, lifecycle hooks are skipped for this thread.
public var skipHooks: Bool = false

public init(
id: String,
projectId: UUID,
Expand All @@ -39,7 +47,10 @@ public final class ChatThread {
worktreePath: String? = nil,
worktreeBranch: String? = nil,
isArchived: Bool = false,
archivedAt: Date? = nil
archivedAt: Date? = nil,
parentThreadId: String? = nil,
threadLabel: String? = nil,
skipHooks: Bool = false
) {
self.id = id
self.projectId = projectId
Expand All @@ -57,6 +68,9 @@ public final class ChatThread {
self.worktreeBranch = worktreeBranch
self.isArchived = isArchived
self.archivedAt = archivedAt
self.parentThreadId = parentThreadId
self.threadLabel = threadLabel
self.skipHooks = skipHooks
}
}

Expand All @@ -81,7 +95,10 @@ extension ChatThread {
worktreePath: worktreePath,
worktreeBranch: worktreeBranch,
isArchived: isArchived,
archivedAt: archivedAt
archivedAt: archivedAt,
parentThreadId: parentThreadId,
threadLabel: threadLabel,
skipHooks: skipHooks
)
}

Expand All @@ -98,6 +115,9 @@ extension ChatThread {
worktreeBranch = summary.worktreeBranch
isArchived = summary.isArchived
archivedAt = summary.archivedAt
parentThreadId = summary.parentThreadId
threadLabel = summary.threadLabel
skipHooks = summary.skipHooks
}

public static func from(_ summary: ChatSession.Summary, cliSessionId: String? = nil) -> ChatThread {
Expand All @@ -117,7 +137,10 @@ extension ChatThread {
worktreePath: summary.worktreePath,
worktreeBranch: summary.worktreeBranch,
isArchived: summary.isArchived,
archivedAt: summary.archivedAt
archivedAt: summary.archivedAt,
parentThreadId: summary.parentThreadId,
threadLabel: summary.threadLabel,
skipHooks: summary.skipHooks
)
}
}
Loading
Loading