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
7 changes: 7 additions & 0 deletions Packages/Sources/RxCodeChatKit/ChatMessageListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,16 @@ public struct ChatMessageListView: View {

extension View {
func chatMessageListRowStyle() -> some View {
#if os(macOS)
padding(EdgeInsets(top: 8, leading: 20, bottom: 24, trailing: 20))
.listRowInsets(EdgeInsets(top: 8, leading: 20, bottom: 24, trailing: 20))
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
#else
listRowInsets(EdgeInsets(top: 8, leading: 20, bottom: 24, trailing: 20))
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
#endif
}
}

Expand Down
1 change: 1 addition & 0 deletions Packages/Sources/RxCodeChatKit/MarkdownView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ struct MarkdownContentView: View {

var body: some View {
StructuredText(markdown: renderedMarkdown)
.id(renderedMarkdown)
.font(.system(size: 15))
.tint(ClaudeTheme.accent)
.textual.inlineStyle(
Expand Down
30 changes: 23 additions & 7 deletions Packages/Sources/RxCodeChatKit/MessageBubble.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ struct MessageBubble: View {
} else {
// Assistant message: render blocks in order
let renderBlocks = assistantRenderBlocks()
let cursorBlockId = streamingCursorBlockId(in: renderBlocks)
// While the model is paused on an undecided ExitPlanMode in this
// same message, sibling tools without results are effectively
// suspended — not running. Drop the streaming flag for those so
Expand All @@ -81,7 +82,11 @@ struct MessageBubble: View {
switch block {
case .text(let textBlock):
if let text = textBlock.text, !text.isEmpty {
assistantTextBubble(text: text, blockId: textBlock.id)
assistantTextBubble(
text: text,
blockId: textBlock.id,
showsCursor: textBlock.id == cursorBlockId
)
}
case .tool(let toolCall):
if toolCall.name == "AskUserQuestion" {
Expand Down Expand Up @@ -290,12 +295,8 @@ struct MessageBubble: View {

// MARK: - Assistant Text Bubble

private func assistantTextBubble(text: String, blockId: String) -> some View {
let isLastBlock = message.blocks.last?.isText == true
&& message.blocks.last?.text == text
let showsCursor = message.isStreaming && isLastBlock

return MarkdownContentView(
private func assistantTextBubble(text: String, blockId: String, showsCursor: Bool) -> some View {
MarkdownContentView(
text: text,
showsTrailingCursor: showsCursor,
isCursorVisible: cursorVisible
Expand All @@ -321,6 +322,21 @@ struct MessageBubble: View {
.accessibilityLabel("Assistant: \(text)")
}

private func streamingCursorBlockId(in renderBlocks: [AssistantRenderBlock]) -> String? {
guard message.isStreaming,
latestStreamingAssistantMessageId == message.id,
case .text(let textBlock) = renderBlocks.last,
textBlock.text?.isEmpty == false
else { return nil }
return textBlock.id
}

private var latestStreamingAssistantMessageId: UUID? {
chatBridge.messages.last {
$0.role == .assistant && $0.isStreaming
}?.id
}

// MARK: - Copy Button

@ViewBuilder
Expand Down
75 changes: 38 additions & 37 deletions Packages/Sources/RxCodeChatKit/MessageListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,51 +23,52 @@ struct MessageListView: View {

var body: some View {
ScrollViewReader { proxy in
List {
messageRows(settledItems[...])

// Streaming view is outside VStack — text deltas don't affect settled layout
if !windowState.focusMode {
StreamingMessageView {
rebuildSettledItems()
if anchor.isNearBottom { scrollToBottomDebounced(proxy) }
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
messageRows(settledItems[...])

// Streaming view is outside the settled rows — text deltas don't
// affect settled layout.
if !windowState.focusMode {
StreamingMessageView {
rebuildSettledItems()
if anchor.isNearBottom { scrollToBottomDebounced(proxy) }
}
// Suppress layout animations when switching sessions so the pulse indicator
// doesn't visually jump as StreamingMessageView changes height.
.animation(.none, value: windowState.currentSessionId)
.chatMessageListRowStyle()
}
// Suppress layout animations when switching sessions so the pulse indicator
// doesn't visually jump as StreamingMessageView changes height.
.animation(.none, value: windowState.currentSessionId)
.chatMessageListRowStyle()
}

if chatBridge.isStreaming && !chatBridge.hasPendingPlanDecision {
// Hide the spinner/dots while the CLI is paused waiting on the
// user's plan decision — the model isn't actually generating
// tokens, so showing "in progress" is misleading.
HStack(alignment: .top, spacing: 0) {
StreamingIndicatorView(
isThinking: chatBridge.isThinking,
startDate: chatBridge.streamingStartDate,
agentProvider: chatBridge.agentProvider,
outputTokens: chatBridge.liveOutputTokens
)
Spacer(minLength: 40)
if chatBridge.isStreaming && !chatBridge.hasPendingPlanDecision {
// Hide the spinner/dots while the CLI is paused waiting on the
// user's plan decision — the model isn't actually generating
// tokens, so showing "in progress" is misleading.
HStack(alignment: .top, spacing: 0) {
StreamingIndicatorView(
isThinking: chatBridge.isThinking,
startDate: chatBridge.streamingStartDate,
agentProvider: chatBridge.agentProvider,
outputTokens: chatBridge.liveOutputTokens
)
Spacer(minLength: 40)
}
.chatMessageListRowStyle()
}

if !chatBridge.isStreaming && !settledItems.isEmpty {
WebPreviewButton(messages: settledItems)
.id("web-preview")
.chatMessageListRowStyle()
}
.chatMessageListRowStyle()
}

if !chatBridge.isStreaming && !settledItems.isEmpty {
WebPreviewButton(messages: settledItems)
.id("web-preview")
Color.clear.frame(height: 1)
.id(Self.bottomAnchorID)
.chatMessageListRowStyle()
}

Color.clear.frame(height: 1)
.id(Self.bottomAnchorID)
.chatMessageListRowStyle()
.frame(maxWidth: .infinity, alignment: .leading)
}
.listStyle(.plain)
.contentMargins(.top, 16, for: .scrollContent)
.scrollContentBackground(.hidden)
.environment(\.defaultMinListRowHeight, 0)
.opacity(isSessionReady ? 1 : 0)
.defaultScrollAnchor(.bottom)
.onScrollGeometryChange(for: ScrollSample.self) { geo in
Expand Down
6 changes: 3 additions & 3 deletions Packages/Sources/RxCodeCore/Views/SessionSidebarRow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public struct SessionSidebarRow: View {
VStack(alignment: .leading, spacing: 3) {
TypewriterTitleText(title: title.prefix(1).uppercased() + title.dropFirst())
.font(.system(size: ClaudeTheme.size(13)))
.foregroundStyle(.primary.opacity(0.8))
.foregroundStyle(ClaudeTheme.textPrimary)
.lineLimit(1)
.contentTransition(.opacity)
.animation(.easeInOut(duration: 0.25), value: title)
Expand All @@ -42,12 +42,12 @@ public struct SessionSidebarRow: View {

Text("·")
.font(.system(size: ClaudeTheme.size(10)))
.foregroundStyle(.tertiary)
.foregroundStyle(ClaudeTheme.textTertiary)
}

Text(Self.compactElapsed(since: updatedAt))
.font(.system(size: ClaudeTheme.size(11)))
.foregroundStyle(.tertiary)
.foregroundStyle(ClaudeTheme.textSecondary)
}
}

Expand Down
104 changes: 104 additions & 0 deletions Packages/Sources/RxCodeSync/Protocol/Payload.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ public enum Payload: Sendable {
case planDecision(PlanDecisionPayload)
case branchOpRequest(BranchOpRequestPayload)
case branchOpResult(BranchOpResultPayload)
case folderTreeRequest(FolderTreeRequestPayload)
case folderTreeResult(FolderTreeResultPayload)
case createProjectRequest(CreateProjectRequestPayload)
case createProjectResult(CreateProjectResultPayload)
case ping(PingPayload)
case pong(PongPayload)
case unknown(type: String)
Expand Down Expand Up @@ -311,6 +315,94 @@ public struct BranchOpResultPayload: Codable, Sendable {
}
}

public struct RemoteFolderNode: Codable, Sendable, Identifiable, Equatable {
public var id: String { path }

public let name: String
public let path: String
public let isSelectable: Bool
public let children: [RemoteFolderNode]

public init(name: String, path: String, isSelectable: Bool = true, children: [RemoteFolderNode] = []) {
self.name = name
self.path = path
self.isSelectable = isSelectable
self.children = children
}
}

public struct FolderTreeRequestPayload: Codable, Sendable {
public let clientRequestID: UUID
/// `nil` asks the desktop for the picker roots. Non-nil asks for that
/// folder's immediate children.
public let path: String?
public let depth: Int
public let includeHidden: Bool

public init(
clientRequestID: UUID = UUID(),
path: String? = nil,
depth: Int = 1,
includeHidden: Bool = false
) {
self.clientRequestID = clientRequestID
self.path = path
self.depth = depth
self.includeHidden = includeHidden
}
}

public struct FolderTreeResultPayload: Codable, Sendable {
public let clientRequestID: UUID
public let requestedPath: String?
public let ok: Bool
public let root: RemoteFolderNode?
public let errorMessage: String?

public init(
clientRequestID: UUID,
requestedPath: String?,
ok: Bool,
root: RemoteFolderNode? = nil,
errorMessage: String? = nil
) {
self.clientRequestID = clientRequestID
self.requestedPath = requestedPath
self.ok = ok
self.root = root
self.errorMessage = errorMessage
}
}

public struct CreateProjectRequestPayload: Codable, Sendable {
public let clientRequestID: UUID
public let path: String

public init(clientRequestID: UUID = UUID(), path: String) {
self.clientRequestID = clientRequestID
self.path = path
}
}

public struct CreateProjectResultPayload: Codable, Sendable {
public let clientRequestID: UUID
public let ok: Bool
public let project: Project?
public let errorMessage: String?

public init(
clientRequestID: UUID,
ok: Bool,
project: Project? = nil,
errorMessage: String? = nil
) {
self.clientRequestID = clientRequestID
self.ok = ok
self.project = project
self.errorMessage = errorMessage
}
}

public struct MobileBranchBriefing: Codable, Sendable, Identifiable, Equatable {
public var id: String { "\(projectId.uuidString)::\(branch)" }

Expand Down Expand Up @@ -1081,6 +1173,10 @@ extension Payload: Codable {
case planDecision = "plan_decision"
case branchOpRequest = "branch_op_request"
case branchOpResult = "branch_op_result"
case folderTreeRequest = "folder_tree_request"
case folderTreeResult = "folder_tree_result"
case createProjectRequest = "create_project_request"
case createProjectResult = "create_project_result"
case ping
case pong
}
Expand Down Expand Up @@ -1119,6 +1215,10 @@ extension Payload: Codable {
case .planDecision: self = .planDecision(try container.decode(PlanDecisionPayload.self, forKey: .data))
case .branchOpRequest: self = .branchOpRequest(try container.decode(BranchOpRequestPayload.self, forKey: .data))
case .branchOpResult: self = .branchOpResult(try container.decode(BranchOpResultPayload.self, forKey: .data))
case .folderTreeRequest: self = .folderTreeRequest(try container.decode(FolderTreeRequestPayload.self, forKey: .data))
case .folderTreeResult: self = .folderTreeResult(try container.decode(FolderTreeResultPayload.self, forKey: .data))
case .createProjectRequest: self = .createProjectRequest(try container.decode(CreateProjectRequestPayload.self, forKey: .data))
case .createProjectResult: self = .createProjectResult(try container.decode(CreateProjectResultPayload.self, forKey: .data))
case .ping: self = .ping(try container.decode(PingPayload.self, forKey: .data))
case .pong: self = .pong(try container.decode(PongPayload.self, forKey: .data))
}
Expand Down Expand Up @@ -1153,6 +1253,10 @@ extension Payload: Codable {
case .planDecision(let p): try container.encode(TypeKey.planDecision.rawValue, forKey: .type); try container.encode(p, forKey: .data)
case .branchOpRequest(let p): try container.encode(TypeKey.branchOpRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data)
case .branchOpResult(let p): try container.encode(TypeKey.branchOpResult.rawValue, forKey: .type); try container.encode(p, forKey: .data)
case .folderTreeRequest(let p): try container.encode(TypeKey.folderTreeRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data)
case .folderTreeResult(let p): try container.encode(TypeKey.folderTreeResult.rawValue, forKey: .type); try container.encode(p, forKey: .data)
case .createProjectRequest(let p): try container.encode(TypeKey.createProjectRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data)
case .createProjectResult(let p): try container.encode(TypeKey.createProjectResult.rawValue, forKey: .type); try container.encode(p, forKey: .data)
case .ping(let p): try container.encode(TypeKey.ping.rawValue, forKey: .type); try container.encode(p, forKey: .data)
case .pong(let p): try container.encode(TypeKey.pong.rawValue, forKey: .type); try container.encode(p, forKey: .data)
case .unknown(let type): try container.encode(type, forKey: .type)
Expand Down
Loading