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
22 changes: 16 additions & 6 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ concurrency:

on:
push:
branches:
- main
release:
types:
- created
Comment on lines 7 to 11
Expand Down Expand Up @@ -90,19 +88,31 @@ jobs:
# CURRENT_PROJECT_VERSION is overridden per-build so every CI build
# has a unique build number (github.run_number), without committing
# churn to project.pbxproj.
set -o pipefail && xcodebuild -destination platform=macOS \
# The provisioning profile is scoped to the RxCode target's Release
# build settings in project.pbxproj (PROVISIONING_PROFILE_SPECIFIER),
# NOT passed on the command line. A command-line PROVISIONING_PROFILE*
# applies to every target in the graph — including SPM package targets
# that don't support provisioning profiles — which fails the archive.
# CODE_SIGN_STYLE/IDENTITY stay global: Manual signing needs no team
# and library targets sign with the identity but need no profile.
set -o pipefail
xcodebuild -destination platform=macOS \
-project RxCode.xcodeproj \
-scheme RxCode \
-configuration Release \
-archivePath output/output.xcarchive \
-allowProvisioningUpdates \
CODE_SIGN_IDENTITY="${{ secrets.SIGNING_CERTIFICATE_NAME }}" \
CODE_SIGN_STYLE=Manual \
PROVISIONING_PROFILE="$MACOS_PROFILE_UUID" \
PROVISIONING_PROFILE_SPECIFIER="$MACOS_PROFILE_SPECIFIER" \
OTHER_CODE_SIGN_FLAGS="--options=runtime --timestamp" \
CURRENT_PROJECT_VERSION=${{ github.run_number }} \
archive | xcpretty
archive 2>&1 | tee xcodebuild-archive.log | xcpretty || {
status=${PIPESTATUS[0]}
echo "::group::Full xcodebuild output (archive failed)"
cat xcodebuild-archive.log
echo "::endgroup::"
exit "$status"
}

- name: Sign Sparkle
run: ./scripts/ci/sign-sparkle.sh
Expand Down
9 changes: 9 additions & 0 deletions Packages/Sources/RxCodeChatKit/MessageListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,15 @@ struct MessageListView: View {
.onChange(of: chatBridge.messages.last?.id) { _, _ in
handleLastMessageChange()
}
// Synthetic hook/auto-continue cards mutate the last message in
// place (result: nil → result) without changing its id, content, or
// the session-level isStreaming flag, so none of the other rebuild
// triggers fire. They flip isResponseComplete when the result lands;
// observe it so the running card refreshes to its result live
// instead of only after a reload.
.onChange(of: chatBridge.messages.last?.isResponseComplete) { _, _ in
handleLastMessageChange()
}
.onChange(of: chatBridge.messages.last?.content) { _, _ in
guard isSessionReady else { return }
requestScrollToBottom()
Expand Down
41 changes: 37 additions & 4 deletions Packages/Sources/RxCodeChatKit/ToolResultView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,10 @@ struct ToolResultView: View {
Spacer()

Group {
if displayIsError {
if isAutoContinueTool {
// System action, not a pass/fail result — no status badge.
EmptyView()
} else if displayIsError {
Image(systemName: "exclamationmark.circle.fill")
.foregroundStyle(ClaudeTheme.statusError)
.font(.caption)
Expand All @@ -114,7 +117,7 @@ struct ToolResultView: View {
.foregroundStyle(ClaudeTheme.statusSuccess)
.font(.caption)
.accessibilityLabel("Completed")
} else if isMessageStreaming {
} else if isMessageStreaming || isRunningHookCard {
ProgressView()
.controlSize(.mini)
.accessibilityLabel("Running")
Expand Down Expand Up @@ -235,7 +238,7 @@ struct ToolResultView: View {

inputSummaryView(maximumNumberOfLines: 1)

if toolCall.result == nil && isMessageStreaming {
if toolCall.result == nil && (isMessageStreaming || isRunningHookCard) {
ProgressView()
.controlSize(.mini)
.accessibilityLabel("Running")
Expand Down Expand Up @@ -289,7 +292,28 @@ struct ToolResultView: View {
}

private var isCardTool: Bool {
isEditTool
isEditTool || isHookTool || isAutoContinueTool
}

/// Lifecycle hooks are emitted as tool calls named "Hook: <name>" so they
/// render through the same status/result card as real tools.
private var isHookTool: Bool {
toolNameLower.hasPrefix("hook:")
}

/// The synthetic card inserted when a failing before-session-stop hook
/// re-prompts the agent. Rendered as its own card (not a user bubble) so it
/// reads as a system action rather than something the user typed.
private var isAutoContinueTool: Bool {
toolNameLower == ToolCall.autoContinueToolName.lowercased()
}

/// A hook card whose command is still running. Hook cards are synthetic and
/// never carry the message-level `isStreaming` flag (a stop hook runs after
/// the turn is finalized), so the "running" spinner keys off the absent
/// result instead of `isMessageStreaming`.
private var isRunningHookCard: Bool {
isHookTool && toolCall.result == nil
}

// MARK: - Edit Diff
Expand Down Expand Up @@ -481,6 +505,8 @@ struct ToolResultView: View {
// MARK: - Helpers

private var sfSymbol: String {
if isAutoContinueTool { return "arrow.triangle.2.circlepath" }
if isHookTool { return "bolt.horizontal.circle" }
switch toolNameLower {
case "agent": return "cpu"
case "read": return "doc.text"
Expand All @@ -497,6 +523,8 @@ struct ToolResultView: View {
}

private var iconColor: Color {
if isAutoContinueTool { return ClaudeTheme.accent }
if isHookTool { return ClaudeTheme.accent }
switch toolNameLower {
case "agent", "bash",
InteractiveTerminalState.toolName: return ClaudeTheme.accent
Expand Down Expand Up @@ -583,6 +611,11 @@ struct ToolResultView: View {
}

private var inputSummary: String {
if isAutoContinueTool {
return toolCall.input["summary"]?.stringValue
?? "Stop hook failed — sent back to the agent to continue."
}

if toolNameLower == "agent" {
if let desc = toolCall.input["description"]?.stringValue {
return desc
Expand Down
4 changes: 4 additions & 0 deletions Packages/Sources/RxCodeCore/Models/ChatMessage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,10 @@ public struct ToolCall: Identifiable, Codable, Sendable, Equatable {
result.map { !$0.isEmpty } ?? false
}

/// Tool name for the synthetic card shown when a failing before-session-stop
/// hook auto-continues the agent. Rendered specially by `ToolResultView`.
public static let autoContinueToolName = "Auto-continue"

/// Tool names that must stay in the message block even without a result —
/// either because the result would be empty by design, or because the UI
/// needs to render them before the user/CLI produces a result.
Expand Down
64 changes: 64 additions & 0 deletions Packages/Sources/RxCodeCore/Models/HookProfile.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import Foundation

/// Lifecycle point at which a hook fires.
public enum HookTrigger: String, Codable, Sendable, CaseIterable, Hashable {
/// Fires once, when a brand-new thread begins its first turn. The hook's
/// stdout is injected into the agent's context for that turn.
case beforeSessionStart
/// Fires when streaming stops, before the thread is finalized. The hook's
/// output is shown and saved as a context block in the thread.
case beforeSessionStop
/// Fires after the thread is finalized/saved when streaming stops. The
/// hook's output is shown only — nothing is passed back to the session.
case afterSessionStop

public var displayName: String {
switch self {
case .beforeSessionStart: return "Before Session Start"
case .beforeSessionStop: return "Before Session Stop"
case .afterSessionStop: return "After Session Stop"
}
}
}

/// What a hook runs. Bash-only today; typed so other kinds can be added later.
public enum HookType: String, Codable, Sendable, CaseIterable, Hashable {
case bash
}

/// A single project-scoped automation that runs a command at a session
/// lifecycle point. Modeled on `RunProfile`; reuses `BashRunConfig` for the
/// command, working directory, and environment presets.
public struct HookProfile: Identifiable, Codable, Sendable, Hashable {
public var id: UUID
public var projectId: UUID
public var name: String
public var enabled: Bool
public var trigger: HookTrigger
public var type: HookType
public var bash: BashRunConfig
public var createdAt: Date
public var updatedAt: Date

public init(
id: UUID = UUID(),
projectId: UUID,
name: String,
enabled: Bool = true,
trigger: HookTrigger = .beforeSessionStart,
type: HookType = .bash,
bash: BashRunConfig = BashRunConfig(),
createdAt: Date = Date(),
updatedAt: Date = Date()
) {
self.id = id
self.projectId = projectId
self.name = name
self.enabled = enabled
self.trigger = trigger
self.type = type
self.bash = bash
self.createdAt = createdAt
self.updatedAt = updatedAt
}
}
40 changes: 40 additions & 0 deletions Packages/Sources/RxCodeCore/Models/HookStatusRecord.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import Foundation
import SwiftData

/// Persisted status of the most recent hook card for a session. One row per
/// `sessionId` — we only keep the last hook (start/stop) so its card survives a
/// reload, mirroring how `PlanDecisionRecord` keeps plan decisions alive across
/// CLI-backed session reloads. Hook cards are synthetic `ChatMessage`s injected
/// by `runHooks`; they never reach the CLI's jsonl transcript, so without this
/// sidecar they vanish when messages are reloaded from disk.
@Model
public final class HookStatusRecord {
@Attribute(.unique) public var sessionId: String
/// The original tool-call id of the hook card, so the rebuilt card dedupes
/// against a live in-memory one.
public var toolId: String
public var name: String
/// `HookTrigger.displayName` at the time the hook ran.
public var trigger: String
public var output: String
public var isError: Bool
public var updatedAt: Date

public init(
sessionId: String,
toolId: String,
name: String,
trigger: String,
output: String,
isError: Bool,
updatedAt: Date = .now
) {
self.sessionId = sessionId
self.toolId = toolId
self.name = name
self.trigger = trigger
self.output = output
self.isError = isError
self.updatedAt = updatedAt
}
}
50 changes: 50 additions & 0 deletions Packages/Sources/RxCodeCore/Models/PackageRunConfig.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import Foundation

/// A JavaScript/TypeScript package manager (or runtime task runner) capable of
/// running a named script from `package.json` (or `deno.json` tasks).
public enum PackageManager: String, Codable, Sendable, CaseIterable, Hashable {
case npm
case yarn
case pnpm
case bun
case deno

/// Command prefix that precedes the script name. npm and pnpm require an
/// explicit `run`; yarn/bun take the script directly; deno uses `deno task`.
public var runPrefix: String {
switch self {
case .npm: return "npm run"
case .yarn: return "yarn"
case .pnpm: return "pnpm run"
case .bun: return "bun run"
case .deno: return "deno task"
}
}

/// Executable name used for install detection (`command -v <executable>`).
public var executable: String { rawValue }

public var displayName: String { rawValue }
}

/// Configuration for a `.packageScript` run profile: run a named script through
/// a selected package manager, with optional extra arguments appended verbatim
/// after the script (e.g. `bun run dev -- --port 3000`).
public struct PackageRunConfig: Codable, Sendable, Hashable {
public var packageManager: PackageManager
/// The script/task name, e.g. `dev`.
public var script: String
/// Extra arguments appended after the script. Free-form so the user can pass
/// `-- --port 3000` (npm-style separator) or plain flags.
public var arguments: String

public init(
packageManager: PackageManager = .npm,
script: String = "",
arguments: String = ""
) {
self.packageManager = packageManager
self.script = script
self.arguments = arguments
}
}
9 changes: 9 additions & 0 deletions Packages/Sources/RxCodeCore/Models/RunProfile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ public enum RunProfileType: String, Codable, Sendable, CaseIterable, Hashable {
case bash
case xcode
case make
/// Runs a `package.json` / `deno.json` script through a selected package
/// manager. Named `packageScript` to avoid the `package` access-control
/// keyword; the rawValue stays `"package"` so it renders as "Package".
case packageScript = "package"
}

public struct RunProfile: Identifiable, Codable, Sendable, Hashable {
Expand All @@ -18,6 +22,9 @@ public struct RunProfile: Identifiable, Codable, Sendable, Hashable {
/// Populated when `type == .make`. Optional so existing on-disk profiles
/// (written before the Make type existed) decode cleanly.
public var make: MakeRunConfig?
/// Populated when `type == .packageScript`. Optional so existing on-disk
/// profiles (written before the Package type existed) decode cleanly.
public var package: PackageRunConfig?
public var beforeSteps: [RunStep]
public var afterSteps: [RunStep]
public var createdAt: Date
Expand All @@ -31,6 +38,7 @@ public struct RunProfile: Identifiable, Codable, Sendable, Hashable {
bash: BashRunConfig = BashRunConfig(),
xcode: XcodeRunConfig? = nil,
make: MakeRunConfig? = nil,
package: PackageRunConfig? = nil,
beforeSteps: [RunStep] = [],
afterSteps: [RunStep] = [],
createdAt: Date = Date(),
Expand All @@ -43,6 +51,7 @@ public struct RunProfile: Identifiable, Codable, Sendable, Hashable {
self.bash = bash
self.xcode = xcode
self.make = make
self.package = package
self.beforeSteps = beforeSteps
self.afterSteps = afterSteps
self.createdAt = createdAt
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,21 +27,27 @@ public struct DetectedRunnable: Identifiable, Codable, Hashable, Sendable {
/// Present when `source == .make`. The dialog materializes these as
/// `.make`-typed profiles instead of bash configurations.
public let make: MakeRunConfig?
/// Present when `source == .npm`. The dialog materializes these as
/// `.packageScript`-typed profiles, carrying the detected package manager
/// and script name.
public let package: PackageRunConfig?

public init(
id: String,
source: RunnableSource,
displayName: String,
command: String,
xcode: XcodeRunConfig? = nil,
make: MakeRunConfig? = nil
make: MakeRunConfig? = nil,
package: PackageRunConfig? = nil
) {
self.id = id
self.source = source
self.displayName = displayName
self.command = command
self.xcode = xcode
self.make = make
self.package = package
}
}

Expand Down
Loading
Loading