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
3 changes: 2 additions & 1 deletion Packages/Sources/MessageList/MessageList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,8 @@ public struct MessageList<Message: MessageListItem, RowContent: View>: View {
private func handleScrollMetrics(_ metrics: MessageListScrollMetrics, proxy: ScrollViewProxy) {
let decision = anchor.apply(
contentHeight: metrics.contentHeight,
visibleMaxY: metrics.visibleMaxY
visibleMaxY: metrics.visibleMaxY,
isUserDriven: isUserDrivenScroll
)
updateIsAtBottomBinding(anchor.isNearBottom)

Expand Down
19 changes: 18 additions & 1 deletion Packages/Sources/MessageList/MessageListScrollAnchor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,14 @@ nonisolated struct MessageListScrollAnchor: Equatable {
self.hasReceivedFirstUpdate = false
}

/// Apply a new scroll geometry sample.
///
/// `isUserDriven` reports whether the change is driven by the user's finger /
/// trackpad (or post-flick glide) rather than by layout or a programmatic
/// scroll. It gates the only branch that can *un-stick* the anchor — see
/// below for why that matters.
@discardableResult
mutating func apply(contentHeight: CGFloat, visibleMaxY: CGFloat) -> Decision {
mutating func apply(contentHeight: CGFloat, visibleMaxY: CGFloat, isUserDriven: Bool) -> Decision {
let distanceFromBottom = max(0, contentHeight - visibleMaxY)
let nowNearBottom = distanceFromBottom < threshold

Expand All @@ -39,6 +45,17 @@ nonisolated struct MessageListScrollAnchor: Equatable {
return previouslyNearBottom ? .scrollToBottom : .none
}

// Content stable (or shrunk). Recomputing `isNearBottom` from the raw
// distance is the ONLY path that can un-stick the anchor, so it must
// only run for a genuine user scroll. A tall card (Edit diff, Bash
// output, etc.) that lays out in one frame leaves `distanceFromBottom`
// huge until the throttled/async scroll-to-bottom executes; a layout
// settle that lands in that window would otherwise recompute
// `isNearBottom` to false even though the user never scrolled — which
// then makes the pending auto-scroll bail and strands the view above
// the bottom. Gating on `isUserDriven` keeps the anchor sticky through
// that settle while still letting a deliberate scroll up release it.
guard isUserDriven else { return .none }
isNearBottom = nowNearBottom
return .none
}
Expand Down
11 changes: 3 additions & 8 deletions Packages/Sources/RxCodeChatKit/ChatMessageBubble.swift
Original file line number Diff line number Diff line change
Expand Up @@ -93,14 +93,9 @@ private struct CompactChatMessageBubble: View {
}

private func userTextBubble(_ text: String) -> some View {
ChatTextContentView(
text,
size: ClaudeTheme.messageSize(14),
color: ClaudeTheme.userBubbleText,
lineSpacing: 2
)
.bubbleStyle(.user)
.frame(maxWidth: 500, alignment: .trailing)
MarkdownContentView(text: text, style: .rxCodeChatUser)
.bubbleStyle(.user)
.frame(maxWidth: 500, alignment: .trailing)
}

private func assistantText(_ text: String) -> some View {
Expand Down
28 changes: 26 additions & 2 deletions Packages/Sources/RxCodeChatKit/MarkdownView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import RxCodeCore
import RxCodeMarkdown

extension MarkdownStyle {
static var rxCodeChat: MarkdownStyle {
public static var rxCodeChat: MarkdownStyle {
MarkdownStyle(
bodyFontSize: ClaudeTheme.messageSize(15),
bodyColor: ClaudeTheme.textPrimary,
Expand All @@ -19,6 +19,27 @@ extension MarkdownStyle {
cornerRadius: ClaudeTheme.cornerRadiusSmall
)
}

/// Markdown styling tuned for the accent-tinted user bubble: text and
/// inline accents derive from `userBubbleText` so they stay legible on the
/// dark bubble background instead of using the global primary/accent colors.
public static var rxCodeChatUser: MarkdownStyle {
let text = ClaudeTheme.userBubbleText
return MarkdownStyle(
bodyFontSize: ClaudeTheme.messageSize(14),
bodyColor: text,
secondaryColor: text.opacity(0.85),
accentColor: text,
codeTextColor: text,
codeBackground: text.opacity(0.12),
codeHeaderBackground: text.opacity(0.08),
borderColor: text.opacity(0.22),
tableHeaderBackground: text.opacity(0.1),
lineSpacing: 3,
blockSpacing: 8,
cornerRadius: ClaudeTheme.cornerRadiusSmall
)
}
}

/// Renders markdown text through the pure SwiftUI markdown package while
Expand All @@ -28,6 +49,7 @@ public struct MarkdownContentView: View {
let showsTrailingCursor: Bool
let isCursorVisible: Bool
let baseURL: URL?
let style: MarkdownStyle
let fadeNewText: Bool
let onOpenLink: MarkdownView.LinkHandler?

Expand All @@ -36,13 +58,15 @@ public struct MarkdownContentView: View {
showsTrailingCursor: Bool = false,
isCursorVisible: Bool = true,
baseURL: URL? = nil,
style: MarkdownStyle = .rxCodeChat,
fadeNewText: Bool = false,
onOpenLink: MarkdownView.LinkHandler? = nil
) {
self.text = text
self.showsTrailingCursor = showsTrailingCursor
self.isCursorVisible = isCursorVisible
self.baseURL = baseURL
self.style = style
self.fadeNewText = fadeNewText
self.onOpenLink = onOpenLink
}
Expand All @@ -53,7 +77,7 @@ public struct MarkdownContentView: View {
showsTrailingCursor: showsTrailingCursor,
isCursorVisible: isCursorVisible,
baseURL: baseURL,
style: .rxCodeChat,
style: style,
fadeNewText: fadeNewText,
onOpenLink: onOpenLink
)
Expand Down
61 changes: 27 additions & 34 deletions Packages/Sources/RxCodeChatKit/MessageBubble.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ struct MessageBubble: View {
/// Threshold (character count) for collapsing long text
private static let longTextThreshold = 500

/// Height the collapsed (long) user bubble is clipped to before "Show more".
private static let collapsedMaxHeight: CGFloat = 120

private enum AssistantRenderBlock: Identifiable {
case text(MessageBlock)
case tool(ToolCall)
Expand Down Expand Up @@ -234,24 +237,25 @@ struct MessageBubble: View {
} else {
VStack(alignment: .leading, spacing: 6) {
let isLong = displayText.count > Self.longTextThreshold
ChatTextContentView(
attributed: chipifiedAttributedString(displayText),
size: ClaudeTheme.messageSize(14),
color: ClaudeTheme.userBubbleText,
maximumNumberOfLines: isLong && !isLongTextExpanded ? 5 : nil
let collapsed = isLong && !isLongTextExpanded
MarkdownContentView(
text: markdownUserText(displayText),
style: .rxCodeChatUser
) { url in
// Intercept the synthetic `rxcode-image://<index>` link emitted
// by chipifiedAttributedString and open the matching image in the
// preview sheet rather than the system browser.
// by markdownUserText and open the matching image in the preview
// sheet rather than the system browser.
guard url.scheme == "rxcode-image",
let index = Int(url.host ?? ""),
let path = imagePath(forChipIndex: index) else {
return false
return .systemAction
}
previewImagePath = path
return true
return .handled
}
.fixedSize(horizontal: false, vertical: true)
.frame(maxHeight: collapsed ? Self.collapsedMaxHeight : nil, alignment: .topLeading)
.clipped()
.fixedSize(horizontal: false, vertical: !collapsed)
if isLong {
Button {
withAnimation(.easeInOut(duration: 0.2)) {
Expand Down Expand Up @@ -660,32 +664,21 @@ struct MessageBubble: View {
return result
}

/// Renders `[Image\d+]` tokens with accent-tinted chip styling. The same tokens
/// are inserted into the input bar by `WindowState.insertImageToken` and drawn
/// with a rounded background there via `ChipLayoutManager`; this mirrors that
/// treatment in the sent user bubble.
///
/// Each chip also gets a `rxcode-image://<index>` link attribute; an
/// `environment(\.openURL, ...)` handler on the Text intercepts the tap and
/// opens the corresponding image in `MessageImagePreviewSheet`. The index
/// matches `WindowState.imageIndex(for:)` — 1-based, image-only.
private func chipifiedAttributedString(_ text: String) -> AttributedString {
var attr = AttributedString(text)
/// Prepares user-message text for the markdown renderer by rewriting `[ImageN]`
/// chip tokens into `[ImageN](rxcode-image://N)` links. The renderer then draws
/// them as tappable accents; the `rxcode-image` scheme is intercepted by the
/// bubble's link handler to open the image preview rather than the browser. The
/// same `[ImageN]` tokens are inserted into the input bar by
/// `WindowState.insertImageToken`; the index matches `WindowState.imageIndex(for:)`
/// — 1-based, image-only.
private func markdownUserText(_ text: String) -> String {
let ns = text as NSString
let fullRange = NSRange(location: 0, length: ns.length)
Self.imageChipRegex.enumerateMatches(in: text, range: fullRange) { match, _, _ in
guard let m = match,
let range = Range(m.range, in: attr),
m.numberOfRanges >= 2,
let indexRange = Range(m.range(at: 1), in: text),
let index = Int(text[indexRange]) else { return }
attr[range].backgroundColor = ClaudeTheme.accent.opacity(0.22)
attr[range].foregroundColor = ClaudeTheme.accent
attr[range].font = .system(size: ClaudeTheme.messageSize(13), weight: .medium)
attr[range].link = URL(string: "rxcode-image://\(index)")
attr[range].underlineStyle = nil
}
return attr
return Self.imageChipRegex.stringByReplacingMatches(
in: text,
range: fullRange,
withTemplate: "[Image$1](rxcode-image://$1)"
)
}

/// Resolve `[ImageN]` chip index (1-based) to a concrete image file path.
Expand Down
12 changes: 10 additions & 2 deletions Packages/Sources/RxCodeChatKit/PlanLogic.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,18 @@ public enum PlanLogic {

public static func isPlanFileWrite(_ toolCall: ToolCall) -> Bool {
guard toolCall.name.lowercased() == "write",
let path = toolCall.input["file_path"]?.stringValue,
path.hasSuffix(".md") else {
let path = toolCall.input["file_path"]?.stringValue else {
return false
}
return isPlanFilePath(path)
}

/// True when `path` points at a Claude plan markdown file living under a
/// `.../.claude/plans/` (or `.../claude/plans/`) directory. These plan
/// documents are planning scaffolding, not real project edits, so the
/// thread changes list excludes them.
public static func isPlanFilePath(_ path: String) -> Bool {
guard path.hasSuffix(".md") else { return false }
return path.contains("/.claude/plans/") || path.contains("/claude/plans/")
}

Expand Down
13 changes: 8 additions & 5 deletions Packages/Sources/RxCodeCore/Hooks/HookController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,10 @@ public protocol HookController: AnyObject {
func completeCard(_ handle: HookCardHandle, sessionKey: String, result: String, isError: Bool)

/// Persist a session's "last hook" so the synthetic card can be rebuilt on
/// reload (hook cards never reach the CLI transcript).
func persistHookStatus(sessionKey: String, toolId: String, name: String, trigger: String, output: String, isError: Bool)
/// reload (hook cards never reach the CLI transcript). Pass `isComplete:
/// false` at insert time for a long-running hook so an in-progress card
/// survives a reload; call again with `isComplete: true` on completion.
func persistHookStatus(sessionKey: String, toolId: String, name: String, trigger: String, output: String, isError: Bool, isComplete: Bool)

/// Enabled user hook profiles for a project + trigger, loading from disk on
/// first access.
Expand All @@ -42,9 +44,9 @@ public protocol HookController: AnyObject {

/// 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?
/// Resolve a stored hook model selection into the provider/model pair used
/// for a spawned agent thread. Empty selections inherit the reviewed thread.
func resolveAgentModelSelection(storedModel: String?, fallbackSessionId: String) -> (provider: AgentProvider, model: String)?
/// Distinct paths of files edited during the thread.
func changedFilePaths(sessionId: String) -> [String]
/// The first user prompt text of a thread, if any.
Expand All @@ -60,6 +62,7 @@ public protocol HookController: AnyObject {
projectId: UUID,
parentThreadId: String,
label: String,
agentProvider: AgentProvider?,
model: String?,
prompt: String,
timeoutSeconds: TimeInterval
Expand Down
9 changes: 8 additions & 1 deletion Packages/Sources/RxCodeCore/Hooks/HookPayloads.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,21 +91,28 @@ public struct SessionEndPayload: Codable, Sendable {
/// The most recent assistant text, captured at dispatch time for the
/// response-complete notification body fallback.
public let lastAssistantText: String
/// True when the thread still had user messages queued at the moment it
/// stopped — captured synchronously before the auto-flush pops one. Stop
/// hooks that act on the change (code review, commit & push) defer while
/// this is true so they only run once the queue has fully drained.
public let hasQueuedFollowups: Bool

public init(
project: Project,
sessionKey: String,
sessionId: String,
reason: SessionEndReason,
turnDidError: Bool,
lastAssistantText: String
lastAssistantText: String,
hasQueuedFollowups: Bool = false
) {
self.project = project
self.sessionKey = sessionKey
self.sessionId = sessionId
self.reason = reason
self.turnDidError = turnDidError
self.lastAssistantText = lastAssistantText
self.hasQueuedFollowups = hasQueuedFollowups
}
}

Expand Down
5 changes: 3 additions & 2 deletions Packages/Sources/RxCodeCore/Models/HookProfile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,9 @@ public enum HookAction: String, Codable, Sendable, CaseIterable, Hashable {

/// Per-hook configuration for the `.codeReview` action.
public struct CodeReviewConfig: Codable, Sendable, Hashable {
/// Model id for the review thread. Empty/`nil` ⇒ inherit the reviewed
/// thread's model.
/// Provider-qualified model key for the review thread (`<provider>:<model>`).
/// Empty/`nil` ⇒ inherit the reviewed thread's model. Older bare model ids
/// are still accepted by the app and resolved against the available model list.
public var model: String?
/// Optional extra guidance appended to the reviewer's prompt.
public var instructions: String?
Expand Down
11 changes: 11 additions & 0 deletions Packages/Sources/RxCodeCore/Models/HookStatusRecord.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import SwiftData
/// 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.
///
/// The row is written at *insert* time (in-progress) for long-running hooks
/// like code review, so the card survives a reload that happens mid-run, and
/// updated again on completion.
@Model
public final class HookStatusRecord {
@Attribute(.unique) public var sessionId: String
Expand All @@ -18,6 +22,11 @@ public final class HookStatusRecord {
public var trigger: String
public var output: String
public var isError: Bool
/// False while the hook card is still running (spinner). Defaulted `true`
/// so existing rows migrate as already-finished. An in-progress row is
/// rebuilt as a running card and finalized on completion — or, if the app
/// closed mid-hook, swept to an "interrupted" state on next launch.
public var isComplete: Bool = true
public var updatedAt: Date

public init(
Expand All @@ -27,6 +36,7 @@ public final class HookStatusRecord {
trigger: String,
output: String,
isError: Bool,
isComplete: Bool = true,
updatedAt: Date = .now
) {
self.sessionId = sessionId
Expand All @@ -35,6 +45,7 @@ public final class HookStatusRecord {
self.trigger = trigger
self.output = output
self.isError = isError
self.isComplete = isComplete
self.updatedAt = updatedAt
}
}
Loading
Loading