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
115 changes: 86 additions & 29 deletions Packages/Sources/RxCodeChatKit/AtFileSearchBar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,40 +3,38 @@ import RxCodeCore

#if os(macOS)

// MARK: - @ File Search Popup

struct AtFilePopup: View {
let entries: [AtFileEntry]
let onSelect: (String) -> Void
// MARK: - @ Mention Popup (Shortcuts + Files)

/// Popup shown when the user types `@` in the chat input. Lists matching
/// shortcuts on top and project files below. `selectedIndex` is a single flat
/// index spanning both sections: `0..<shortcuts.count` selects a shortcut,
/// `shortcuts.count..<(shortcuts.count + files.count)` selects a file.
struct AtMentionPopup: View {
let shortcuts: [ChatShortcut]
let files: [AtFileEntry]
let onSelectShortcut: (ChatShortcut) -> Void
let onSelectFile: (String) -> Void
@Binding var selectedIndex: Int

var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Header
HStack {
Image(systemName: "doc.text.magnifyingglass")
.font(.system(size: ClaudeTheme.size(10)))
.foregroundStyle(ClaudeTheme.textTertiary)
Text("File Search", bundle: .module)
.font(.system(size: ClaudeTheme.size(11), weight: .medium))
.foregroundStyle(ClaudeTheme.textTertiary)
Spacer()
Text("\(entries.count)")
.font(.system(size: ClaudeTheme.size(10)))
.foregroundStyle(ClaudeTheme.textTertiary)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)

Divider()
.foregroundStyle(ClaudeTheme.borderSubtle)

ScrollViewReader { proxy in
ScrollView {
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(entries.enumerated()), id: \.element.id) { index, entry in
fileRowButton(entry, isSelected: index == selectedIndex)
.id(index)
if !shortcuts.isEmpty {
sectionHeader(icon: "bolt.fill", title: "Shortcuts", count: shortcuts.count)
ForEach(Array(shortcuts.enumerated()), id: \.element.id) { index, shortcut in
shortcutRowButton(shortcut, isSelected: index == selectedIndex)
.id(index)
}
}
if !files.isEmpty {
sectionHeader(icon: "doc.text.magnifyingglass", title: "Files", count: files.count)
ForEach(Array(files.enumerated()), id: \.element.id) { index, entry in
let flatIndex = shortcuts.count + index
fileRowButton(entry, isSelected: flatIndex == selectedIndex)
.id(flatIndex)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
Expand All @@ -58,10 +56,70 @@ struct AtFilePopup: View {
.shadow(color: ClaudeTheme.shadowColor, radius: 12, y: -4)
}

@ViewBuilder
private func sectionHeader(icon: String, title: LocalizedStringKey, count: Int) -> some View {
HStack {
Image(systemName: icon)
.font(.system(size: ClaudeTheme.size(10)))
.foregroundStyle(ClaudeTheme.textTertiary)
Text(title, bundle: .module)
.font(.system(size: ClaudeTheme.size(11), weight: .medium))
.foregroundStyle(ClaudeTheme.textTertiary)
Spacer()
Text("\(count)")
.font(.system(size: ClaudeTheme.size(10)))
.foregroundStyle(ClaudeTheme.textTertiary)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(ClaudeTheme.surfaceElevated)
}

@ViewBuilder
private func shortcutRowButton(_ shortcut: ChatShortcut, isSelected: Bool) -> some View {
Button {
onSelectShortcut(shortcut)
} label: {
HStack(spacing: 10) {
Image(systemName: shortcut.isTerminalCommand ? "terminal" : "bolt")
.font(.system(size: ClaudeTheme.size(13)))
.foregroundStyle(isSelected ? ClaudeTheme.accent : ClaudeTheme.textTertiary)
.frame(width: 20)

VStack(alignment: .leading, spacing: 2) {
Text(shortcut.name)
.font(.system(size: ClaudeTheme.size(13), weight: .medium))
.foregroundStyle(isSelected ? ClaudeTheme.accent : ClaudeTheme.textPrimary)

Text(shortcut.message)
.font(.system(size: ClaudeTheme.size(11), design: shortcut.isTerminalCommand ? .monospaced : .default))
.foregroundStyle(ClaudeTheme.textTertiary)
.lineLimit(1)
}

Spacer()

if shortcut.isTerminalCommand {
Text("terminal", bundle: .module)
.font(.system(size: ClaudeTheme.size(9)))
.foregroundStyle(ClaudeTheme.textTertiary)
.padding(.horizontal, 5)
.padding(.vertical, 1)
.background(ClaudeTheme.surfaceSecondary, in: Capsule())
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(isSelected ? ClaudeTheme.accentSubtle : Color.clear)
}

@ViewBuilder
private func fileRowButton(_ entry: AtFileEntry, isSelected: Bool) -> some View {
Button {
onSelect(entry.relativePath)
onSelectFile(entry.relativePath)
} label: {
HStack(spacing: 10) {
Image(systemName: entry.icon)
Expand Down Expand Up @@ -91,7 +149,6 @@ struct AtFilePopup: View {
.padding(.vertical, 8)
.background(isSelected ? ClaudeTheme.accentSubtle : Color.clear)
}

}

// MARK: - AtFileEntry
Expand Down
29 changes: 29 additions & 0 deletions Packages/Sources/RxCodeChatKit/ChatMessageListView.swift
Original file line number Diff line number Diff line change
@@ -1,16 +1,35 @@
import SwiftUI
import RxCodeCore

/// Name of the coordinate space spanning the chat scroll content. Containers
/// (`MessageListView`, `MobileChatView`) declare it on their scroll content so
/// message rows and the dynamic tail spacer can be measured in a common space.
public nonisolated let chatContentCoordinateSpace = "rxcode.chat.content"

extension EnvironmentValues {
/// The message whose on-screen geometry the container wants reported back —
/// the latest user message, used to size the dynamic tail spacer.
@Entry public var chatTrackedMessageID: UUID?
/// Callback invoked with the tracked message's `minY` in
/// `chatContentCoordinateSpace` whenever it changes.
@Entry public var chatTrackedMessageGeometry: (CGFloat) -> Void = { _ in }
}

public struct ChatMessageListView: View {
private let messages: [ChatMessage]
private let transientGroupMinSize: Int

@Environment(\.chatTrackedMessageID) private var trackedMessageID
@Environment(\.chatTrackedMessageGeometry) private var trackedMessageGeometry

public init(messages: [ChatMessage], transientGroupMinSize: Int = 2) {
self.messages = messages
self.transientGroupMinSize = transientGroupMinSize
}

public var body: some View {
let tracked = trackedMessageID
let report = trackedMessageGeometry
ForEach(chatMessageGroups(messages, minGroupSize: transientGroupMinSize)) { group in
if group.isTransientGroup {
ChatTransientGroupSummaryView(messages: group.messages)
Expand All @@ -19,6 +38,16 @@ public struct ChatMessageListView: View {
.chatMessageListRowStyle()
} else if let message = group.messages.first {
ChatMessageBubble(message: message)
// Report the tracked (latest user) message's position so the
// container can size the tail spacer. A true no-op for every
// other row: the transform short-circuits to a constant.
.onGeometryChange(for: CGFloat.self) { proxy in
message.id == tracked
? proxy.frame(in: .named(chatContentCoordinateSpace)).minY
: 0
} action: { newValue in
if message.id == tracked { report(newValue) }
}
.id(message.id)
.transition(messageFadeTransition(role: message.role))
.chatMessageListRowStyle()
Expand Down
57 changes: 2 additions & 55 deletions Packages/Sources/RxCodeChatKit/ChatView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import RxCodeCore
public struct ChatView<InputAccessory: View, BottomAccessory: View, AboveInputAccessory: View>: View {
@Environment(WindowState.self) private var windowState
@Environment(ChatBridge.self) private var chatBridge
@State private var shortcuts: [ChatShortcut] = []

private let inputAccessory: InputAccessory
private let bottomAccessory: BottomAccessory
Expand Down Expand Up @@ -39,9 +38,7 @@ public struct ChatView<InputAccessory: View, BottomAccessory: View, AboveInputAc
aboveInputAccessory

InputBarView(accessory: inputAccessory) {
if windowState.selectedProject != nil && !shortcuts.isEmpty {
shortcutBar
}
EmptyView()
}

bottomAccessory
Expand All @@ -59,12 +56,6 @@ public struct ChatView<InputAccessory: View, BottomAccessory: View, AboveInputAc
Task { await chatBridge.cancelStreaming() }
return .handled
}
.onReceive(NotificationCenter.default.publisher(for: .chatShortcutsDidChange)) { _ in
shortcuts = ChatShortcutRegistry.currentShortcuts
}
.onAppear {
shortcuts = ChatShortcutRegistry.currentShortcuts
}
}

// MARK: - Empty State
Expand All @@ -91,9 +82,7 @@ public struct ChatView<InputAccessory: View, BottomAccessory: View, AboveInputAc
aboveInputAccessory

InputBarView(accessory: inputAccessory) {
if windowState.selectedProject != nil && !shortcuts.isEmpty {
shortcutBar
}
EmptyView()
}

bottomAccessory
Expand All @@ -105,48 +94,6 @@ public struct ChatView<InputAccessory: View, BottomAccessory: View, AboveInputAc
}
}

// MARK: - Shortcut Bar

private var shortcutBar: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(shortcuts) { shortcut in
Button {
executeShortcut(shortcut)
} label: {
HStack(spacing: 5) {
if shortcut.isTerminalCommand {
Image(systemName: "terminal").font(.system(size: ClaudeTheme.size(10), weight: .medium))
}
Text(shortcut.name).font(.system(size: ClaudeTheme.size(12), weight: .medium))
}
.foregroundStyle(ClaudeTheme.accent)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(ClaudeTheme.accentSubtle, in: Capsule())
.overlay(Capsule().strokeBorder(ClaudeTheme.accent.opacity(0.35), lineWidth: 1))
}
.buttonStyle(.plain)
.help(shortcut.isTerminalCommand ? "⌨ \(shortcut.message)" : shortcut.message)
.disabled(chatBridge.isStreaming)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 6)
}
.background(ClaudeTheme.background)
}

private func executeShortcut(_ shortcut: ChatShortcut) {
guard !chatBridge.isStreaming else { return }
if shortcut.isTerminalCommand {
Task { await chatBridge.runTerminalCommand(shortcut.message) }
} else {
windowState.inputText = shortcut.message
Task { await chatBridge.send() }
}
}

// MARK: - Messages

private var messageScrollView: some View {
Expand Down
Loading