diff --git a/Packages/Sources/RxCodeChatKit/AtFileSearchBar.swift b/Packages/Sources/RxCodeChatKit/AtFileSearchBar.swift index 138b417..9c47c06 100644 --- a/Packages/Sources/RxCodeChatKit/AtFileSearchBar.swift +++ b/Packages/Sources/RxCodeChatKit/AtFileSearchBar.swift @@ -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.. 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) @@ -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) @@ -91,7 +149,6 @@ struct AtFilePopup: View { .padding(.vertical, 8) .background(isSelected ? ClaudeTheme.accentSubtle : Color.clear) } - } // MARK: - AtFileEntry diff --git a/Packages/Sources/RxCodeChatKit/ChatMessageListView.swift b/Packages/Sources/RxCodeChatKit/ChatMessageListView.swift index 8e4678a..330c651 100644 --- a/Packages/Sources/RxCodeChatKit/ChatMessageListView.swift +++ b/Packages/Sources/RxCodeChatKit/ChatMessageListView.swift @@ -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) @@ -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() diff --git a/Packages/Sources/RxCodeChatKit/ChatView.swift b/Packages/Sources/RxCodeChatKit/ChatView.swift index 73efc84..1ccfc60 100644 --- a/Packages/Sources/RxCodeChatKit/ChatView.swift +++ b/Packages/Sources/RxCodeChatKit/ChatView.swift @@ -7,7 +7,6 @@ import RxCodeCore public struct ChatView: 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 @@ -39,9 +38,7 @@ public struct ChatView: View { @State private var textPreviewAttachment: Attachment? @State private var imagePreviewAttachment: Attachment? @State private var isDragOver = false - @State private var showAtFilePopup = false - @State private var atFileSelectedIndex = 0 + @State private var showAtPopup = false + @State private var atSelectedIndex = 0 + @State private var shortcuts: [ChatShortcut] = [] @State private var historyIndex: Int = -1 @State private var textFieldLayoutID = 0 @State private var measuredInputHeight: CGFloat = 20 @@ -100,16 +101,19 @@ struct InputBarView: View { if showSlashPopup && !slashFilteredCommands.isEmpty { SlashCommandPopup( query: slashQuery, + agent: chatBridge.agentProvider, onSelect: { cmd in selectSlashCommand(cmd) }, selectedIndex: $slashSelectedIndex ) .transition(.offset(y: 10).combined(with: .opacity)) } - if showAtFilePopup && !atFileFilteredEntries.isEmpty { - AtFilePopup( - entries: atFileFilteredEntries, - onSelect: { relativePath in selectAtFile(relativePath) }, - selectedIndex: $atFileSelectedIndex + if showAtPopup && atCombinedCount > 0 { + AtMentionPopup( + shortcuts: atShortcutMatches, + files: atFileFilteredEntries, + onSelectShortcut: { shortcut in selectShortcut(shortcut) }, + onSelectFile: { relativePath in selectAtFile(relativePath) }, + selectedIndex: $atSelectedIndex ) .transition(.offset(y: 10).combined(with: .opacity)) } @@ -145,6 +149,10 @@ struct InputBarView: View { if let path = windowState.selectedProject?.path { AtFileSearch.prefetch(projectPath: path) } + shortcuts = ChatShortcutRegistry.currentShortcuts + } + .onReceive(NotificationCenter.default.publisher(for: .chatShortcutsDidChange)) { _ in + shortcuts = ChatShortcutRegistry.currentShortcuts } .onChange(of: windowState.selectedProject?.path) { _, newPath in if let path = newPath { @@ -360,16 +368,16 @@ struct InputBarView: View { if shouldShowSlash { slashSelectedIndex = 0 } let shouldShowAt = !shouldShowSlash && hasActiveAtQuery(in: newValue) - if shouldShowAt != showAtFilePopup { - withAnimation(.easeOut(duration: 0.15)) { showAtFilePopup = shouldShowAt } + if shouldShowAt != showAtPopup { + withAnimation(.easeOut(duration: 0.15)) { showAtPopup = shouldShowAt } } - if shouldShowAt { atFileSelectedIndex = 0 } + if shouldShowAt { atSelectedIndex = 0 } } private func handleUpArrow() -> KeyPress.Result { - if showAtFilePopup && !atFileFilteredEntries.isEmpty { - let count = atFileFilteredEntries.count - atFileSelectedIndex = (atFileSelectedIndex - 1 + count) % count + if showAtPopup && atCombinedCount > 0 { + let count = atCombinedCount + atSelectedIndex = (atSelectedIndex - 1 + count) % count return .handled } if showSlashPopup && !slashFilteredCommands.isEmpty { @@ -389,9 +397,9 @@ struct InputBarView: View { } private func handleDownArrow() -> KeyPress.Result { - if showAtFilePopup && !atFileFilteredEntries.isEmpty { - let count = atFileFilteredEntries.count - atFileSelectedIndex = (atFileSelectedIndex + 1) % count + if showAtPopup && atCombinedCount > 0 { + let count = atCombinedCount + atSelectedIndex = (atSelectedIndex + 1) % count return .handled } if showSlashPopup && !slashFilteredCommands.isEmpty { @@ -414,9 +422,8 @@ struct InputBarView: View { } private func handleTab() -> KeyPress.Result { - if showAtFilePopup && !atFileFilteredEntries.isEmpty { - let entries = atFileFilteredEntries - if atFileSelectedIndex < entries.count { selectAtFile(entries[atFileSelectedIndex].relativePath) } + if showAtPopup && atCombinedCount > 0 { + selectAtMention(at: atSelectedIndex) return .handled } guard showSlashPopup && !slashFilteredCommands.isEmpty else { return .ignored } @@ -572,8 +579,8 @@ struct InputBarView: View { } private func handleEscapeKey() -> Bool { - if showAtFilePopup { - withAnimation(.easeOut(duration: 0.15)) { showAtFilePopup = false } + if showAtPopup { + withAnimation(.easeOut(duration: 0.15)) { showAtPopup = false } return true } if showSlashPopup { @@ -597,7 +604,7 @@ struct InputBarView: View { } private var slashFilteredCommands: [SlashCommand] { - SlashCommandRegistry.filtered(by: slashQuery) + SlashCommandRegistry.filtered(by: slashQuery, agent: chatBridge.agentProvider) } private var userMessageHistory: [String] { @@ -617,6 +624,19 @@ struct InputBarView: View { return AtFileSearch.search(query: atFileQuery, projectPath: project.path) } + private var atShortcutMatches: [ChatShortcut] { + let query = atFileQuery.lowercased() + guard !query.isEmpty else { return Array(shortcuts.prefix(20)) } + return shortcuts.filter { + $0.name.lowercased().contains(query) || $0.message.lowercased().contains(query) + } + } + + /// Total rows in the unified `@` popup — shortcuts followed by files. + private var atCombinedCount: Int { + atShortcutMatches.count + atFileFilteredEntries.count + } + private func selectSlashCommand(_ cmd: SlashCommand) { withAnimation(.easeOut(duration: 0.15)) { showSlashPopup = false } if cmd.acceptsInput && !cmd.isInteractive { @@ -628,7 +648,7 @@ struct InputBarView: View { } private func selectAtFile(_ relativePath: String) { - withAnimation(.easeOut(duration: 0.15)) { showAtFilePopup = false } + withAnimation(.easeOut(duration: 0.15)) { showAtPopup = false } var text = windowState.inputText if let atRange = text.range(of: "@", options: .backwards) { text.replaceSubrange(atRange.lowerBound..., with: "@\(relativePath) ") @@ -636,6 +656,36 @@ struct InputBarView: View { windowState.inputText = text } + /// Dispatches a flat `@` popup index to the shortcut or file at that row. + private func selectAtMention(at index: Int) { + let shortcutMatches = atShortcutMatches + if index < shortcutMatches.count { + selectShortcut(shortcutMatches[index]) + return + } + let fileIndex = index - shortcutMatches.count + let entries = atFileFilteredEntries + if fileIndex >= 0 && fileIndex < entries.count { + selectAtFile(entries[fileIndex].relativePath) + } + } + + private func selectShortcut(_ shortcut: ChatShortcut) { + withAnimation(.easeOut(duration: 0.15)) { showAtPopup = false } + var text = windowState.inputText + if let atRange = text.range(of: "@", options: .backwards) { + text.replaceSubrange(atRange.lowerBound..., with: "") + } + if shortcut.isTerminalCommand { + windowState.inputText = text + guard !chatBridge.isStreaming else { return } + Task { await chatBridge.runTerminalCommand(shortcut.message) } + } else { + // Insert the shortcut's message so the user can review/edit before sending. + windowState.inputText = text + shortcut.message + } + } + private func hasActiveAtQuery(in text: String) -> Bool { guard let atRange = text.range(of: "@", options: .backwards) else { return false } let afterAt = String(text[atRange.upperBound...]) @@ -715,11 +765,8 @@ struct InputBarView: View { } return } - if showAtFilePopup && !atFileFilteredEntries.isEmpty { - let entries = atFileFilteredEntries - if atFileSelectedIndex < entries.count { - selectAtFile(entries[atFileSelectedIndex].relativePath) - } + if showAtPopup && atCombinedCount > 0 { + selectAtMention(at: atSelectedIndex) return } sendMessage() diff --git a/Packages/Sources/RxCodeChatKit/MessageListView.swift b/Packages/Sources/RxCodeChatKit/MessageListView.swift index ba4f7eb..370e65c 100644 --- a/Packages/Sources/RxCodeChatKit/MessageListView.swift +++ b/Packages/Sources/RxCodeChatKit/MessageListView.swift @@ -21,160 +21,223 @@ struct MessageListView: View { @State private var readyTask: Task? @State private var anchor = AutoScrollAnchor() @State private var isSessionReady = false + /// Visible height of the message `List` — drives the dynamic tail spacer. + @State private var viewportHeight: CGFloat = 0 + /// `minY` of the latest user message in `chatContentCoordinateSpace`. + @State private var latestUserMinY: CGFloat = 0 + /// `minY` of the tail spacer in `chatContentCoordinateSpace`. + @State private var tailSpacerMinY: CGFloat = 0 + /// Latest user message id already seen — distinguishes a genuine new send + /// from a session switch / disk load. + @State private var lastTrackedUserID: UUID? + /// Session the `lastTrackedUserID` baseline belongs to. + @State private var pinSessionID: String? private static let log = Logger(subsystem: "com.claudework", category: "MessageListView") private static let bottomAnchorID = "message-list-bottom-anchor" 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) } - } - // Suppress layout animations when switching sessions so the pulse indicator - // doesn't visually jump as StreamingMessageView changes height. - .animation(.none, value: windowState.currentSessionId) - .chatMessageListRowStyle() - } + messageList(proxy: proxy) + } + } - 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() - } + /// The rows inside the `List` — extracted so the type-checker handles the + /// content separately from the long modifier chain in `messageList`. + @ViewBuilder + private var messageListRows: some View { + messageRows(settledItems[...]) + + // Streaming view is outside VStack — text deltas don't affect settled layout + if !windowState.focusMode { + StreamingMessageView { + // While streaming, the dynamic tail spacer keeps the pinned + // question in place — no scroll-to-bottom. + rebuildSettledItems() + } + // 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 && !settledItems.isEmpty { - WebPreviewButton(messages: settledItems) - .id("web-preview") - .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) + } + .chatMessageListRowStyle() + } + + if !chatBridge.isStreaming && !settledItems.isEmpty { + WebPreviewButton(messages: settledItems) + .id("web-preview") + .chatMessageListRowStyle() + } - Color.clear.frame(height: 1) - .id(Self.bottomAnchorID) - .chatMessageListRowStyle() + tailSpacer + } + + /// Dynamic tail spacer: pads the latest turn so the user message can always + /// be scrolled to the very top, and shrinks as the assistant response grows + /// so the pinned message stays put. + private var tailSpacer: some View { + Color.clear + .frame(height: tailSpacerHeight) + .id(Self.bottomAnchorID) + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + .onGeometryChange(for: CGFloat.self) { proxy in + proxy.frame(in: .named(chatContentCoordinateSpace)).minY + } action: { newValue in + updateTailSpacerMinY(newValue) } - .listStyle(.plain) - .contentMargins(.top, 16, for: .scrollContent) - .scrollContentBackground(.hidden) - .environment(\.defaultMinListRowHeight, 0) - .opacity(isSessionReady ? 1 : 0) - .defaultScrollAnchor(.bottom) - .onScrollGeometryChange(for: ScrollSample.self) { geo in - ScrollSample(contentHeight: geo.contentSize.height, visibleMaxY: geo.visibleRect.maxY) - } action: { _, sample in - // Route the geometry change through AutoScrollAnchor so content - // growth (e.g. an Edit/Bash card expanding) doesn't un-stick the - // anchor — only deliberate user scrolling does. When growth - // happened while anchored, the anchor asks us to scroll. - let decision = anchor.apply(contentHeight: sample.contentHeight, visibleMaxY: sample.visibleMaxY) - if decision == .scrollToBottom { - scrollToBottomDebounced(proxy) + } + + private func messageList(proxy: ScrollViewProxy) -> some View { + let base = List { messageListRows } + .listStyle(.plain) + .contentMargins(.top, 16, for: .scrollContent) + .scrollContentBackground(.hidden) + .environment(\.defaultMinListRowHeight, 0) + .opacity(isSessionReady ? 1 : 0) + .coordinateSpace(.named(chatContentCoordinateSpace)) + .environment(\.chatTrackedMessageID, trackedUserMessageID) + .environment(\.chatTrackedMessageGeometry, updateLatestUserMinY) + .onGeometryChange(for: CGFloat.self) { proxy in + proxy.size.height + } action: { newValue in + viewportHeight = newValue } - } - .task(id: windowState.currentSessionId) { - let sid = windowState.currentSessionId ?? "" - Self.log.info("[MessageList.task] fired sid=\(sid, privacy: .public) bridgeMessages=\(chatBridge.messages.count) isStreaming=\(chatBridge.isStreaming) isLoadingFromDisk=\(chatBridge.isLoadingFromDisk)") - // When the CLI emits its first `system:init` event mid-stream, AppState - // swaps currentSessionId from the local "pending-..." placeholder to - // the real CLI sid. That id change re-fires this task even though the - // user did not switch sessions — fading out here causes a visible blink. - // Detect that case via chatBridge.isStreaming and keep the list visible. - if chatBridge.isStreaming { - rebuildSettledItems() - anchor.resetToBottom() - if !isSessionReady { isSessionReady = true } - Self.log.info("[MessageList.task] streaming-path settled=\(settledItems.count) sid=\(sid, privacy: .public)") - return + let scrolling = base + .onScrollGeometryChange(for: ScrollSample.self) { geo in + ScrollSample(contentHeight: geo.contentSize.height, visibleMaxY: geo.visibleRect.maxY) + } action: { _, sample in + // Route the geometry change through AutoScrollAnchor so content + // growth (e.g. an Edit/Bash card expanding) doesn't un-stick the + // anchor — only deliberate user scrolling does. Suppressed while + // streaming: the dynamic spacer keeps the question pinned. + let decision = anchor.apply(contentHeight: sample.contentHeight, visibleMaxY: sample.visibleMaxY) + if decision == .scrollToBottom && !chatBridge.isStreaming { + scrollToBottomDebounced(proxy) + } } - isSessionReady = false - scrollTask?.cancel() - settleScrollTask?.cancel() - readyTask?.cancel() - rebuildSettledItems() - Self.log.info("[MessageList.task] post-rebuild settled=\(settledItems.count) sid=\(sid, privacy: .public) isLoadingFromDisk=\(chatBridge.isLoadingFromDisk)") - // Skip scroll/fade delay for empty sessions — appear instantly, - // unless we're still loading persisted messages from disk (in which - // case the onChange handler below will fade the list in once messages - // arrive, avoiding the empty → populated "blink"). - guard !settledItems.isEmpty else { - if !chatBridge.isLoadingFromDisk { - isSessionReady = true - Self.log.info("[MessageList.task] empty + not-loading → ready sid=\(sid, privacy: .public)") - } else { - Self.log.info("[MessageList.task] empty + still-loading → waiting for disk sid=\(sid, privacy: .public)") + .onChange(of: trackedUserMessageID) { _, newID in + handleTrackedUserChange(newID, proxy: proxy) + } + .task(id: windowState.currentSessionId) { + await handleSessionTask(proxy: proxy) + } + return scrolling + .onChange(of: chatBridge.isLoadingFromDisk) { _, isLoading in + handleLoadingChange(isLoading, proxy: proxy) + } + .onChange(of: chatBridge.isStreaming) { old, new in + handleStreamingChange(old: old, new: new, proxy: proxy) + } + .onChange(of: isSessionReady) { _, new in + Self.log.info("[MessageList.ready] isSessionReady=\(new) sid=\(windowState.currentSessionId ?? "", privacy: .public) settled=\(settledItems.count)") + } + .onChange(of: settledItems.count) { _, new in + Self.log.info("[MessageList.settled] settled=\(new) sid=\(windowState.currentSessionId ?? "", privacy: .public) isSessionReady=\(isSessionReady) isLoadingFromDisk=\(chatBridge.isLoadingFromDisk)") + } + .overlay { + if settledItems.isEmpty && !chatBridge.isStreaming && windowState.currentSessionId == nil { + EmptySessionView() + .allowsHitTesting(false) } - return } - try? await Task.sleep(for: .milliseconds(16)) // 1 frame: scroll after VStack layout is committed - scrollToBottom(proxy) - // Pre-set isNearBottom so streaming messages that arrive before onScrollGeometryChange - // fires still trigger scrollToBottomDebounced(), keeping the pulse pinned to the bottom. - anchor.resetToBottom() - try? await Task.sleep(for: .milliseconds(32)) // 2 frames: fade-in after scroll settles - withAnimation(.easeIn(duration: 0.15)) { isSessionReady = true } - } - .onChange(of: chatBridge.isLoadingFromDisk) { _, isLoading in - let sid = windowState.currentSessionId ?? "" - Self.log.info("[MessageList.onLoadChange] isLoading=\(isLoading) sid=\(sid, privacy: .public) bridgeMessages=\(chatBridge.messages.count) settled=\(settledItems.count)") - // When a background disk load finishes for a freshly switched session, - // rebuild the settled list and fade in — same sequence as the .task above. - // Rebuild regardless of `isSessionReady`: there is a race where .task - // observes a stale `isLoadingFromDisk == false` (bridge observation - // hasn't propagated yet), sets `isSessionReady = true` via the empty - // early-exit, and then this handler would otherwise skip the rebuild, - // leaving settled items empty even after messages load. - guard !isLoading else { return } + } + + // MARK: - Session lifecycle + + private func handleSessionTask(proxy: ScrollViewProxy) async { + let sid = windowState.currentSessionId ?? "" + Self.log.info("[MessageList.task] fired sid=\(sid, privacy: .public) bridgeMessages=\(chatBridge.messages.count) isStreaming=\(chatBridge.isStreaming) isLoadingFromDisk=\(chatBridge.isLoadingFromDisk)") + // When the CLI emits its first `system:init` event mid-stream, AppState + // swaps currentSessionId from the local "pending-..." placeholder to + // the real CLI sid. That id change re-fires this task even though the + // user did not switch sessions — fading out here causes a visible blink. + // Detect that case via chatBridge.isStreaming and keep the list visible. + if chatBridge.isStreaming { rebuildSettledItems() - Self.log.info("[MessageList.onLoadChange] post-rebuild settled=\(settledItems.count) sid=\(sid, privacy: .public)") - // Fade-in lives on `readyTask` so the content-growth path - // (`scrollToBottomDebounced`, which owns `scrollTask`) cannot cancel it. - readyTask?.cancel() - readyTask = Task { @MainActor in - try? await Task.sleep(for: .milliseconds(16)) - guard !Task.isCancelled else { return } - scrollToBottom(proxy) - anchor.resetToBottom() - guard !isSessionReady else { return } - try? await Task.sleep(for: .milliseconds(32)) - guard !Task.isCancelled else { return } - withAnimation(.easeIn(duration: 0.15)) { isSessionReady = true } - } + anchor.resetToBottom() + syncPinTracking() + if !isSessionReady { isSessionReady = true } + Self.log.info("[MessageList.task] streaming-path settled=\(settledItems.count) sid=\(sid, privacy: .public)") + return } - .onChange(of: chatBridge.isStreaming) { old, new in - // Only update when streaming ends — settled list doesn't change at start, so skip - if old && !new { - rebuildSettledItems() - anchor.resetToBottom() - pinScrollToBottomDuringHandoff(proxy) + isSessionReady = false + scrollTask?.cancel() + settleScrollTask?.cancel() + readyTask?.cancel() + rebuildSettledItems() + syncPinTracking() + Self.log.info("[MessageList.task] post-rebuild settled=\(settledItems.count) sid=\(sid, privacy: .public) isLoadingFromDisk=\(chatBridge.isLoadingFromDisk)") + // Skip scroll/fade delay for empty sessions — appear instantly, + // unless we're still loading persisted messages from disk (in which + // case the onChange handler below will fade the list in once messages + // arrive, avoiding the empty → populated "blink"). + guard !settledItems.isEmpty else { + if !chatBridge.isLoadingFromDisk { + isSessionReady = true + Self.log.info("[MessageList.task] empty + not-loading → ready sid=\(sid, privacy: .public)") + } else { + Self.log.info("[MessageList.task] empty + still-loading → waiting for disk sid=\(sid, privacy: .public)") } + return } - .onChange(of: isSessionReady) { _, new in - Self.log.info("[MessageList.ready] isSessionReady=\(new) sid=\(windowState.currentSessionId ?? "", privacy: .public) settled=\(settledItems.count)") - } - .onChange(of: settledItems.count) { _, new in - Self.log.info("[MessageList.settled] settled=\(new) sid=\(windowState.currentSessionId ?? "", privacy: .public) isSessionReady=\(isSessionReady) isLoadingFromDisk=\(chatBridge.isLoadingFromDisk)") - } - .overlay { - if settledItems.isEmpty && !chatBridge.isStreaming && windowState.currentSessionId == nil { - EmptySessionView() - .allowsHitTesting(false) - } + try? await Task.sleep(for: .milliseconds(16)) // 1 frame: scroll after VStack layout is committed + scrollToBottom(proxy) + anchor.resetToBottom() + try? await Task.sleep(for: .milliseconds(32)) // 2 frames: fade-in after scroll settles + withAnimation(.easeIn(duration: 0.15)) { isSessionReady = true } + } + + private func handleLoadingChange(_ isLoading: Bool, proxy: ScrollViewProxy) { + let sid = windowState.currentSessionId ?? "" + Self.log.info("[MessageList.onLoadChange] isLoading=\(isLoading) sid=\(sid, privacy: .public) bridgeMessages=\(chatBridge.messages.count) settled=\(settledItems.count)") + // When a background disk load finishes for a freshly switched session, + // rebuild the settled list and fade in — same sequence as the .task above. + guard !isLoading else { return } + rebuildSettledItems() + syncPinTracking() + Self.log.info("[MessageList.onLoadChange] post-rebuild settled=\(settledItems.count) sid=\(sid, privacy: .public)") + // Fade-in lives on `readyTask` so the content-growth path + // (`scrollToBottomDebounced`, which owns `scrollTask`) cannot cancel it. + readyTask?.cancel() + readyTask = Task { @MainActor in + try? await Task.sleep(for: .milliseconds(16)) + guard !Task.isCancelled else { return } + scrollToBottom(proxy) + anchor.resetToBottom() + guard !isSessionReady else { return } + try? await Task.sleep(for: .milliseconds(32)) + guard !Task.isCancelled else { return } + withAnimation(.easeIn(duration: 0.15)) { isSessionReady = true } } + } + + private func handleStreamingChange(old: Bool, new: Bool, proxy: ScrollViewProxy) { + // Only update when streaming ends — settled list doesn't change at start. + guard old && !new else { return } + rebuildSettledItems() + anchor.resetToBottom() + // Keep the question pinned to the top through the row handoff; fall back + // to the bottom only when there is no user message. + if let pinned = trackedUserMessageID { + pinScrollDuringHandoff(to: pinned, anchor: .top, proxy: proxy) + } else { + pinScrollDuringHandoff(to: Self.bottomAnchorID, anchor: .bottom, proxy: proxy) } } @@ -232,18 +295,83 @@ struct MessageListView: View { /// the user sees the list jump up, then jump back down. A single debounced /// scroll can't fix it reliably: the streaming-insertion animation keeps /// emitting scroll-geometry changes that re-arm and starve the debounce. - /// Re-assert the bottom on every frame for a short window so the snap is - /// corrected within one frame, before it becomes visible. - private func pinScrollToBottomDuringHandoff(_ proxy: ScrollViewProxy) { + /// Re-assert the desired anchor on every frame for a short window so the + /// snap is corrected within one frame, before it becomes visible. + private func pinScrollDuringHandoff(to id: some Hashable, anchor: UnitPoint, proxy: ScrollViewProxy) { settleScrollTask?.cancel() settleScrollTask = Task { @MainActor in for _ in 0..<12 { guard !Task.isCancelled else { return } - scrollToBottom(proxy) + proxy.scrollTo(id, anchor: anchor) try? await Task.sleep(for: .milliseconds(16)) } } } + + // MARK: - Pin to top + + /// The latest user message — the row pinned to the top on send and the + /// reference point for the dynamic tail spacer. + private var trackedUserMessageID: UUID? { + chatBridge.messages.last(where: { $0.role == .user })?.id + } + + /// Height of the tail spacer: pads the latest turn so the user message can + /// always be scrolled to the very top. Shrinks as the response grows, so the + /// pinned message stays put without any further scrolling. + private var tailSpacerHeight: CGFloat { + guard viewportHeight > 0 else { return 1 } + let latestTurnHeight = max(0, tailSpacerMinY - latestUserMinY) + return max(1, viewportHeight - latestTurnHeight) + } + + /// Re-baseline the new-send detector to the current session so a session + /// switch or disk load is never mistaken for a freshly sent message. + private func syncPinTracking() { + pinSessionID = windowState.currentSessionId + lastTrackedUserID = trackedUserMessageID + } + + /// React to the latest user message changing: pin a genuine new send to the + /// top, but ignore the change when it is really a session switch. + private func handleTrackedUserChange(_ newID: UUID?, proxy: ScrollViewProxy) { + let sid = windowState.currentSessionId + guard pinSessionID == sid else { + // Session switch — re-baseline without pinning. + pinSessionID = sid + lastTrackedUserID = newID + return + } + guard let newID, newID != lastTrackedUserID else { return } + lastTrackedUserID = newID + guard !chatBridge.isLoadingFromDisk else { return } + // A genuine new user message in the active session — pin it to the top. + scrollTask?.cancel() + scrollTask = Task { @MainActor in + try? await Task.sleep(for: .milliseconds(16)) // 1 frame: spacer + row layout + guard !Task.isCancelled else { return } + withAnimation(.easeOut(duration: 0.28)) { + proxy.scrollTo(newID, anchor: .top) + } + } + } + + /// `minY` of the latest user message — fed back from `ChatMessageListView`. + private func updateLatestUserMinY(_ value: CGFloat) { + guard abs(value - latestUserMinY) > 0.5 else { return } + var t = Transaction() + t.animation = nil + withTransaction(t) { latestUserMinY = value } + } + + /// `minY` of the tail spacer — its distance from the user message is the + /// height of the latest turn. + private func updateTailSpacerMinY(_ value: CGFloat) { + guard abs(value - tailSpacerMinY) > 0.5 else { return } + var t = Transaction() + t.animation = nil + withTransaction(t) { tailSpacerMinY = value } + } } // MARK: - Streaming Message (isolated view — chatBridge.messages dependency confined to this view) diff --git a/Packages/Sources/RxCodeChatKit/SlashCommandBar.swift b/Packages/Sources/RxCodeChatKit/SlashCommandBar.swift index 590fc11..6df6253 100644 --- a/Packages/Sources/RxCodeChatKit/SlashCommandBar.swift +++ b/Packages/Sources/RxCodeChatKit/SlashCommandBar.swift @@ -12,15 +12,18 @@ public struct SlashCommand: Identifiable, Codable, Hashable { public var detailDescription: String? public var acceptsInput: Bool public var isInteractive: Bool + /// Agent this command applies to. `nil` means global (available to every agent). + public var agentProvider: AgentProvider? public var command: String { "/\(name)" } - public init(name: String, description: String, detailDescription: String? = nil, acceptsInput: Bool = false, isInteractive: Bool = false) { + public init(name: String, description: String, detailDescription: String? = nil, acceptsInput: Bool = false, isInteractive: Bool = false, agentProvider: AgentProvider? = nil) { self.name = name self.description = description self.detailDescription = detailDescription self.acceptsInput = acceptsInput self.isInteractive = isInteractive + self.agentProvider = agentProvider } public init(from decoder: Decoder) throws { @@ -30,6 +33,7 @@ public struct SlashCommand: Identifiable, Codable, Hashable { detailDescription = try c.decodeIfPresent(String.self, forKey: .detailDescription) acceptsInput = try c.decodeIfPresent(Bool.self, forKey: .acceptsInput) ?? false isInteractive = try c.decodeIfPresent(Bool.self, forKey: .isInteractive) ?? false + agentProvider = try c.decodeIfPresent(AgentProvider.self, forKey: .agentProvider) } } @@ -80,7 +84,7 @@ public enum SlashCommandRegistry { _cachedCommands = nil } - public static let defaultCommands: [SlashCommand] = [ + private static let defaultCommandsRaw: [SlashCommand] = [ // CLI built-in: conversation SlashCommand(name: "clear", description: "Start a new conversation"), SlashCommand(name: "btw", description: "Side question not added to conversation", acceptsInput: true), @@ -173,6 +177,13 @@ public enum SlashCommandRegistry { SlashCommand(name: "exit", description: "Exit CLI"), ] + /// All built-in commands, scoped to Claude Code — they are Claude CLI features. + public static let defaultCommands: [SlashCommand] = defaultCommandsRaw.map { + var command = $0 + command.agentProvider = .claudeCode + return command + } + private static var defaultCommandKeys: Set { Set(defaultCommands.map { commandKey($0.name) }) } @@ -209,6 +220,8 @@ public enum SlashCommandRegistry { for defaultCommand in defaultCommands { guard var modified = byKey[commandKey(defaultCommand.name)] else { continue } modified.name = defaultCommand.name + // Built-in scope is fixed (Claude Code) and not user-editable. + modified.agentProvider = defaultCommand.agentProvider if modified != defaultCommand { result[defaultCommand.name] = modified } @@ -396,10 +409,12 @@ public enum SlashCommandRegistry { String(localized: "detailDescription: optional longer text shown in command details. Use null or omit it when empty.", bundle: .module), String(localized: "acceptsInput: true lets users type additional text after the command.", bundle: .module), String(localized: "isInteractive: true runs the command in the interactive terminal.", bundle: .module), + String(localized: "agentProvider: agent this command applies to (claudeCode, codex, acp). Use null or omit it for all agents.", bundle: .module), "", String(localized: "All properties example:", bundle: .module), "{", " \"acceptsInput\": true,", + " \"agentProvider\": \"claudeCode\",", " \"description\": \"\(String(localized: "Short description shown in the command picker", bundle: .module))\",", " \"detailDescription\": \"\(String(localized: "Optional longer description shown in command details", bundle: .module))\",", " \"isInteractive\": false,", @@ -416,14 +431,23 @@ public enum SlashCommandRegistry { commands.filter { isEnabled(name: $0.name) } } - static func filtered(by query: String) -> [SlashCommand] { + /// Whether a command should be offered for the given agent. + /// A `nil` agent (no active session) shows everything; otherwise a command + /// is visible when it is global (`agentProvider == nil`) or matches the agent. + static func isVisible(_ cmd: SlashCommand, for agent: AgentProvider?) -> Bool { + guard let agent else { return true } + return cmd.agentProvider == nil || cmd.agentProvider == agent + } + + static func filtered(by query: String, agent: AgentProvider?) -> [SlashCommand] { let q = query.lowercased().trimmingCharacters(in: .whitespaces) - if q.isEmpty || q == "/" { return enabledCommands } + let base = enabledCommands.filter { isVisible($0, for: agent) } + if q.isEmpty || q == "/" { return base } let search = q.hasPrefix("/") ? String(q.dropFirst()) : q var nameMatches: [SlashCommand] = [] var descriptionMatches: [SlashCommand] = [] - for cmd in enabledCommands { + for cmd in base { if cmd.name.lowercased().contains(search) { nameMatches.append(cmd) } else if cmd.description.lowercased().contains(search) { @@ -438,12 +462,13 @@ public enum SlashCommandRegistry { struct SlashCommandPopup: View { let query: String + let agent: AgentProvider? let onSelect: (SlashCommand) -> Void @Binding var selectedIndex: Int @State private var detailCommand: SlashCommand? private var filtered: [SlashCommand] { - SlashCommandRegistry.filtered(by: query) + SlashCommandRegistry.filtered(by: query, agent: agent) } func showDetailForSelected() { diff --git a/Packages/Sources/RxCodeChatKit/SlashCommandManagerView.swift b/Packages/Sources/RxCodeChatKit/SlashCommandManagerView.swift index 1d3ab53..72eb23c 100644 --- a/Packages/Sources/RxCodeChatKit/SlashCommandManagerView.swift +++ b/Packages/Sources/RxCodeChatKit/SlashCommandManagerView.swift @@ -257,6 +257,8 @@ public struct SlashCommandManagerView: View { .font(.system(size: ClaudeTheme.size(13), weight: .semibold, design: .monospaced)) .foregroundStyle(isEnabled ? Color.primary : Color.secondary) + agentScopeBadge(cmd.agentProvider) + if isDefault { Text("default", bundle: .module) .font(.system(size: ClaudeTheme.size(9))) @@ -313,6 +315,19 @@ public struct SlashCommandManagerView: View { } } + /// Capsule showing which agent a command applies to. + @ViewBuilder + private func agentScopeBadge(_ provider: AgentProvider?) -> some View { + let label = provider?.displayName ?? String(localized: "All Agents", bundle: .module) + let tint: Color = provider == nil ? .secondary : Color.accentColor + Text(label) + .font(.system(size: ClaudeTheme.size(9), weight: .medium)) + .foregroundStyle(tint) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(tint.opacity(0.12), in: Capsule()) + } + // MARK: - Empty State private var emptyState: some View { @@ -416,6 +431,7 @@ struct SlashCommandEditView: View { @State private var detailDesc: String = "" @State private var acceptsInput: Bool = false @State private var isInteractive: Bool = false + @State private var agentScope: AgentProvider? = nil private var isEditing: Bool { command != nil } private var normalizedName: String { SlashCommandRegistry.normalizedNameForInput(name) } @@ -539,6 +555,23 @@ struct SlashCommandEditView: View { .labelsHidden() } } + + // Agent scope + fieldSection("Agent") { + Picker("", selection: $agentScope) { + Text("All Agents (Global)", bundle: .module).tag(nil as AgentProvider?) + ForEach(AgentProvider.allCases, id: \.self) { provider in + Text(provider.displayName).tag(Optional(provider)) + } + } + .labelsHidden() + .pickerStyle(.menu) + .disabled(isDefault) + + Text("Built-in commands are Claude Code only. Custom commands can target one agent or all.", bundle: .module) + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + } } .frame(maxWidth: .infinity, alignment: .leading) .padding(20) @@ -586,7 +619,8 @@ struct SlashCommandEditView: View { description: desc.trimmingCharacters(in: .whitespacesAndNewlines), detailDescription: detailDesc.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : detailDesc.trimmingCharacters(in: .whitespacesAndNewlines), acceptsInput: acceptsInput, - isInteractive: isInteractive + isInteractive: isInteractive, + agentProvider: isDefault ? .claudeCode : agentScope ) onSave(result) dismiss() @@ -597,7 +631,7 @@ struct SlashCommandEditView: View { .padding(.horizontal, 20) .padding(.vertical, 12) } - .frame(width: 520, height: 520) + .frame(width: 520, height: 580) .focusable(false) .onAppear { if let cmd = command { @@ -606,6 +640,7 @@ struct SlashCommandEditView: View { detailDesc = cmd.detailDescription ?? "" acceptsInput = cmd.acceptsInput isInteractive = cmd.isInteractive + agentScope = cmd.agentProvider } } } diff --git a/Packages/Sources/RxCodeCore/RunProfile/DetectedRunnable.swift b/Packages/Sources/RxCodeCore/RunProfile/DetectedRunnable.swift new file mode 100644 index 0000000..12a86cb --- /dev/null +++ b/Packages/Sources/RxCodeCore/RunProfile/DetectedRunnable.swift @@ -0,0 +1,65 @@ +import Foundation + +/// Source of an auto-detected runnable shown in the "+" picker of the Run +/// Configurations dialog (desktop) and the run profile editor (mobile). +public enum RunnableSource: String, Codable, Sendable, Hashable { + case xcode + case npm + case make +} + +/// A single auto-detected runnable. Pre-fills either a bash command (npm, +/// make) or a structured Xcode config (when `xcode` is non-nil) when the user +/// picks it from the "+" menu. Not persisted on its own — selecting one +/// materializes a normal `RunProfile`. +/// +/// The desktop produces these via `RunProfileDetector`; they are also sent to +/// paired mobile clients over the sync protocol so detection logic stays +/// entirely on the desktop. +public struct DetectedRunnable: Identifiable, Codable, Hashable, Sendable { + public let id: String + public let source: RunnableSource + public let displayName: String + public let command: String + /// Present when `source == .xcode`. The dialog materializes these as + /// `.xcode`-typed profiles instead of bash configurations. + public let xcode: XcodeRunConfig? + /// Present when `source == .make`. The dialog materializes these as + /// `.make`-typed profiles instead of bash configurations. + public let make: MakeRunConfig? + + public init( + id: String, + source: RunnableSource, + displayName: String, + command: String, + xcode: XcodeRunConfig? = nil, + make: MakeRunConfig? = nil + ) { + self.id = id + self.source = source + self.displayName = displayName + self.command = command + self.xcode = xcode + self.make = make + } +} + +/// Auto-detected runnables for a project, grouped by source. +public struct DetectedRunnables: Codable, Sendable, Hashable { + public var xcode: [DetectedRunnable] + public var npm: [DetectedRunnable] + public var make: [DetectedRunnable] + + public init( + xcode: [DetectedRunnable] = [], + npm: [DetectedRunnable] = [], + make: [DetectedRunnable] = [] + ) { + self.xcode = xcode + self.npm = npm + self.make = make + } + + public var isEmpty: Bool { xcode.isEmpty && npm.isEmpty && make.isEmpty } +} diff --git a/Packages/Sources/RxCodeSync/APNs/EncryptedAlert.swift b/Packages/Sources/RxCodeSync/APNs/EncryptedAlert.swift index fd639aa..82532c3 100644 --- a/Packages/Sources/RxCodeSync/APNs/EncryptedAlert.swift +++ b/Packages/Sources/RxCodeSync/APNs/EncryptedAlert.swift @@ -1,12 +1,15 @@ import Foundation import CryptoKit -/// On-the-wire shape of the alert blob nested under `enc` in the APNs payload. +/// On-the-wire shape of a sealed APNs blob. +/// +/// Carried under `enc` for visible alerts and under `encWidget` for silent +/// home-screen widget refreshes. The envelope is content-agnostic — it only +/// holds the sender pubkey, nonce, and ciphertext, never the plaintext shape. /// /// The same `SessionCrypto.seal/open` primitive used for relay envelopes is -/// reused here, so the iOS Notification Service Extension only needs the -/// device's long-term Curve25519 private key plus the sender's pubkey to -/// decrypt and present the visible alert. +/// reused here, so the recipient only needs the device's long-term Curve25519 +/// private key plus the sender's pubkey to decrypt the payload. public struct EncryptedAlert: Codable, Sendable { public let v: Int public let from: String @@ -48,25 +51,49 @@ public struct AlertPlaintext: Codable, Sendable { } } +/// Plaintext shape of a sealed home-screen widget snapshot, mirroring the +/// fields the desktop pushes for `RxCodeWidgetData`. Every field except +/// `updatedAt` is optional so a usage-only or job-count-only push still merges +/// cleanly into the device's stored snapshot. +public struct WidgetSnapshotPayload: Codable, Sendable { + /// Number of in-progress jobs, or `nil` to leave the stored value untouched. + public let jobs: Int? + /// Claude Code 5-hour utilization (0...100), or `nil` to leave untouched. + public let cc: Double? + /// Codex 5-hour utilization (0...100), or `nil` to leave untouched. + public let codex: Double? + /// When the desktop produced this snapshot, unix seconds. + public let updatedAt: Double + + public init(jobs: Int? = nil, cc: Double? = nil, codex: Double? = nil, updatedAt: Double) { + self.jobs = jobs + self.cc = cc + self.codex = codex + self.updatedAt = updatedAt + } +} + public enum APNsCrypto { - /// Desktop side: seal an alert for a paired mobile. - public static func seal( - plaintext: AlertPlaintext, + /// Seal any `Encodable` value into the content-agnostic `EncryptedAlert` + /// envelope. Backs both the alert and widget paths below. + public static func sealValue( + _ value: T, sender: Curve25519.KeyAgreement.PrivateKey, recipient: Curve25519.KeyAgreement.PublicKey ) throws -> EncryptedAlert { - let data = try JSONEncoder().encode(plaintext) + let data = try JSONEncoder().encode(value) let (nonce, ct) = try SessionCrypto.seal(plaintext: data, sender: sender, recipient: recipient) return EncryptedAlert(from: sender.publicKey.rawRepresentation.hexString, nonce: nonce, ct: ct) } - /// NSE side: open a sealed alert. The `from` field tells the NSE which - /// stored peer pubkey to use as the sender. - public static func open( + /// Open an `EncryptedAlert` envelope into a value of the requested type. + /// The `from` field tells the recipient which peer pubkey is the sender. + public static func openValue( + _ type: T.Type, envelope: EncryptedAlert, recipient: Curve25519.KeyAgreement.PrivateKey, sender: Curve25519.KeyAgreement.PublicKey - ) throws -> AlertPlaintext { + ) throws -> T { guard let nonce = envelope.nonceData, let ct = envelope.ciphertextData else { throw SessionCrypto.CryptoError.openFailed } @@ -76,6 +103,43 @@ public enum APNsCrypto { recipient: recipient, sender: sender ) - return try JSONDecoder().decode(AlertPlaintext.self, from: raw) + return try JSONDecoder().decode(T.self, from: raw) + } + + /// Desktop side: seal an alert for a paired mobile. + public static func seal( + plaintext: AlertPlaintext, + sender: Curve25519.KeyAgreement.PrivateKey, + recipient: Curve25519.KeyAgreement.PublicKey + ) throws -> EncryptedAlert { + try sealValue(plaintext, sender: sender, recipient: recipient) + } + + /// NSE side: open a sealed alert. The `from` field tells the NSE which + /// stored peer pubkey to use as the sender. + public static func open( + envelope: EncryptedAlert, + recipient: Curve25519.KeyAgreement.PrivateKey, + sender: Curve25519.KeyAgreement.PublicKey + ) throws -> AlertPlaintext { + try openValue(AlertPlaintext.self, envelope: envelope, recipient: recipient, sender: sender) + } + + /// Desktop side: seal a widget snapshot for a paired mobile. + public static func sealWidget( + _ snapshot: WidgetSnapshotPayload, + sender: Curve25519.KeyAgreement.PrivateKey, + recipient: Curve25519.KeyAgreement.PublicKey + ) throws -> EncryptedAlert { + try sealValue(snapshot, sender: sender, recipient: recipient) + } + + /// App side: open a sealed widget snapshot in the background-push handler. + public static func openWidget( + envelope: EncryptedAlert, + recipient: Curve25519.KeyAgreement.PrivateKey, + sender: Curve25519.KeyAgreement.PublicKey + ) throws -> WidgetSnapshotPayload { + try openValue(WidgetSnapshotPayload.self, envelope: envelope, recipient: recipient, sender: sender) } } diff --git a/Packages/Sources/RxCodeSync/Protocol/DetectionPayloads.swift b/Packages/Sources/RxCodeSync/Protocol/DetectionPayloads.swift new file mode 100644 index 0000000..658d1f1 --- /dev/null +++ b/Packages/Sources/RxCodeSync/Protocol/DetectionPayloads.swift @@ -0,0 +1,40 @@ +import Foundation +import RxCodeCore + +/// Mobile → desktop: ask the desktop to scan a project for runnable Xcode +/// schemes, npm scripts, and Make targets. Detection runs `xcodebuild -list` +/// and parses project files, so it stays on the desktop and is requested on +/// demand rather than carried in every snapshot. +public struct RunnableDetectRequestPayload: Codable, Sendable { + public let clientRequestID: UUID + public let projectID: UUID + + public init(clientRequestID: UUID = UUID(), projectID: UUID) { + self.clientRequestID = clientRequestID + self.projectID = projectID + } +} + +/// Desktop → mobile: the result of a `RunnableDetectRequestPayload`. `detected` +/// is `nil` when `ok` is `false`. +public struct RunnableDetectResultPayload: Codable, Sendable { + public let clientRequestID: UUID + public let projectID: UUID + public let ok: Bool + public let errorMessage: String? + public let detected: DetectedRunnables? + + public init( + clientRequestID: UUID, + projectID: UUID, + ok: Bool, + errorMessage: String? = nil, + detected: DetectedRunnables? = nil + ) { + self.clientRequestID = clientRequestID + self.projectID = projectID + self.ok = ok + self.errorMessage = errorMessage + self.detected = detected + } +} diff --git a/Packages/Sources/RxCodeSync/Protocol/FolderPayloads.swift b/Packages/Sources/RxCodeSync/Protocol/FolderPayloads.swift new file mode 100644 index 0000000..12c3bf3 --- /dev/null +++ b/Packages/Sources/RxCodeSync/Protocol/FolderPayloads.swift @@ -0,0 +1,133 @@ +import Foundation +import RxCodeCore + +/// A node in the remote folder tree shown by the mobile picker. Carries plain +/// files too when a request set `includeFiles` (e.g. picking a Makefile). +public struct RemoteFolderNode: Codable, Sendable, Identifiable, Equatable { + public var id: String { path } + + public let name: String + public let path: String + public let isSelectable: Bool + /// `false` for plain files surfaced when a picker requested files. Defaults + /// to `true` so folder trees from older desktops decode as directories. + public let isDirectory: Bool + public let children: [RemoteFolderNode] + + public init( + name: String, + path: String, + isSelectable: Bool = true, + isDirectory: Bool = true, + children: [RemoteFolderNode] = [] + ) { + self.name = name + self.path = path + self.isSelectable = isSelectable + self.isDirectory = isDirectory + self.children = children + } + + private enum CodingKeys: String, CodingKey { + case name, path, isSelectable, isDirectory, children + } + + public init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + name = try c.decode(String.self, forKey: .name) + path = try c.decode(String.self, forKey: .path) + isSelectable = try c.decodeIfPresent(Bool.self, forKey: .isSelectable) ?? true + isDirectory = try c.decodeIfPresent(Bool.self, forKey: .isDirectory) ?? true + children = try c.decodeIfPresent([RemoteFolderNode].self, forKey: .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 + /// When `true` the desktop also lists plain files (not just folders) so the + /// picker can select a file such as a Makefile. + public let includeFiles: Bool + + public init( + clientRequestID: UUID = UUID(), + path: String? = nil, + depth: Int = 1, + includeHidden: Bool = false, + includeFiles: Bool = false + ) { + self.clientRequestID = clientRequestID + self.path = path + self.depth = depth + self.includeHidden = includeHidden + self.includeFiles = includeFiles + } + + private enum CodingKeys: String, CodingKey { + case clientRequestID, path, depth, includeHidden, includeFiles + } + + public init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + clientRequestID = try c.decode(UUID.self, forKey: .clientRequestID) + path = try c.decodeIfPresent(String.self, forKey: .path) + depth = try c.decodeIfPresent(Int.self, forKey: .depth) ?? 1 + includeHidden = try c.decodeIfPresent(Bool.self, forKey: .includeHidden) ?? false + includeFiles = try c.decodeIfPresent(Bool.self, forKey: .includeFiles) ?? false + } +} + +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 + } +} diff --git a/Packages/Sources/RxCodeSync/Protocol/Payload.swift b/Packages/Sources/RxCodeSync/Protocol/Payload.swift index 8db19f4..03cf0ea 100644 --- a/Packages/Sources/RxCodeSync/Protocol/Payload.swift +++ b/Packages/Sources/RxCodeSync/Protocol/Payload.swift @@ -44,6 +44,8 @@ public enum Payload: Sendable { case runProfileResult(RunProfileResultPayload) case runProfileRunRequest(RunProfileRunRequestPayload) case runProfileStopRequest(RunProfileStopRequestPayload) + case runnableDetectRequest(RunnableDetectRequestPayload) + case runnableDetectResult(RunnableDetectResultPayload) case runTaskUpdate(RunTaskUpdatePayload) case skillCatalogRequest(SkillCatalogRequestPayload) case skillCatalogResult(SkillCatalogResultPayload) @@ -104,6 +106,8 @@ public extension Payload { case .runProfileResult: return "run_profile_result" case .runProfileRunRequest: return "run_profile_run_request" case .runProfileStopRequest: return "run_profile_stop_request" + case .runnableDetectRequest: return "runnable_detect_request" + case .runnableDetectResult: return "runnable_detect_result" case .runTaskUpdate: return "run_task_update" case .skillCatalogRequest: return "skill_catalog_request" case .skillCatalogResult: return "skill_catalog_result" @@ -474,93 +478,9 @@ 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 - } -} +// `RemoteFolderNode`, `FolderTreeRequestPayload`, `FolderTreeResultPayload`, +// `CreateProjectRequestPayload`, and `CreateProjectResultPayload` live in +// `FolderPayloads.swift` to keep this file under the line-length limit. public struct MobileProjectRunProfiles: Codable, Sendable, Equatable { public let projectId: UUID @@ -715,6 +635,9 @@ public struct RunTaskUpdatePayload: Codable, Sendable { } } +// `RunnableDetectRequestPayload` / `RunnableDetectResultPayload` live in +// `DetectionPayloads.swift` to keep this file under the line-length limit. + // MARK: - Codable extension Payload: Codable { @@ -761,6 +684,8 @@ extension Payload: Codable { case runProfileResult = "run_profile_result" case runProfileRunRequest = "run_profile_run_request" case runProfileStopRequest = "run_profile_stop_request" + case runnableDetectRequest = "runnable_detect_request" + case runnableDetectResult = "runnable_detect_result" case runTaskUpdate = "run_task_update" case skillCatalogRequest = "skill_catalog_request" case skillCatalogResult = "skill_catalog_result" @@ -825,6 +750,8 @@ extension Payload: Codable { case .runProfileResult: self = .runProfileResult(try container.decode(RunProfileResultPayload.self, forKey: .data)) case .runProfileRunRequest: self = .runProfileRunRequest(try container.decode(RunProfileRunRequestPayload.self, forKey: .data)) case .runProfileStopRequest: self = .runProfileStopRequest(try container.decode(RunProfileStopRequestPayload.self, forKey: .data)) + case .runnableDetectRequest: self = .runnableDetectRequest(try container.decode(RunnableDetectRequestPayload.self, forKey: .data)) + case .runnableDetectResult: self = .runnableDetectResult(try container.decode(RunnableDetectResultPayload.self, forKey: .data)) case .runTaskUpdate: self = .runTaskUpdate(try container.decode(RunTaskUpdatePayload.self, forKey: .data)) case .skillCatalogRequest: self = .skillCatalogRequest(try container.decode(SkillCatalogRequestPayload.self, forKey: .data)) case .skillCatalogResult: self = .skillCatalogResult(try container.decode(SkillCatalogResultPayload.self, forKey: .data)) @@ -885,6 +812,8 @@ extension Payload: Codable { case .runProfileResult(let p): try container.encode(TypeKey.runProfileResult.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .runProfileRunRequest(let p): try container.encode(TypeKey.runProfileRunRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .runProfileStopRequest(let p): try container.encode(TypeKey.runProfileStopRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .runnableDetectRequest(let p): try container.encode(TypeKey.runnableDetectRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .runnableDetectResult(let p): try container.encode(TypeKey.runnableDetectResult.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .runTaskUpdate(let p): try container.encode(TypeKey.runTaskUpdate.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .skillCatalogRequest(let p): try container.encode(TypeKey.skillCatalogRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .skillCatalogResult(let p): try container.encode(TypeKey.skillCatalogResult.rawValue, forKey: .type); try container.encode(p, forKey: .data) diff --git a/RxCode/App/AppState+MobileRemote.swift b/RxCode/App/AppState+MobileRemote.swift index e1bf2fa..7ce5505 100644 --- a/RxCode/App/AppState+MobileRemote.swift +++ b/RxCode/App/AppState+MobileRemote.swift @@ -404,18 +404,27 @@ extension AppState { name: Host.current().localizedName ?? "Mac", path: "", isSelectable: false, - children: mobileFolderPickerRoots(depth: 1, includeHidden: request.includeHidden) + children: mobileFolderPickerRoots( + depth: 1, + includeHidden: request.includeHidden, + includeFiles: request.includeFiles + ) ) } return try mobileFolderNode( for: URL(fileURLWithPath: path).standardizedFileURL, depth: depth, - includeHidden: request.includeHidden + includeHidden: request.includeHidden, + includeFiles: request.includeFiles ) } - func mobileFolderPickerRoots(depth: Int, includeHidden: Bool) -> [RemoteFolderNode] { + func mobileFolderPickerRoots( + depth: Int, + includeHidden: Bool, + includeFiles: Bool + ) -> [RemoteFolderNode] { let home = FileManager.default.homeDirectoryForCurrentUser.standardizedFileURL var candidates = [ home, @@ -433,14 +442,20 @@ extension AppState { return candidates.compactMap { url in let path = url.path guard seen.insert(path).inserted else { return nil } - return try? mobileFolderNode(for: url, depth: depth, includeHidden: includeHidden) + return try? mobileFolderNode( + for: url, + depth: depth, + includeHidden: includeHidden, + includeFiles: includeFiles + ) } } func mobileFolderNode( for url: URL, depth: Int, - includeHidden: Bool + includeHidden: Bool, + includeFiles: Bool ) throws -> RemoteFolderNode { let fm = FileManager.default var isDirectory: ObjCBool = false @@ -465,27 +480,43 @@ extension AppState { options: options )) ?? [] - let children = contents - .filter { child in - guard let values = try? child.resourceValues(forKeys: [.isDirectoryKey, .isHiddenKey]), - values.isDirectory == true - else { return false } + // Keep directories (always) and plain files (when requested). Folders + // sort first so navigation targets stay grouped above file leaves. + let entries = contents + .compactMap { child -> (url: URL, isDirectory: Bool)? in + guard let values = try? child.resourceValues(forKeys: [.isDirectoryKey, .isHiddenKey]) + else { return nil } if !includeHidden && (values.isHidden == true || child.lastPathComponent.hasPrefix(".")) { - return false + return nil } - return !Self.mobileFolderIgnoredNames.contains(child.lastPathComponent) + if Self.mobileFolderIgnoredNames.contains(child.lastPathComponent) { return nil } + let isDir = values.isDirectory == true + if !isDir && !includeFiles { return nil } + return (child, isDir) } .sorted { lhs, rhs in - lhs.lastPathComponent.localizedStandardCompare(rhs.lastPathComponent) == .orderedAscending + if lhs.isDirectory != rhs.isDirectory { return lhs.isDirectory } + return lhs.url.lastPathComponent.localizedStandardCompare(rhs.url.lastPathComponent) == .orderedAscending } .prefix(Self.mobileFolderMaxChildren) - .compactMap { child in - try? mobileFolderNode( - for: child.standardizedFileURL, + + let children = entries.compactMap { entry -> RemoteFolderNode? in + if entry.isDirectory { + return try? mobileFolderNode( + for: entry.url.standardizedFileURL, depth: depth - 1, - includeHidden: includeHidden + includeHidden: includeHidden, + includeFiles: includeFiles ) } + return RemoteFolderNode( + name: entry.url.lastPathComponent, + path: entry.url.standardizedFileURL.path, + isSelectable: false, + isDirectory: false, + children: [] + ) + } return RemoteFolderNode(name: name, path: url.path, children: Array(children)) } diff --git a/RxCode/App/AppState+MobileSync.swift b/RxCode/App/AppState+MobileSync.swift index 4c687e5..c1c710d 100644 --- a/RxCode/App/AppState+MobileSync.swift +++ b/RxCode/App/AppState+MobileSync.swift @@ -232,6 +232,20 @@ extension AppState { } mobileSyncObservers.append(runProfileStopObserver) + let runnableDetectObserver = center.addObserver( + forName: .mobileSyncRunnableDetectRequested, + object: nil, + queue: nil + ) { [weak self] notification in + guard let fromHex = notification.userInfo?["from"] as? String, + let request = notification.userInfo?["payload"] as? RunnableDetectRequestPayload + else { return } + Task { @MainActor [weak self] in + await self?.handleMobileRunnableDetectRequest(request, fromHex: fromHex) + } + } + mobileSyncObservers.append(runnableDetectObserver) + let questionAnswerObserver = center.addObserver( forName: .mobileSyncQuestionAnswerReceived, object: nil, @@ -803,4 +817,35 @@ extension AppState { if ok { scheduleMobileSnapshotBroadcast() } } + /// Scan a project for runnable Xcode schemes, npm scripts, and Make targets + /// on behalf of a paired mobile device. Detection logic lives entirely on + /// the desktop — mobile only displays the result. + func handleMobileRunnableDetectRequest( + _ request: RunnableDetectRequestPayload, + fromHex: String + ) async { + logger.info("[MobileSync] handling runnable detection project=\(request.projectID.uuidString, privacy: .public) mobileKey=\(String(fromHex.prefix(12)), privacy: .public)") + guard let project = projects.first(where: { $0.id == request.projectID }) else { + logger.error("[MobileSync] runnable detection rejected unknown project=\(request.projectID.uuidString, privacy: .public)") + let result = RunnableDetectResultPayload( + clientRequestID: request.clientRequestID, + projectID: request.projectID, + ok: false, + errorMessage: "Project not found on desktop." + ) + await MobileSyncService.shared.send(.runnableDetectResult(result), toHex: fromHex) + return + } + + let detected = await RunProfileDetector().detect(in: project.path) + logger.info("[MobileSync] runnable detection complete project=\(request.projectID.uuidString, privacy: .public) xcode=\(detected.xcode.count, privacy: .public) npm=\(detected.npm.count, privacy: .public) make=\(detected.make.count, privacy: .public)") + let result = RunnableDetectResultPayload( + clientRequestID: request.clientRequestID, + projectID: request.projectID, + ok: true, + detected: detected + ) + await MobileSyncService.shared.send(.runnableDetectResult(result), toHex: fromHex) + } + } diff --git a/RxCode/Services/MobileSyncService+EventDispatch.swift b/RxCode/Services/MobileSyncService+EventDispatch.swift index 1a7b65d..f086a28 100644 --- a/RxCode/Services/MobileSyncService+EventDispatch.swift +++ b/RxCode/Services/MobileSyncService+EventDispatch.swift @@ -261,6 +261,14 @@ extension MobileSyncService { object: nil, userInfo: ["from": inbound.fromHex, "payload": req] ) + case .runnableDetectRequest(let req): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "runnable_detect_request") else { return } + logger.info("[MobileSync] runnable detection requested project=\(req.projectID.uuidString, privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") + NotificationCenter.default.post( + name: .mobileSyncRunnableDetectRequested, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": req] + ) case .skillCatalogRequest(let req): guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "skill_catalog_request") else { return } logger.info("[MobileSync] skill catalog requested forceRefresh=\(req.forceRefresh, privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") diff --git a/RxCode/Services/MobileSyncService+LiveActivity.swift b/RxCode/Services/MobileSyncService+LiveActivity.swift index df2b7fe..a7dca3d 100644 --- a/RxCode/Services/MobileSyncService+LiveActivity.swift +++ b/RxCode/Services/MobileSyncService+LiveActivity.swift @@ -294,29 +294,58 @@ extension MobileSyncService { /// Push the current ongoing-job count and agent usage to every paired /// device as a silent background notification, refreshing the home-screen /// widget. Also called by `AppState` when rate-limit usage refreshes. + /// + /// The snapshot is E2E-encrypted per device (sealed to that device's + /// Curve25519 key), so the relay only ever forwards opaque ciphertext. func pushWidgetUpdate() { let jobCount = streamingSessionIDs.count lastWidgetJobCount = jobCount let devices = pairedDevices.filter { ($0.apnsToken?.isEmpty == false) } guard !devices.isEmpty, let pushURL = Self.pushEndpointURL(from: relayURL) else { return } let usage = usageSnapshotProvider?() - var widget: [String: Any] = [ - "jobs": jobCount, - "updatedAt": Date().timeIntervalSince1970, - ] - if let cc = usage?.cc { widget["cc"] = cc } - if let codex = usage?.codex { widget["codex"] = codex } - let payload: [String: Any] = ["aps": ["content-available": 1], "widget": widget] + let snapshot = WidgetSnapshotPayload( + jobs: jobCount, + cc: usage?.cc, + codex: usage?.codex, + updatedAt: Date().timeIntervalSince1970 + ) for device in devices { guard let token = device.apnsToken else { continue } Task { - await postRawPush(deviceToken: token, pushType: "background", - apnsPayload: payload, collapseID: "rxcode-widget", - device: device, pushURL: pushURL) + await sendWidgetPush(snapshot, to: device, token: token, pushURL: pushURL) } } } + /// Seal the widget snapshot for one device and POST it as a silent + /// background push. Per-device failures are logged and swallowed — widget + /// refreshes are best-effort. + private func sendWidgetPush( + _ snapshot: WidgetSnapshotPayload, + to device: PairedDevice, + token: String, + pushURL: URL + ) async { + guard let peer = await client.peer(forHex: device.pubkeyHex) else { + logger.error("[Push] widget push skipped — unknown peer deviceKey=\(String(device.pubkeyHex.prefix(12)), privacy: .public)") + return + } + let encWidget: String + do { + let envelope = try APNsCrypto.sealWidget( + snapshot, sender: identity.privateKey, recipient: peer + ) + encWidget = try JSONEncoder().encode(envelope).base64EncodedString() + } catch { + logger.error("[Push] widget seal failed deviceKey=\(String(device.pubkeyHex.prefix(12)), privacy: .public): \(error.localizedDescription, privacy: .public)") + return + } + let payload: [String: Any] = ["aps": ["content-available": 1], "encWidget": encWidget] + await postRawPush(deviceToken: token, pushType: "background", + apnsPayload: payload, collapseID: "rxcode-widget", + device: device, pushURL: pushURL) + } + /// POST a raw (Live Activity or background) push to the relay `/push` /// endpoint. Failures are logged and swallowed — these are best-effort. func postRawPush( diff --git a/RxCode/Services/MobileSyncService.swift b/RxCode/Services/MobileSyncService.swift index 04034ac..17f8eed 100644 --- a/RxCode/Services/MobileSyncService.swift +++ b/RxCode/Services/MobileSyncService.swift @@ -596,6 +596,7 @@ extension Notification.Name { static let mobileSyncRunProfileMutationRequested = Notification.Name("mobileSync.runProfileMutationRequested") static let mobileSyncRunProfileRunRequested = Notification.Name("mobileSync.runProfileRunRequested") static let mobileSyncRunProfileStopRequested = Notification.Name("mobileSync.runProfileStopRequested") + static let mobileSyncRunnableDetectRequested = Notification.Name("mobileSync.runnableDetectRequested") static let mobileSyncSkillCatalogRequested = Notification.Name("mobileSync.skillCatalogRequested") static let mobileSyncSkillMutationRequested = Notification.Name("mobileSync.skillMutationRequested") static let mobileSyncSkillSourceMutationRequested = Notification.Name("mobileSync.skillSourceMutationRequested") diff --git a/RxCode/Services/RunProfile/RunProfileDetector.swift b/RxCode/Services/RunProfile/RunProfileDetector.swift index 4b3a715..6a1addb 100644 --- a/RxCode/Services/RunProfile/RunProfileDetector.swift +++ b/RxCode/Services/RunProfile/RunProfileDetector.swift @@ -1,54 +1,8 @@ import Foundation import RxCodeCore -/// Source of an auto-detected runnable shown in the "+" picker of the Run -/// Configurations dialog. -enum RunnableSource: String, Sendable, Hashable { - case xcode - case npm - case make -} - -/// A single auto-detected runnable. Pre-fills either a bash command (npm, -/// make) or a structured Xcode config (when `xcode` is non-nil) when the user -/// picks it from the "+" menu. Not persisted on its own — selecting one -/// materializes a normal `RunProfile`. -struct DetectedRunnable: Identifiable, Hashable, Sendable { - let id: String - let source: RunnableSource - let displayName: String - let command: String - /// Present when `source == .xcode`. The dialog materializes these as - /// `.xcode`-typed profiles instead of bash configurations. - let xcode: XcodeRunConfig? - /// Present when `source == .make`. The dialog materializes these as - /// `.make`-typed profiles instead of bash configurations. - let make: MakeRunConfig? - - init( - id: String, - source: RunnableSource, - displayName: String, - command: String, - xcode: XcodeRunConfig? = nil, - make: MakeRunConfig? = nil - ) { - self.id = id - self.source = source - self.displayName = displayName - self.command = command - self.xcode = xcode - self.make = make - } -} - -struct DetectedRunnables: Sendable, Hashable { - var xcode: [DetectedRunnable] = [] - var npm: [DetectedRunnable] = [] - var make: [DetectedRunnable] = [] - - var isEmpty: Bool { xcode.isEmpty && npm.isEmpty && make.isEmpty } -} +// `RunnableSource`, `DetectedRunnable`, and `DetectedRunnables` live in +// `RxCodeCore` so the sync protocol can carry detection results to mobile. /// Read-only scanner that surfaces Xcode schemes, npm scripts, and Make /// targets at a project root. Errors are swallowed — a broken `xcodebuild` diff --git a/RxCode/Views/Settings/CommandsSettingsTab.swift b/RxCode/Views/Settings/CommandsSettingsTab.swift new file mode 100644 index 0000000..73dba78 --- /dev/null +++ b/RxCode/Views/Settings/CommandsSettingsTab.swift @@ -0,0 +1,37 @@ +import RxCodeChatKit +import RxCodeCore +import SwiftUI + +/// Settings tab that hosts both the slash command manager and the shortcut +/// manager behind a segmented control. +struct CommandsSettingsTab: View { + private enum Segment: Hashable { + case slashCommands + case shortcuts + } + + @State private var segment: Segment = .slashCommands + + var body: some View { + VStack(spacing: 0) { + Picker("", selection: $segment) { + Text("Slash Commands").tag(Segment.slashCommands) + Text("Shortcuts").tag(Segment.shortcuts) + } + .pickerStyle(.segmented) + .labelsHidden() + .padding(.horizontal, 20) + .padding(.top, 12) + .padding(.bottom, 8) + + Divider() + + switch segment { + case .slashCommands: + SlashCommandManagerView(isEmbedded: true) + case .shortcuts: + ShortcutManagerView(isEmbedded: true) + } + } + } +} diff --git a/RxCode/Views/SettingsView.swift b/RxCode/Views/SettingsView.swift index 8775288..814fabd 100644 --- a/RxCode/Views/SettingsView.swift +++ b/RxCode/Views/SettingsView.swift @@ -29,41 +29,35 @@ struct SettingsView: View { } .tag(1) - SlashCommandManagerView(isEmbedded: true) + CommandsSettingsTab() .tabItem { - Label("Slash Commands", systemImage: "terminal.fill") + Label("Commands", systemImage: "command") } .tag(2) - ShortcutManagerView(isEmbedded: true) - .tabItem { - Label("Shortcuts", systemImage: "bolt.fill") - } - .tag(3) - SkillMarketView(isEmbedded: true) .tabItem { Label("Skill Marketplace", systemImage: "brain.head.profile") } - .tag(4) + .tag(3) MCPSettingsTab() .tabItem { Label("MCP", systemImage: "puzzlepiece.extension") } - .tag(5) + .tag(4) ACPClientSettingsTab() .tabItem { Label("ACP Clients", systemImage: "link.circle") } - .tag(6) + .tag(5) MobileSettingsTab() .tabItem { Label("Mobile", systemImage: "iphone.gen3") } - .tag(7) + .tag(6) } .frame(width: 680, height: 620) .focusable(false) diff --git a/RxCode/Views/UserManualView.swift b/RxCode/Views/UserManualView.swift index 058a8e9..2bda3bd 100644 --- a/RxCode/Views/UserManualView.swift +++ b/RxCode/Views/UserManualView.swift @@ -190,7 +190,7 @@ enum ManualTopic: String, CaseIterable, Identifiable { case .shortcuts: "Keyboard Shortcuts" case .slashCommands: "Slash Commands" case .attachments: "File & Image Attachments" - case .customShortcuts: "Shortcut Buttons" + case .customShortcuts: "Shortcuts" case .inspectorPanel: "Inspector Panel" case .github: "GitHub Integration" case .marketplace: "Skill Marketplace" @@ -233,7 +233,7 @@ enum ManualTopic: String, CaseIterable, Identifiable { ), ManualSection( title: "Top Toolbar", - body: "The toolbar contains: New Chat, Manage Slash Commands, Manage Shortcut Buttons, Skip Permissions toggle, Model picker, Inspector panel toggle, Settings, and the GitHub integration button." + body: "The toolbar contains: New Chat, Manage Commands, Skip Permissions toggle, Model picker, Inspector panel toggle, Settings, and the GitHub integration button." ), ] @@ -384,11 +384,11 @@ enum ManualTopic: String, CaseIterable, Identifiable { ] ), ManualSection( - title: "@ File Popup", + title: "@ Mention Popup", body: "", items: [ - KeyValueItem(key: "↑ / ↓", value: "Select item"), - KeyValueItem(key: "Return / Tab", value: "Insert file path"), + KeyValueItem(key: "↑ / ↓", value: "Select shortcut or file"), + KeyValueItem(key: "Return / Tab", value: "Run shortcut or insert file path"), KeyValueItem(key: "Escape", value: "Close popup"), ] ), @@ -414,9 +414,14 @@ enum ManualTopic: String, CaseIterable, Identifiable { title: "Interactive Commands", body: "Some commands (such as /config, /permissions, /model) open in a full interactive terminal popup sheet, where you can use the interactive CLI interface. The popup closes automatically when the command finishes." ), + ManualSection( + title: "Agent-Specific Commands", + body: "Each command targets an agent: built-in commands are Claude Code only, while custom commands can target Claude Code, Codex, ACP, or all agents. The popup only lists commands available to the current session's agent.", + note: "Set a command's agent in its editor under Settings → Commands." + ), ManualSection( title: "Managing Commands", - body: "Click the / button in the toolbar, or open Settings → Slash Commands to add, edit, delete, or disable custom commands. Built-in commands can be edited and enabled or disabled in the app.", + body: "Click the / button in the toolbar, or open Settings → Commands to add, edit, delete, or disable custom commands. Built-in commands can be edited and enabled or disabled in the app.", note: "JSON import/export backs up or shares custom commands only. Built-in commands in imported files are ignored." ), ] @@ -439,11 +444,11 @@ enum ManualTopic: String, CaseIterable, Identifiable { note: "Screenshots can be pasted directly — they are automatically attached as images." ), ManualSection( - title: "@ File References", - body: "Type @ in the input field to open a project file search popup. Files are filtered in real time as you type.", + title: "@ Mentions", + body: "Type @ in the input field to open the mention popup. It lists your shortcuts on top and project files below, both filtered in real time as you type.", items: [ - KeyValueItem(key: "↑ / ↓", value: "Select item"), - KeyValueItem(key: "Return / Tab", value: "Insert file path into message"), + KeyValueItem(key: "↑ / ↓", value: "Select shortcut or file"), + KeyValueItem(key: "Return / Tab", value: "Run shortcut or insert file path"), KeyValueItem(key: "Escape", value: "Close popup"), ] ), @@ -462,20 +467,20 @@ enum ManualTopic: String, CaseIterable, Identifiable { case .customShortcuts: [ ManualSection( - title: "What are Shortcut Buttons?", - body: "Quick-access buttons shown at the top of the chat area. Run frequently used messages or shell commands with a single click." + title: "What are Shortcuts?", + body: "Saved snippets for frequently used messages or shell commands. Type @ in the input field to open the mention popup and pick a shortcut from the list." ), ManualSection( - title: "Adding a Shortcut", - body: "Click the ⚡ button in the toolbar to open the Shortcut Manager, or press the + button on the right side of the shortcut bar. You can set a name, message/command, icon, and color." + title: "Using a Shortcut", + body: "Type @ to open the popup, continue typing to filter, then press Return or Tab. A message shortcut is inserted into the input field for you to review and send; a terminal-command shortcut runs immediately." ), ManualSection( title: "Terminal Command Mode", - body: "Enable the \"Run as terminal command\" option to execute the shortcut as a shell command in the inspector terminal instead of sending it as a chat message. The project directory is used as the working directory." + body: "Enable the \"Run as terminal command\" option to execute the shortcut as a shell command in the inspector terminal instead of inserting it as a chat message. The project directory is used as the working directory." ), ManualSection( title: "Managing Shortcuts", - body: "Open Settings → Shortcuts to reorder, edit, or delete shortcuts. Shortcut configurations are saved per project.", + body: "Open Settings → Commands and switch to the Shortcuts segment to add, edit, or delete shortcuts.", note: "JSON import/export is supported for backing up or sharing your shortcut set." ), ] @@ -617,7 +622,7 @@ enum ManualTopic: String, CaseIterable, Identifiable { [ ManualSection( title: "Opening Settings", - body: "Choose RxCode → Settings from the menu bar or press ⌘, to open the Settings window. Settings are organized into four tabs: General, Message, Slash Commands, and Shortcuts." + body: "Choose RxCode → Settings from the menu bar or press ⌘, to open the Settings window. Settings are organized into tabs including General, Message, and Commands." ), ManualSection( title: "General Tab", @@ -657,8 +662,8 @@ enum ManualTopic: String, CaseIterable, Identifiable { ] ), ManualSection( - title: "Slash Commands & Shortcuts Tabs", - body: "The Slash Commands tab manages built-in and custom commands — edit, enable/disable, add, or delete. JSON import/export covers custom commands only. The Shortcuts tab manages shortcut buttons with JSON import/export support." + title: "Commands Tab", + body: "The Commands tab has two segments. Slash Commands manages built-in and custom commands — edit, enable/disable, add, delete, or set an agent scope; JSON import/export covers custom commands only. Shortcuts manages your @-triggered shortcuts with JSON import/export support." ), ManualSection( title: "Checking for Updates", diff --git a/RxCodeMobile/AppDelegate.swift b/RxCodeMobile/AppDelegate.swift index 940ebdb..3248c6e 100644 --- a/RxCodeMobile/AppDelegate.swift +++ b/RxCodeMobile/AppDelegate.swift @@ -58,30 +58,24 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent /// Handles a silent background push carrying a fresh home-screen widget /// snapshot. The desktop sends these (`push_type=background`) whenever the - /// ongoing-job count or agent usage changes; the payload is plaintext - /// because WidgetKit data is low-sensitivity and not user-facing text. + /// ongoing-job count or agent usage changes. The snapshot is E2E-encrypted + /// to this device under `encWidget` and decrypted here before it reaches + /// the widget store. func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any]) async -> UIBackgroundFetchResult { - guard let widget = userInfo["widget"] as? [String: Any] else { + guard let snapshot = decryptWidgetSnapshot(from: userInfo, context: "background-push") else { return .noData } // Merge into the existing snapshot so a job-count-only push doesn't // wipe the usage figures (and vice versa). - var snapshot = RxCodeWidgetStore.load() - if let jobs = (widget["jobs"] as? NSNumber)?.intValue { - snapshot.jobCount = jobs - } - if let cc = (widget["cc"] as? NSNumber)?.doubleValue { - snapshot.ccUsagePercent = cc - } - if let codex = (widget["codex"] as? NSNumber)?.doubleValue { - snapshot.codexUsagePercent = codex - } - snapshot.updatedAt = (widget["updatedAt"] as? NSNumber)?.doubleValue - ?? Date().timeIntervalSince1970 - RxCodeWidgetStore.save(snapshot) - logger.info("[Widget] background push applied jobs=\(snapshot.jobCount, privacy: .public)") + var stored = RxCodeWidgetStore.load() + if let jobs = snapshot.jobs { stored.jobCount = jobs } + if let cc = snapshot.cc { stored.ccUsagePercent = cc } + if let codex = snapshot.codex { stored.codexUsagePercent = codex } + stored.updatedAt = snapshot.updatedAt + RxCodeWidgetStore.save(stored) + logger.info("[Widget] background push applied jobs=\(stored.jobCount, privacy: .public)") return .newData } @@ -171,19 +165,61 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent /// notification-tap fallback. `context` is a request identifier for logging. private func decryptAlertPlaintext(from userInfo: [AnyHashable: Any], context: String) -> AlertPlaintext? { - guard let encB64 = userInfo["enc"] as? String else { - logger.info("[APNs] notification has no enc payload context=\(context, privacy: .public)") + guard let resolved = resolveEncryptedEnvelope(from: userInfo, key: "enc", context: context) else { + return nil + } + do { + return try APNsCrypto.open(envelope: resolved.envelope, + recipient: resolved.recipient, + sender: resolved.sender) + } catch { + logger.error("[APNs] decrypt failed context=\(context, privacy: .public) sender=\(String(resolved.envelope.from.prefix(12)), privacy: .public): \(error.localizedDescription, privacy: .public)") + return nil + } + } + + /// Decrypts the `encWidget` blob carried by a silent background push into a + /// `WidgetSnapshotPayload`. The desktop seals one envelope per device, so + /// the relay only ever forwards opaque ciphertext. + private func decryptWidgetSnapshot(from userInfo: [AnyHashable: Any], + context: String) -> WidgetSnapshotPayload? { + guard let resolved = resolveEncryptedEnvelope(from: userInfo, key: "encWidget", context: context) else { + return nil + } + do { + return try APNsCrypto.openWidget(envelope: resolved.envelope, + recipient: resolved.recipient, + sender: resolved.sender) + } catch { + logger.error("[Widget] decrypt failed context=\(context, privacy: .public) sender=\(String(resolved.envelope.from.prefix(12)), privacy: .public): \(error.localizedDescription, privacy: .public)") + return nil + } + } + + /// Resolves the `EncryptedAlert` envelope stored under `key`, together with + /// the local device private key and the sender's public key needed to open + /// it. Shared by the alert and widget decryption paths. `context` is a + /// request identifier used for logging. + private func resolveEncryptedEnvelope( + from userInfo: [AnyHashable: Any], + key: String, + context: String + ) -> (envelope: EncryptedAlert, + recipient: Curve25519.KeyAgreement.PrivateKey, + sender: Curve25519.KeyAgreement.PublicKey)? { + guard let encB64 = userInfo[key] as? String else { + logger.info("[APNs] notification has no \(key, privacy: .public) payload context=\(context, privacy: .public)") return nil } guard let raw = Data(base64Encoded: encB64) else { - logger.error("[APNs] enc payload is not valid base64 context=\(context, privacy: .public) length=\(encB64.count, privacy: .public)") + logger.error("[APNs] \(key, privacy: .public) payload is not valid base64 context=\(context, privacy: .public) length=\(encB64.count, privacy: .public)") return nil } let envelope: EncryptedAlert do { envelope = try JSONDecoder().decode(EncryptedAlert.self, from: raw) } catch { - logger.error("[APNs] encrypted alert decode failed context=\(context, privacy: .public): \(error.localizedDescription, privacy: .public)") + logger.error("[APNs] encrypted envelope decode failed context=\(context, privacy: .public): \(error.localizedDescription, privacy: .public)") return nil } @@ -207,13 +243,7 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent logger.error("[APNs] sender public key parse failed sender=\(String(envelope.from.prefix(12)), privacy: .public): \(error.localizedDescription, privacy: .public)") return nil } - - do { - return try APNsCrypto.open(envelope: envelope, recipient: identity.privateKey, sender: senderKey) - } catch { - logger.error("[APNs] decrypt failed context=\(context, privacy: .public) sender=\(String(envelope.from.prefix(12)), privacy: .public) identity=\(String(identity.publicKeyHex.prefix(12)), privacy: .public): \(error.localizedDescription, privacy: .public)") - return nil - } + return (envelope, identity.privateKey, senderKey) } private static func apnsSummary(_ userInfo: [AnyHashable: Any]) -> String { diff --git a/RxCodeMobile/State/MobileAppState+Inbound.swift b/RxCodeMobile/State/MobileAppState+Inbound.swift index 07572fb..0bde3c7 100644 --- a/RxCodeMobile/State/MobileAppState+Inbound.swift +++ b/RxCodeMobile/State/MobileAppState+Inbound.swift @@ -107,6 +107,7 @@ extension MobileAppState { } activeSessionID = active } + hasReceivedInitialSnapshot = true refreshWidgetData() case .moreMessages(let page): guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "more_messages") else { return } @@ -180,6 +181,9 @@ extension MobileAppState { case .runTaskUpdate(let update): guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "run_task_update") else { return } upsertRunTask(update.task) + case .runnableDetectResult(let result): + guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "runnable_detect_result") else { return } + applyRunnableDetectResult(result) case .skillCatalogResult(let result): guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "skill_catalog_result") else { return } applySkillCatalogResult(result) @@ -226,6 +230,20 @@ extension MobileAppState { } } + func applyRunnableDetectResult(_ result: RunnableDetectResultPayload) { + runnableDetectInFlight.remove(result.projectID) + if pendingRunnableDetectRequestID == result.clientRequestID { + pendingRunnableDetectRequestID = nil + } + if result.ok, let detected = result.detected { + detectedRunnablesByProject[result.projectID] = detected + runnableDetectError = nil + logger.info("[RunProfiles] applied detection project=\(result.projectID.uuidString, privacy: .public) xcode=\(detected.xcode.count, privacy: .public) npm=\(detected.npm.count, privacy: .public) make=\(detected.make.count, privacy: .public)") + } else { + runnableDetectError = result.errorMessage ?? "Failed to detect runnables." + } + } + func applySkillCatalogResult(_ result: SkillCatalogResultPayload) { guard pendingSkillCatalogRequestID == result.clientRequestID else { return } pendingSkillCatalogRequestID = nil diff --git a/RxCodeMobile/State/MobileAppState+Intents.swift b/RxCodeMobile/State/MobileAppState+Intents.swift index f0f38b7..d8915f0 100644 --- a/RxCodeMobile/State/MobileAppState+Intents.swift +++ b/RxCodeMobile/State/MobileAppState+Intents.swift @@ -80,9 +80,9 @@ extension MobileAppState { try? await client.send(.newSessionRequest(payload), toHex: pairedDesktopPubkey) } - func requestRemoteFolder(path: String? = nil) async { + func requestRemoteFolder(path: String? = nil, includeFiles: Bool = false) async { guard isPaired else { return } - let request = FolderTreeRequestPayload(path: path, depth: 1) + let request = FolderTreeRequestPayload(path: path, depth: 1, includeFiles: includeFiles) pendingFolderTreeRequestID = request.clientRequestID remoteFolderIsLoading = true remoteFolderError = nil diff --git a/RxCodeMobile/State/MobileAppState+Sync.swift b/RxCodeMobile/State/MobileAppState+Sync.swift index 8d6743d..e816169 100644 --- a/RxCodeMobile/State/MobileAppState+Sync.swift +++ b/RxCodeMobile/State/MobileAppState+Sync.swift @@ -245,6 +245,29 @@ extension MobileAppState { } } + /// Ask the desktop to scan a project for runnable Xcode schemes, npm + /// scripts, and Make targets. The desktop owns all detection logic; mobile + /// only renders the result in the run profile editor. + func requestRunnableDetection(projectID: UUID) async { + guard isPaired else { + logger.error("[RunProfiles] detection dropped because mobile is not paired project=\(projectID.uuidString, privacy: .public)") + return + } + let payload = RunnableDetectRequestPayload(projectID: projectID) + pendingRunnableDetectRequestID = payload.clientRequestID + runnableDetectInFlight.insert(projectID) + runnableDetectError = nil + do { + try await client.send(.runnableDetectRequest(payload), toHex: pairedDesktopPubkey) + logger.info("[RunProfiles] sent detection request id=\(payload.clientRequestID.uuidString, privacy: .public) project=\(projectID.uuidString, privacy: .public)") + } catch { + pendingRunnableDetectRequestID = nil + runnableDetectInFlight.remove(projectID) + runnableDetectError = "Failed to request detection: \(error.localizedDescription)" + logger.error("[RunProfiles] detection send failed project=\(projectID.uuidString, privacy: .public): \(error.localizedDescription, privacy: .public)") + } + } + /// Update the search query and dispatch a debounced search request to the /// paired desktop. Empty queries clear results without hitting the network. /// Stale requests are discarded by `clientRequestID`. @@ -359,6 +382,7 @@ extension MobileAppState { } func clearDesktopMirror() { + hasReceivedInitialSnapshot = false projects = [] sessions = [] branchBriefings = [] diff --git a/RxCodeMobile/State/MobileAppState.swift b/RxCodeMobile/State/MobileAppState.swift index e2e2b31..ec9628b 100644 --- a/RxCodeMobile/State/MobileAppState.swift +++ b/RxCodeMobile/State/MobileAppState.swift @@ -72,6 +72,14 @@ final class MobileAppState: ObservableObject { @Published var runTasks: [MobileRunTaskSnapshot] = [] @Published var inFlightRunProfileRequests: Set = [] @Published var lastRunProfileError: String? + /// Auto-detected runnables (Xcode schemes, npm scripts, Make targets) per + /// project. Produced by the desktop's `RunProfileDetector` and requested on + /// demand when the run profile screen opens — never computed on mobile. + @Published var detectedRunnablesByProject: [UUID: DetectedRunnables] = [:] + /// Projects with an in-flight detection request, keyed by project id. + @Published var runnableDetectInFlight: Set = [] + @Published var runnableDetectError: String? + var pendingRunnableDetectRequestID: UUID? // MARK: - Remote desktop config: Skills @@ -146,6 +154,10 @@ final class MobileAppState: ObservableObject { @Published var searchProjectIDs: [UUID] = [] @Published var searchThreadHits: [SearchHit] = [] @Published var isSearching: Bool = false + /// Whether the mobile app has received its first snapshot from the desktop + /// since launch or pairing. Used to show a loading state instead of + /// "No Projects" when waiting for the initial sync. + @Published var hasReceivedInitialSnapshot: Bool = false var pendingSearchID: UUID? var searchDebounceTask: Task? diff --git a/RxCodeMobile/Views/GlassThreadCard.swift b/RxCodeMobile/Views/GlassThreadCard.swift index 64c553f..2325c2c 100644 --- a/RxCodeMobile/Views/GlassThreadCard.swift +++ b/RxCodeMobile/Views/GlassThreadCard.swift @@ -44,16 +44,16 @@ struct GlassThreadCard: View { VStack(alignment: .leading, spacing: 6) { // Title row HStack(spacing: 6) { - Text(displayTitle) - .font(.system(size: 16, weight: .medium)) - .foregroundStyle(.primary) - .lineLimit(1) - if session.isPinned { Image(systemName: "pin.fill") .font(.system(size: 10, weight: .semibold)) .foregroundStyle(.tertiary) } + + Text(displayTitle) + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(.primary) + .lineLimit(1) } // Metadata row @@ -133,15 +133,7 @@ struct GlassThreadCard: View { // MARK: - Streaming Indicator private var streamingIndicator: some View { - HStack(spacing: 3) { - ForEach(0..<3, id: \.self) { index in - Circle() - .fill(ClaudeTheme.accent) - .frame(width: 4, height: 4) - .opacity(0.6) - } - } - .modifier(PulsingAnimation()) + StreamingIndicatorView() } // MARK: - Helpers @@ -205,34 +197,92 @@ private struct GlassCardButtonStyle: ButtonStyle { } } -// MARK: - Pulsing Animation +// MARK: - Streaming Indicator View -private struct PulsingAnimation: ViewModifier { - @State private var isAnimating = false +/// An animated streaming indicator with bouncing dots. +/// Shows clearly when a thread is actively processing. +struct StreamingIndicatorView: View { + @State private var animatingDots = [false, false, false] - func body(content: Content) -> some View { - content - .opacity(isAnimating ? 0.4 : 1.0) - .animation( - .easeInOut(duration: 0.8) - .repeatForever(autoreverses: true), - value: isAnimating - ) - .onAppear { - isAnimating = true + var body: some View { + HStack(spacing: 3) { + ForEach(0..<3, id: \.self) { index in + Circle() + .fill(ClaudeTheme.accent) + .frame(width: 6, height: 6) + .scaleEffect(animatingDots[index] ? 1.0 : 0.5) + .opacity(animatingDots[index] ? 1.0 : 0.4) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background { + Capsule() + .fill(ClaudeTheme.accent.opacity(0.15)) + } + .onAppear { + startAnimation() + } + } + + private func startAnimation() { + for index in 0..<3 { + withAnimation( + .easeInOut(duration: 0.4) + .repeatForever(autoreverses: true) + .delay(Double(index) * 0.15) + ) { + animatingDots[index] = true } + } + } +} + +#Preview("Streaming Indicator") { + VStack(spacing: 20) { + StreamingIndicatorView() + + HStack { + Text("Processing...") + .font(.subheadline) + .foregroundStyle(.secondary) + StreamingIndicatorView() + } } + .padding() } -// MARK: - Preview +// MARK: - Thread Card Previews -#Preview { +#Preview("Thread States") { ScrollView { VStack(spacing: 12) { + // Active streaming thread - most prominent GlassThreadCard( session: SessionSummary( id: "1", projectId: UUID(), + title: "Building new feature module", + updatedAt: Date(), + isPinned: false, + isArchived: false, + isStreaming: true, + attention: nil, + todos: [ + TodoItem(id: 1, content: "Create models", activeForm: "Creating models", status: .completed), + TodoItem(id: 2, content: "Add views", activeForm: "Adding views", status: .inProgress), + TodoItem(id: 3, content: "Write tests", activeForm: "Writing tests", status: .pending) + ], + hasUncheckedCompletion: false + ), + isSelected: false + ) + + // Selected thread with todos + GlassThreadCard( + session: SessionSummary( + id: "2", + projectId: UUID(), title: "Implement Liquid Glass UI", updatedAt: Date().addingTimeInterval(-300), isPinned: true, @@ -241,40 +291,76 @@ private struct PulsingAnimation: ViewModifier { attention: nil, todos: [ TodoItem(id: 1, content: "Task 1", activeForm: "Doing task 1", status: .completed), - TodoItem(id: 2, content: "Task 2", activeForm: "Doing task 2", status: .inProgress), - TodoItem(id: 3, content: "Task 3", activeForm: "Doing task 3", status: .pending) + TodoItem(id: 2, content: "Task 2", activeForm: "Doing task 2", status: .completed), + TodoItem(id: 3, content: "Task 3", activeForm: "Doing task 3", status: .completed) ], hasUncheckedCompletion: false ), isSelected: true ) + // Thread needing attention (permission) GlassThreadCard( session: SessionSummary( - id: "2", + id: "3", + projectId: UUID(), + title: "Deploy to production", + updatedAt: Date().addingTimeInterval(-120), + isPinned: false, + isArchived: false, + isStreaming: false, + attention: .permission, + todos: nil, + hasUncheckedCompletion: false + ), + isSelected: false + ) + + // Thread needing attention (question) + GlassThreadCard( + session: SessionSummary( + id: "4", + projectId: UUID(), + title: "Code review assistance", + updatedAt: Date().addingTimeInterval(-600), + isPinned: false, + isArchived: false, + isStreaming: false, + attention: .question, + todos: nil, + hasUncheckedCompletion: false + ), + isSelected: false + ) + + // Completed thread with unchecked completion + GlassThreadCard( + session: SessionSummary( + id: "5", projectId: UUID(), title: "Fix scrolling performance", updatedAt: Date().addingTimeInterval(-3600), isPinned: false, isArchived: false, - isStreaming: true, + isStreaming: false, attention: nil, todos: nil, - hasUncheckedCompletion: false + hasUncheckedCompletion: true ), isSelected: false ) + // Regular idle thread GlassThreadCard( session: SessionSummary( - id: "3", + id: "6", projectId: UUID(), title: "Review pull request", updatedAt: Date().addingTimeInterval(-86400), isPinned: false, isArchived: false, isStreaming: false, - attention: .permission, + attention: nil, todos: nil, hasUncheckedCompletion: false ), diff --git a/RxCodeMobile/Views/MobileBriefingView.swift b/RxCodeMobile/Views/MobileBriefingView.swift index 0481f85..f42f6b1 100644 --- a/RxCodeMobile/Views/MobileBriefingView.swift +++ b/RxCodeMobile/Views/MobileBriefingView.swift @@ -20,6 +20,9 @@ struct GroupedBriefing: Identifiable { var key: BriefingGroupKey { BriefingGroupKey(projectId: projectId, branch: branch) } } +/// Briefing view for iPhone (stack-based navigation) and iPad detail column. +/// On iPhone, this shows a grid of cards that push to detail views. +/// On iPad in three-column mode, use `BriefingListView` for the content column instead. struct MobileBriefingView: View { @EnvironmentObject private var state: MobileAppState @Namespace private var glassNamespace @@ -260,6 +263,387 @@ struct MobileBriefingView: View { } } +// MARK: - Briefing List View (iPad Content Column) + +/// List view for the iPad three-column layout content column. +/// Shows briefing cards in a vertical list with selection support. +struct BriefingListView: View { + @EnvironmentObject private var state: MobileAppState + @Binding var selectedGroup: BriefingGroupKey? + @Namespace private var glassNamespace + + /// Selected project ids for filtering. Empty = show every project. + @State private var selectedProjectIds: Set = [] + + /// When false (default), only show briefings for each project's current branch. + @State private var showAllBranches: Bool = false + + var body: some View { + ScrollView { + LazyVStack(spacing: 12) { + if groups.isEmpty { + emptyState + .frame(maxWidth: .infinity, minHeight: 200) + } else { + GlassEffectContainer(spacing: 12) { + ForEach(groups) { group in + Button { + selectedGroup = group.key + } label: { + BriefingListCard( + group: group, + projectName: projectsById[group.projectId]?.name ?? "Unknown Project", + activeJobCount: activeJobCountByProject[group.projectId] ?? 0, + isSelected: selectedGroup == group.key, + namespace: glassNamespace + ) + } + .buttonStyle(BriefingListCardButtonStyle(isSelected: selectedGroup == group.key)) + .glassEffectID(group.id, in: glassNamespace) + } + } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + .scrollDismissesKeyboard(.interactively) + .navigationTitle("Briefing") + .toolbar { + if hasAnyData { + ToolbarItem(placement: .topBarTrailing) { + filterMenu + } + } + } + .refreshable { + await state.refreshSnapshot() + } + } + + // MARK: - Data + + private var projectsById: [UUID: Project] { + Dictionary(uniqueKeysWithValues: state.projects.map { ($0.id, $0) }) + } + + private var activeJobCountByProject: [UUID: Int] { + var counts: [UUID: Int] = [:] + for session in state.sessions where session.isStreaming { + counts[session.projectId, default: 0] += 1 + } + return counts + } + + private var allGroups: [GroupedBriefing] { + groupBriefings(briefings: state.branchBriefings, threads: state.threadSummaries) + } + + private var groups: [GroupedBriefing] { + let projectFiltered: [GroupedBriefing] + if selectedProjectIds.isEmpty { + projectFiltered = allGroups + } else { + projectFiltered = allGroups.filter { selectedProjectIds.contains($0.projectId) } + } + + if showAllBranches { + return projectFiltered + } + return projectFiltered.filter { group in + guard let branch = state.projectBranches[group.projectId] else { + return true + } + return group.branch == branch + } + } + + private var hasAnyData: Bool { + !state.branchBriefings.isEmpty || !state.threadSummaries.isEmpty + } + + private var projectsWithData: [Project] { + let ids = Set(allGroups.map(\.projectId)) + return state.projects.filter { ids.contains($0.id) } + } + + private var isFilterActive: Bool { + showAllBranches || !selectedProjectIds.isEmpty + } + + private func toggleProject(_ id: UUID) { + if selectedProjectIds.contains(id) { + selectedProjectIds.remove(id) + } else { + selectedProjectIds.insert(id) + } + } + + // MARK: - Filter Menu + + @ViewBuilder + private var filterMenu: some View { + Menu { + Section("Branches") { + Button { + showAllBranches = false + } label: { + if !showAllBranches { + Label("Current branch", systemImage: "checkmark") + } else { + Text("Current branch") + } + } + Button { + showAllBranches = true + } label: { + if showAllBranches { + Label("All branches", systemImage: "checkmark") + } else { + Text("All branches") + } + } + } + + let projects = projectsWithData + if projects.count > 1 { + Section("Projects") { + Button { + selectedProjectIds.removeAll() + } label: { + if selectedProjectIds.isEmpty { + Label("All projects", systemImage: "checkmark") + } else { + Text("All projects") + } + } + ForEach(projects) { project in + Button { + toggleProject(project.id) + } label: { + if selectedProjectIds.contains(project.id) { + Label(project.name, systemImage: "checkmark") + } else { + Text(project.name) + } + } + } + } + } + } label: { + Image(systemName: isFilterActive + ? "line.3.horizontal.decrease.circle.fill" + : "line.3.horizontal.decrease.circle") + } + } + + // MARK: - Empty State + + @ViewBuilder + private var emptyState: some View { + if hasAnyData { + ContentUnavailableView( + "Nothing to Show", + systemImage: "line.3.horizontal.decrease.circle", + description: Text( + showAllBranches + ? "No briefings match the selected projects." + : "No briefings for the current branch." + ) + ) + } else { + ContentUnavailableView( + "No Briefings Yet", + systemImage: "doc.text.magnifyingglass", + description: Text("Briefings appear here after threads finish on your Mac.") + ) + } + } +} + +// MARK: - Briefing List Card (Compact for Content Column) + +private struct BriefingListCard: View { + let group: GroupedBriefing + let projectName: String + let activeJobCount: Int + let isSelected: Bool + let namespace: Namespace.ID + + private var threadCount: Int { group.threads.count } + + var body: some View { + HStack(spacing: 12) { + // Project icon + ZStack { + Circle() + .fill(accentGradient.opacity(0.15)) + .frame(width: 40, height: 40) + + Image(systemName: "folder.fill") + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(accentGradient) + } + + // Content + VStack(alignment: .leading, spacing: 4) { + Text(projectName) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + + HStack(spacing: 6) { + Image(systemName: "arrow.triangle.branch") + .font(.system(size: 9, weight: .medium)) + Text(group.branch) + .font(.system(size: 12)) + .lineLimit(1) + } + .foregroundStyle(.secondary) + + // Metadata + FlowLayout(spacing: 8) { + if threadCount > 0 { + HStack(spacing: 4) { + Image(systemName: "bubble.left.and.bubble.right") + .font(.system(size: 9, weight: .medium)) + Text("\(threadCount)") + .font(.system(size: 11, weight: .medium)) + } + .foregroundStyle(.secondary) + } + + if activeJobCount > 0 { + HStack(spacing: 4) { + Circle() + .fill(.green) + .frame(width: 5, height: 5) + Text("\(activeJobCount) active") + .font(.system(size: 11, weight: .medium)) + } + .foregroundStyle(.green) + } + + HStack(spacing: 4) { + Image(systemName: "clock") + .font(.system(size: 9)) + Text(group.updatedAt.formatted(.relative(presentation: .named))) + .font(.system(size: 11)) + } + .foregroundStyle(.tertiary) + } + } + + Spacer(minLength: 0) + + Image(systemName: "chevron.right") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.tertiary) + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + + private var accentGradient: LinearGradient { + LinearGradient( + colors: [ + Color(red: 0.95, green: 0.6, blue: 0.4), + Color(red: 0.85, green: 0.5, blue: 0.55) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } +} + +// MARK: - Briefing List Card Button Style + +private struct BriefingListCardButtonStyle: ButtonStyle { + let isSelected: Bool + @Environment(\.colorScheme) private var colorScheme + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .background { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(backgroundColor(isPressed: configuration.isPressed)) + } + .glassEffect( + glassConfig(isPressed: configuration.isPressed), + in: .rect(cornerRadius: 14) + ) + .scaleEffect(configuration.isPressed ? 0.98 : 1.0) + .animation(.spring(duration: 0.2), value: configuration.isPressed) + } + + private func backgroundColor(isPressed: Bool) -> Color { + if isSelected { + return ClaudeTheme.accent.opacity(0.15) + } else if isPressed { + return Color.primary.opacity(0.05) + } else { + return .clear + } + } + + private func glassConfig(isPressed: Bool) -> Glass { + if isSelected { + return .regular.tint(ClaudeTheme.accent.opacity(0.3)).interactive() + } else { + return .regular.interactive() + } + } +} + +// MARK: - Flow Layout + +/// A layout that arranges views horizontally and wraps to the next line when needed. +private struct FlowLayout: Layout { + var spacing: CGFloat = 8 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let sizes = subviews.map { $0.sizeThatFits(.unspecified) } + return layout(sizes: sizes, containerWidth: proposal.width ?? .infinity).size + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + let sizes = subviews.map { $0.sizeThatFits(.unspecified) } + let offsets = layout(sizes: sizes, containerWidth: bounds.width).offsets + + for (index, subview) in subviews.enumerated() { + subview.place( + at: CGPoint(x: bounds.minX + offsets[index].x, y: bounds.minY + offsets[index].y), + proposal: ProposedViewSize(sizes[index]) + ) + } + } + + private func layout(sizes: [CGSize], containerWidth: CGFloat) -> (offsets: [CGPoint], size: CGSize) { + var offsets: [CGPoint] = [] + var currentX: CGFloat = 0 + var currentY: CGFloat = 0 + var lineHeight: CGFloat = 0 + var maxWidth: CGFloat = 0 + + for size in sizes { + if currentX + size.width > containerWidth && currentX > 0 { + currentX = 0 + currentY += lineHeight + spacing + lineHeight = 0 + } + + offsets.append(CGPoint(x: currentX, y: currentY)) + lineHeight = max(lineHeight, size.height) + currentX += size.width + spacing + maxWidth = max(maxWidth, currentX - spacing) + } + + return (offsets, CGSize(width: maxWidth, height: currentY + lineHeight)) + } +} + // MARK: - Briefing Card private struct BriefingCard: View { diff --git a/RxCodeMobile/Views/MobileChatView.swift b/RxCodeMobile/Views/MobileChatView.swift index a3b78d3..963d6a2 100644 --- a/RxCodeMobile/Views/MobileChatView.swift +++ b/RxCodeMobile/Views/MobileChatView.swift @@ -1,9 +1,15 @@ import Combine +import OSLog import RxCodeChatKit import RxCodeCore import RxCodeSync import SwiftUI +private let mobileChatLogger = Logger( + subsystem: "com.idealapp.RxCodeMobile", + category: "MobileChat" +) + /// Read-write chat view. User messages are forwarded to the desktop and the /// desktop agent's stream is mirrored back as `session_update` payloads. struct MobileChatView: View { @@ -42,9 +48,23 @@ struct MobileChatView: View { /// set when the user scrolls up so they can read history without being /// yanked back down. Re-armed once they return to the bottom. @State private var autoScrollEnabled = true + /// Full height of the scroll view (independent of the keyboard). + @State private var scrollViewHeight: CGFloat = 0 + /// Global `minY` of the scroll view. + @State private var scrollViewMinY: CGFloat = 0 + /// Global `minY` of the floating composer stack — its top is the boundary + /// between the visible chat area and the area covered by composer + keyboard. + @State private var composerMinY: CGFloat = 0 + /// `minY` of the latest user message in `chatContentCoordinateSpace`. + @State private var latestUserMinY: CGFloat = 0 + /// `minY` of the tail spacer in `chatContentCoordinateSpace`. + @State private var tailSpacerMinY: CGFloat = 0 + /// Set by `handleSend`; the next appended user message is the one we sent + /// and should be pinned to the top of the viewport. + @State private var awaitingSentUserMessage = false + @State private var distanceFromBottom: CGFloat = 0 private static let bottomAnchorID = "message-list-bottom" - private static let bottomContentPadding: CGFloat = 200 /// Distance from the bottom past which the "scroll to bottom" button shows. private static let nearBottomThreshold: CGFloat = 120 /// Distance from the true bottom within which auto-follow re-arms. Kept @@ -307,16 +327,35 @@ struct MobileChatView: View { MobileStreamingIndicator(isThinking: isThinking) .transition(.opacity) } + // Dynamic tail spacer: pads the latest turn so a sent + // message can be pinned to the top, and shrinks as the + // assistant response grows so the question stays put. + Color.clear + .frame(height: tailSpacerHeight) + .onGeometryChange(for: CGFloat.self) { proxy in + proxy.frame(in: .named(chatContentCoordinateSpace)).minY + } action: { newValue in + updateTailSpacerMinY(newValue) + } Color.clear - .frame(height: Self.bottomContentPadding) + .frame(height: 1) .id(Self.bottomAnchorID) } .padding(.horizontal, 16) .padding(.top, 8) .animation(.easeInOut(duration: 0.2), value: isStreaming) .animation(.easeInOut(duration: 0.2), value: isLoadingMore) + .coordinateSpace(.named(chatContentCoordinateSpace)) + .environment(\.chatTrackedMessageID, trackedUserMessageID) + .environment(\.chatTrackedMessageGeometry, updateLatestUserMinY) } .scrollDismissesKeyboard(.interactively) + .onGeometryChange(for: CGRect.self) { proxy in + proxy.frame(in: .global) + } action: { rect in + scrollViewHeight = rect.height + scrollViewMinY = rect.minY + } .onScrollPhaseChange { _, newPhase, _ in isUserDragging = newPhase == .tracking || newPhase == .interacting @@ -325,8 +364,14 @@ struct MobileChatView: View { .onScrollGeometryChange(for: CGFloat.self) { geo in geo.contentSize.height - geo.visibleRect.maxY } action: { _, distanceFromBottom in + self.distanceFromBottom = distanceFromBottom let near = distanceFromBottom <= Self.nearBottomThreshold - if isNearBottom != near { isNearBottom = near } + if isNearBottom != near { + mobileChatLogger.debug( + "[ScrollButton] visibility near=\(near, privacy: .public) distance=\(Double(distanceFromBottom), privacy: .public)" + ) + isNearBottom = near + } // Returning to the true bottom re-arms auto-follow. if distanceFromBottom <= Self.atBottomThreshold { autoScrollEnabled = true @@ -358,13 +403,21 @@ struct MobileChatView: View { } .onChange(of: messages.last?.id) { _, _ in // Keyed on the last message id so a prepended older page - // (which leaves the tail unchanged) doesn't yank to bottom. + // (which leaves the tail unchanged) doesn't yank the view. guard didEstablishInitialScroll else { didEstablishInitialScroll = true return } - guard autoScrollEnabled else { return } - withAnimation { proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) } + guard let last = messages.last else { return } + if last.role == .user, awaitingSentUserMessage { + // The message we just sent round-tripped back from the + // desktop — pin it to the top of the viewport. + awaitingSentUserMessage = false + autoScrollEnabled = false + pinSentMessageToTop(last.id, proxy: proxy) + } else if autoScrollEnabled { + withAnimation { proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) } + } } .onChange(of: messages.last?.content) { _, _ in guard didEstablishInitialScroll, autoScrollEnabled else { return } @@ -382,13 +435,21 @@ struct MobileChatView: View { pendingTopAnchorID = nil proxy.scrollTo(anchor, anchor: .top) } - .overlay(alignment: .bottomTrailing) { + .overlay(alignment: .bottomLeading) { if !isNearBottom { scrollToBottomButton(proxy: proxy) - .padding(.trailing, 16) - .padding(.bottom, 80) + .padding(.leading, 16) + .padding(.bottom, scrollToBottomButtonBottomPadding) .transition(.scale.combined(with: .opacity)) .zIndex(1) + .onAppear { + mobileChatLogger.debug( + "[ScrollButton] appeared bottomPadding=\(Double(scrollToBottomButtonBottomPadding), privacy: .public)" + ) + } + .onDisappear { + mobileChatLogger.debug("[ScrollButton] disappeared") + } } } .animation(.spring(duration: 0.25), value: isNearBottom) @@ -422,6 +483,11 @@ struct MobileChatView: View { } .background(Color.clear) .zIndex(2) + .onGeometryChange(for: CGFloat.self) { proxy in + proxy.frame(in: .global).minY + } action: { newValue in + composerMinY = newValue + } } } @@ -530,14 +596,82 @@ struct MobileChatView: View { // MARK: - Send / Stop private func handleSend(_ trimmed: String) { - // Sending always returns the user's focus to the latest messages. - autoScrollEnabled = true + // The sent message round-trips through the desktop; when it comes back + // it is pinned to the top. Suppress bottom-follow so the streaming + // reply fills the space below the question instead of yanking past it. + awaitingSentUserMessage = true + autoScrollEnabled = false Task { await state.sendUserMessage(trimmed, sessionID: sessionID) composer = "" } } + // MARK: - Pin to top + + /// The latest user message — the row pinned to the top on send and the + /// reference point for the dynamic tail spacer. + private var trackedUserMessageID: UUID? { + messages.last(where: { $0.role == .user })?.id + } + + /// Visible chat area above the floating composer. `composerMinY` rides up + /// with the keyboard, so this shrinks automatically when the keyboard opens. + private var availableHeight: CGFloat { + guard composerMinY > 0, scrollViewMinY > 0 else { return scrollViewHeight } + return max(0, composerMinY - scrollViewMinY) + } + + /// Minimum tail spacer: keeps the last line of a long reply clear of the + /// area covered by the composer (and keyboard). + private var minTailSpacer: CGFloat { + max(0, scrollViewHeight - availableHeight) + 16 + } + + /// Keeps the floating scroll button above the composer, including queued + /// message/question/plan banners and the keyboard-driven composer lift. + private var scrollToBottomButtonBottomPadding: CGFloat { + guard composerMinY > 0, scrollViewMinY > 0 else { return 80 } + let coveredHeight = max(0, scrollViewMinY + scrollViewHeight - composerMinY) + return coveredHeight + 12 + } + + /// Dynamic tail spacer height — pads the latest turn so the user message + /// can be pinned to the top, shrinking as the response grows. + private var tailSpacerHeight: CGFloat { + guard availableHeight > 0 else { return minTailSpacer } + let latestTurnHeight = max(0, tailSpacerMinY - latestUserMinY) + return max(minTailSpacer, availableHeight - latestTurnHeight) + } + + /// Pin a freshly sent user message to the top of the viewport. + private func pinSentMessageToTop(_ id: UUID, proxy: ScrollViewProxy) { + // Defer one runloop so the dynamic tail spacer grows before we scroll — + // otherwise there is not enough content to reach the top. + DispatchQueue.main.async { + withAnimation(.easeInOut(duration: 0.25)) { + proxy.scrollTo(id, anchor: .top) + } + } + } + + /// `minY` of the latest user message — fed back from `ChatMessageListView`. + private func updateLatestUserMinY(_ value: CGFloat) { + guard abs(value - latestUserMinY) > 0.5 else { return } + var t = Transaction() + t.animation = nil + withTransaction(t) { latestUserMinY = value } + } + + /// `minY` of the tail spacer — its distance from the user message is the + /// height of the latest turn. + private func updateTailSpacerMinY(_ value: CGFloat) { + guard abs(value - tailSpacerMinY) > 0.5 else { return } + var t = Transaction() + t.animation = nil + withTransaction(t) { tailSpacerMinY = value } + } + private func handleStop() { guard !sessionID.isEmpty else { return } Task { await state.cancelStream(sessionID: sessionID) } @@ -551,8 +685,12 @@ struct MobileChatView: View { private func scrollToBottomButton(proxy: ScrollViewProxy) -> some View { Button { + mobileChatLogger.info( + "[ScrollButton] tap session=\(sessionID, privacy: .public) messages=\(messages.count, privacy: .public) distance=\(Double(distanceFromBottom), privacy: .public) padding=\(Double(scrollToBottomButtonBottomPadding), privacy: .public)" + ) autoScrollEnabled = true - withAnimation { proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) } + isUserDragging = false + scrollToBottomFromButton(proxy) } label: { Image(systemName: "arrow.down") .font(.system(size: 14, weight: .semibold)) @@ -568,10 +706,25 @@ struct MobileChatView: View { ) .shadow(color: .black.opacity(0.12), radius: 6, x: 0, y: 2) } + .frame(width: 48, height: 48) + .contentShape(Circle()) .buttonStyle(.plain) .accessibilityLabel("Scroll to bottom") } + private func scrollToBottomFromButton(_ proxy: ScrollViewProxy) { + mobileChatLogger.debug("[ScrollButton] scroll immediate") + withAnimation(.easeInOut(duration: 0.2)) { + proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) + } + DispatchQueue.main.async { + mobileChatLogger.debug("[ScrollButton] scroll deferred") + withAnimation(.easeInOut(duration: 0.2)) { + proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) + } + } + } + // MARK: - Queued preview pill private var queuedPreviewPill: some View { diff --git a/RxCodeMobile/Views/MobileRunProfileEditorView.swift b/RxCodeMobile/Views/MobileRunProfileEditorView.swift new file mode 100644 index 0000000..4e75300 --- /dev/null +++ b/RxCodeMobile/Views/MobileRunProfileEditorView.swift @@ -0,0 +1,290 @@ +import RxCodeCore +import RxCodeSync +import SwiftUI + +/// Editor sheet for a single run profile on mobile. Scheme/Target fields are +/// backed by desktop auto-detection; the file fields (working directory, Xcode +/// project, Makefile) open the remote folder file-sheet. +struct MobileRunProfileEditorView: View { + @EnvironmentObject private var state: MobileAppState + @Environment(\.dismiss) private var dismiss + @State private var draft: RunProfile + @State private var activePicker: FolderPick? + let projectID: UUID + + /// Sentinel tag for the "Custom…" entry in detection-backed pickers. + private static let customTag = "__rxcode_custom_value__" + + /// Which field's remote file-sheet is currently open. + private enum FolderPick: Int, Identifiable { + case workingDirectory + case xcodeContainer + case makefile + var id: Int { rawValue } + } + + /// Desktop-detected runnables for this project, used to back the Scheme and + /// Target pickers. Empty until the detection request resolves. + private var detected: DetectedRunnables { + state.detectedRunnablesByProject[projectID] ?? DetectedRunnables() + } + + private var projectPath: String? { + state.projects.first { $0.id == projectID }?.path + } + + init(profile: RunProfile, projectID: UUID) { + self._draft = State(initialValue: profile) + self.projectID = projectID + } + + var body: some View { + Form { + Section("Configuration") { + TextField("Name", text: $draft.name) + Picker("Type", selection: Binding( + get: { draft.type }, + set: { type in + draft.type = type + if type == .xcode, draft.xcode == nil { draft.xcode = XcodeRunConfig() } + if type == .make, draft.make == nil { draft.make = MakeRunConfig() } + } + )) { + Text("Bash").tag(RunProfileType.bash) + Text("Xcode").tag(RunProfileType.xcode) + Text("Make").tag(RunProfileType.make) + } + } + + switch draft.type { + case .bash: + bashSection + case .xcode: + xcodeSection + case .make: + makeSection + } + } + .navigationTitle(draft.name.isEmpty ? "Run Profile" : draft.name) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + var saved = draft + saved.projectId = projectID + saved.updatedAt = Date() + Task { + await state.saveRunProfile(saved, projectID: projectID) + dismiss() + } + } + .disabled(draft.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + .sheet(item: $activePicker) { pick in + folderPickerSheet(for: pick) + } + } + + /// Builds the remote file-sheet for whichever field requested it. The + /// chosen path is converted to a project-relative value so it matches what + /// desktop auto-detection stores. + @ViewBuilder + private func folderPickerSheet(for pick: FolderPick) -> some View { + switch pick { + case .workingDirectory: + RemoteFolderPickerView(mode: .pickFolder( + title: "Working Directory", + startPath: projectPath, + onSelect: { draft.bash.workingDirectory = projectRelativePath($0) } + )) + .environmentObject(state) + case .xcodeContainer: + RemoteFolderPickerView(mode: .pickEntry(.init( + title: "Xcode Project", + startPath: projectPath, + includeFiles: false, + isTarget: { $0.name.hasSuffix(".xcodeproj") || $0.name.hasSuffix(".xcworkspace") }, + onSelect: { path in + var xcode = draft.xcode ?? XcodeRunConfig() + xcode.container = projectRelativePath(path) + xcode.isWorkspace = path.hasSuffix(".xcworkspace") + draft.xcode = xcode + } + ))) + .environmentObject(state) + case .makefile: + RemoteFolderPickerView(mode: .pickEntry(.init( + title: "Makefile", + startPath: projectPath, + includeFiles: true, + isTarget: { !$0.isDirectory }, + onSelect: { path in + var make = draft.make ?? MakeRunConfig() + make.makefile = projectRelativePath(path) + draft.make = make + } + ))) + .environmentObject(state) + } + } + + /// Converts an absolute desktop path to one relative to the project root + /// when it lives inside the project, so stored config matches desktop + /// detection (which uses names like `App.xcodeproj`). Paths outside the + /// project are kept absolute. + private func projectRelativePath(_ absolute: String) -> String { + guard let root = projectPath, !root.isEmpty else { return absolute } + let normalizedRoot = root.hasSuffix("/") ? String(root.dropLast()) : root + if absolute == normalizedRoot { return "" } + let prefix = normalizedRoot + "/" + if absolute.hasPrefix(prefix) { return String(absolute.dropFirst(prefix.count)) } + return absolute + } + + /// Trailing "Browse" button that opens the remote file-sheet for `pick`. + private func browseButton(_ pick: FolderPick) -> some View { + Button { + activePicker = pick + } label: { + Image(systemName: "folder") + } + .buttonStyle(.borderless) + .accessibilityLabel("Browse") + } + + private var bashSection: some View { + Section("Command") { + TextEditor(text: $draft.bash.command) + .font(.system(.body, design: .monospaced)) + .frame(minHeight: 90) + workingDirectoryField + } + } + + private var xcodeSection: some View { + Section("Xcode") { + let xcode = Binding( + get: { draft.xcode ?? XcodeRunConfig() }, + set: { draft.xcode = $0 } + ) + HStack { + TextField("Project / Workspace", text: xcode.container, prompt: Text("App.xcodeproj")) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + browseButton(.xcodeContainer) + } + Toggle("Use Workspace", isOn: xcode.isWorkspace) + schemeField(xcode) + TextField("Configuration", text: xcode.configuration) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + Picker("Action", selection: xcode.action) { + ForEach(XcodeAction.allCases, id: \.self) { action in + Text(action.rawValue.capitalized).tag(action) + } + } + TextField("Destination", text: xcode.destination, prompt: Text("Optional xcodebuild destination")) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + } + } + + private var makeSection: some View { + Section("Make") { + let make = Binding( + get: { draft.make ?? MakeRunConfig() }, + set: { draft.make = $0 } + ) + HStack { + TextField("Makefile", text: make.makefile, prompt: Text("Makefile")) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + browseButton(.makefile) + } + targetField(make) + TextField("Arguments", text: make.arguments) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + workingDirectoryField + } + } + + /// Working directory field shared by the bash and make sections, with a + /// "Browse" button that opens the remote folder file-sheet. + private var workingDirectoryField: some View { + HStack { + TextField( + "Working Directory", + text: $draft.bash.workingDirectory, + prompt: Text("Project root") + ) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + browseButton(.workingDirectory) + } + } + + /// Scheme field: a picker over desktop-detected Xcode schemes, falling back + /// to a free-text field when nothing was detected or "Custom…" is chosen. + @ViewBuilder + private func schemeField(_ xcode: Binding) -> some View { + let schemes = detected.xcode.compactMap { $0.xcode?.scheme } + if schemes.isEmpty { + TextField("Scheme", text: xcode.scheme) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + } else { + Picker("Scheme", selection: detectionSelection(xcode.scheme, options: schemes)) { + ForEach(schemes, id: \.self) { Text($0).tag($0) } + Text("Custom…").tag(Self.customTag) + } + if !schemes.contains(xcode.wrappedValue.scheme) { + TextField("Custom Scheme", text: xcode.scheme) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + } + } + } + + /// Target field: a picker over desktop-detected Make targets, falling back + /// to a free-text field when nothing was detected or "Custom…" is chosen. + @ViewBuilder + private func targetField(_ make: Binding) -> some View { + let targets = detected.make.compactMap { $0.make?.target } + if targets.isEmpty { + TextField("Target", text: make.target) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + } else { + Picker("Target", selection: detectionSelection(make.target, options: targets)) { + ForEach(targets, id: \.self) { Text($0).tag($0) } + Text("Custom…").tag(Self.customTag) + } + if !targets.contains(make.wrappedValue.target) { + TextField("Custom Target", text: make.target) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + } + } + } + + /// Bridges a free-text `String` binding to a picker selection: reports the + /// "Custom…" sentinel whenever the current value isn't a detected option. + private func detectionSelection(_ value: Binding, options: [String]) -> Binding { + Binding( + get: { options.contains(value.wrappedValue) ? value.wrappedValue : Self.customTag }, + set: { newValue in + if newValue == Self.customTag { + if options.contains(value.wrappedValue) { + value.wrappedValue = "" + } + } else { + value.wrappedValue = newValue + } + } + ) + } +} diff --git a/RxCodeMobile/Views/ProjectsSidebar.swift b/RxCodeMobile/Views/ProjectsSidebar.swift index 634678c..fe87b43 100644 --- a/RxCodeMobile/Views/ProjectsSidebar.swift +++ b/RxCodeMobile/Views/ProjectsSidebar.swift @@ -1,6 +1,6 @@ -import SwiftUI import RxCodeCore import RxCodeSync +import SwiftUI import TipKit struct ProjectsSidebar: View { @@ -8,98 +8,121 @@ struct ProjectsSidebar: View { @Binding var selected: UUID? @Binding var showingBriefing: Bool var showsBriefingItem = true + /// When true, uses Button with selection callback (iPad split view). + /// When false, uses NavigationLink for stack-based navigation (iPhone). var usesSelection = true @State private var searchText = "" @State private var showingRemoteFolderPicker = false + @Namespace private var glassNamespace var body: some View { - list - .navigationTitle("Projects") - .listStyle(.sidebar) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button { - showingRemoteFolderPicker = true - } label: { - Image(systemName: "folder.badge.plus") + content + .navigationTitle("Projects") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + showingRemoteFolderPicker = true + } label: { + Image(systemName: "folder.badge.plus") + } + .accessibilityLabel("Add Project") + .popoverTip(MobileTips.RemoteProjectTip(), arrowEdge: .top) } - .accessibilityLabel("Add Project") - .popoverTip(MobileTips.RemoteProjectTip(), arrowEdge: .top) } - } - .sheet(isPresented: $showingRemoteFolderPicker) { - RemoteFolderPickerView() - .environmentObject(state) - } - .refreshable { - await state.refreshSnapshot() - } - .searchable( - text: $searchText, - placement: .navigationBarDrawer(displayMode: .automatic), - prompt: "Search projects and threads" - ) - .popoverTip(MobileTips.SearchTip(), arrowEdge: .top) - .autocorrectionDisabled(true) - .textInputAutocapitalization(.never) - .onChange(of: searchText) { _, newValue in - state.updateSearchQuery(newValue) - } - .onDisappear { - state.updateSearchQuery("") - } - .onChange(of: state.lastCreatedProjectID) { _, newValue in - guard let newValue else { return } - selected = newValue - showingBriefing = false - } - } - - @ViewBuilder - private var list: some View { - if usesSelection { - List(selection: $selected) { - listContent + .sheet(isPresented: $showingRemoteFolderPicker) { + RemoteFolderPickerView() + .environmentObject(state) } - } else { - List { - listContent + .refreshable { + await state.refreshSnapshot() + } + .modifier(ProjectSidebarSearchModifier(isEnabled: showsSearch, searchText: $searchText)) + .modifier(ProjectSidebarSearchTipModifier(isEnabled: showsSearch)) + .autocorrectionDisabled(true) + .textInputAutocapitalization(.never) + .onChange(of: searchText) { _, newValue in + if showsSearch { + state.updateSearchQuery(newValue) + } + } + .onDisappear { + state.updateSearchQuery("") + } + .onChange(of: showsSearch) { _, newValue in + if !newValue { + searchText = "" + state.updateSearchQuery("") + } + } + .onChange(of: state.lastCreatedProjectID) { _, newValue in + guard let newValue else { return } + selected = newValue + showingBriefing = false } - } } + // MARK: - Content + @ViewBuilder - private var listContent: some View { - if searchText.isEmpty { + private var content: some View { + if !showsSearch || searchText.isEmpty { defaultContent } else { searchResultsContent } } + // MARK: - Default Content (Glass Cards) + @ViewBuilder private var defaultContent: some View { - if showsBriefingItem { - Button { - selected = nil - showingBriefing = true - } label: { - Label("Briefing", systemImage: "doc.text") - .font(.headline) - .foregroundStyle(showingBriefing ? Color.accentColor : Color.primary) - } - .popoverTip(MobileTips.BriefingTip(), arrowEdge: .trailing) - } + ScrollView { + LazyVStack(spacing: 12) { + // Briefing Card + if showsBriefingItem { + BriefingNavigationCard( + isSelected: showingBriefing, + usesNavigationLink: false, // Briefing always uses callback + namespace: glassNamespace + ) { + selected = nil + showingBriefing = true + } + .popoverTip(MobileTips.BriefingTip(), arrowEdge: .trailing) + } - Section("Projects") { - ForEach(state.projects) { project in - NavigationLink(value: project.id) { - projectRow(project) + // Projects Section + if !state.projects.isEmpty { + GlassEffectContainer(spacing: 12) { + ForEach(state.projects) { project in + GlassProjectCard( + project: project, + threadCount: threadCount(for: project.id), + activeJobCount: activeJobCount(for: project.id), + lastActivity: lastActivity(for: project.id), + isSelected: selected == project.id, + usesNavigationLink: !usesSelection, + namespace: glassNamespace, + onSelect: usesSelection ? { + selected = project.id + showingBriefing = false + } : nil + ) + } + } + } else { + emptyProjectsView } } + .padding(.horizontal, 16) + .padding(.vertical, 12) } + .scrollDismissesKeyboard(.interactively) + .animation(.spring(duration: 0.3), value: state.projects.map(\.id)) } + // MARK: - Search Results Content + @ViewBuilder private var searchResultsContent: some View { let projectMatches = state.projects.filter { project in @@ -108,90 +131,359 @@ struct ProjectsSidebar: View { let threadHits = state.searchThreadHits let projectsByID = Dictionary(uniqueKeysWithValues: state.projects.map { ($0.id, $0) }) - if projectMatches.isEmpty && threadHits.isEmpty { - Section { - if state.isSearching { - HStack(spacing: 8) { - ProgressView() - Text("Searching…") - .foregroundStyle(.secondary) + ScrollView { + LazyVStack(spacing: 16) { + if projectMatches.isEmpty && threadHits.isEmpty { + if state.isSearching { + HStack(spacing: 8) { + ProgressView() + Text("Searching…") + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.top, 40) + } else { + ContentUnavailableView.search(text: searchText) + .padding(.top, 40) } } else { - ContentUnavailableView.search(text: searchText) - .listRowBackground(Color.clear) - } - } - } else { - if !projectMatches.isEmpty { - Section("Projects") { - ForEach(projectMatches) { project in - NavigationLink(value: project.id) { - projectRow(project) + // Project matches + if !projectMatches.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("Projects") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.secondary) + .padding(.leading, 4) + + GlassEffectContainer(spacing: 10) { + ForEach(projectMatches) { project in + GlassProjectCard( + project: project, + threadCount: threadCount(for: project.id), + activeJobCount: activeJobCount(for: project.id), + lastActivity: lastActivity(for: project.id), + isSelected: selected == project.id, + usesNavigationLink: !usesSelection, + namespace: glassNamespace, + onSelect: usesSelection ? { + selected = project.id + showingBriefing = false + } : nil + ) + } + } } } - } - } - if !threadHits.isEmpty { - Section("Threads") { - ForEach(threadHits) { hit in - NavigationLink(value: hit.sessionID) { - threadHitRow(hit, project: projectsByID[hit.projectID]) + // Thread matches + if !threadHits.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("Threads") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.secondary) + .padding(.leading, 4) + + GlassEffectContainer(spacing: 10) { + ForEach(threadHits) { hit in + SearchThreadHitCard( + hit: hit, + project: projectsByID[hit.projectID], + namespace: glassNamespace + ) + } + } } } } } + .padding(.horizontal, 16) + .padding(.vertical, 12) } + .scrollDismissesKeyboard(.interactively) } - private func threadHitRow(_ hit: SearchHit, project: Project?) -> some View { - let title = hit.title.isEmpty ? ChatSession.defaultTitle : hit.title - let showSnippet = !hit.snippet.isEmpty && hit.snippet != title + // MARK: - Empty State - return HStack(spacing: 8) { - Image(systemName: "bubble.left") + private var emptyProjectsView: some View { + VStack(spacing: 16) { + Image(systemName: "folder.badge.questionmark") + .font(.system(size: 48, weight: .light)) + .foregroundStyle(.tertiary) + + Text("No Projects") + .font(.title3.weight(.medium)) .foregroundStyle(.secondary) - VStack(alignment: .leading, spacing: 2) { - Text(title) - .font(.body) - .lineLimit(1) + Text("Add a project from your Mac to get started.") + .font(.subheadline) + .foregroundStyle(.tertiary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) - if showSnippet { - Text(hit.snippet) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(2) - } + Button { + showingRemoteFolderPicker = true + } label: { + Label("Add Project", systemImage: "plus") + .font(.subheadline.weight(.medium)) + } + .buttonStyle(.glass) + .padding(.top, 8) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 60) + } - if let project { - Text(project.name) - .font(.caption2) - .foregroundStyle(.tertiary) - .lineLimit(1) - } + // MARK: - Helpers + + private var showsSearch: Bool { + !usesSelection + } + + private func threadCount(for projectID: UUID) -> Int { + state.sessions.filter { $0.projectId == projectID && !$0.isArchived }.count + } + + private func activeJobCount(for projectID: UUID) -> Int { + state.sessions.filter { $0.projectId == projectID && $0.isStreaming }.count + } + + private func lastActivity(for projectID: UUID) -> Date? { + state.sessions + .filter { $0.projectId == projectID } + .map(\.updatedAt) + .max() + } + + private static func truncatedPath(_ path: String) -> String { + guard path.hasPrefix("/Users/") else { return path } + let afterUsers = path.dropFirst("/Users/".count) + guard let slash = afterUsers.firstIndex(of: "/") else { return path } + return "~" + afterUsers[slash...] + } +} + +private struct ProjectSidebarSearchModifier: ViewModifier { + let isEnabled: Bool + @Binding var searchText: String + + @ViewBuilder + func body(content: Content) -> some View { + if isEnabled { + content.searchable( + text: $searchText, + placement: .navigationBarDrawer(displayMode: .automatic), + prompt: "Search projects and threads" + ) + } else { + content + } + } +} + +private struct ProjectSidebarSearchTipModifier: ViewModifier { + let isEnabled: Bool + + @ViewBuilder + func body(content: Content) -> some View { + if isEnabled { + content.popoverTip(MobileTips.SearchTip(), arrowEdge: .top) + } else { + content + } + } +} + +// MARK: - Briefing Navigation Card + +private struct BriefingNavigationCard: View { + let isSelected: Bool + var usesNavigationLink: Bool = false + let namespace: Namespace.ID + var onSelect: (() -> Void)? + + var body: some View { + if usesNavigationLink { + // Not used for briefing, but keeping pattern consistent + Button { onSelect?() } label: { cardContent } + .buttonStyle(GlassProjectCardButtonStyle(isSelected: isSelected)) + .glassEffectID("briefing", in: namespace) + } else { + Button { onSelect?() } label: { cardContent } + .buttonStyle(GlassProjectCardButtonStyle(isSelected: isSelected)) + .glassEffectID("briefing", in: namespace) + } + } + + private var cardContent: some View { + HStack(spacing: 14) { + // Icon + ZStack { + Circle() + .fill(briefingGradient.opacity(0.15)) + .frame(width: 44, height: 44) + + Image(systemName: "doc.text.fill") + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(briefingGradient) } + + VStack(alignment: .leading, spacing: 4) { + Text("Briefing") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(.primary) + + Text("Daily summary of your projects") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + } + + Spacer(minLength: 0) + + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.tertiary) } - .padding(.vertical, 2) + .padding(.horizontal, 16) + .padding(.vertical, 14) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) } - private func projectRow(_ project: Project) -> some View { - HStack(spacing: 8) { - Image(systemName: "folder.fill") - .foregroundStyle(.secondary) + private var briefingGradient: LinearGradient { + LinearGradient( + colors: [ + Color(red: 0.4, green: 0.6, blue: 0.9), + Color(red: 0.5, green: 0.4, blue: 0.85) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } +} + +// MARK: - Glass Project Card - VStack(alignment: .leading, spacing: 2) { +private struct GlassProjectCard: View { + let project: Project + let threadCount: Int + let activeJobCount: Int + let lastActivity: Date? + let isSelected: Bool + /// When true, uses NavigationLink for stack-based navigation (iPhone). + /// When false, uses Button with onSelect callback for selection-based navigation (iPad). + var usesNavigationLink: Bool = true + let namespace: Namespace.ID + var onSelect: (() -> Void)? + + var body: some View { + if usesNavigationLink { + NavigationLink(value: project.id) { + cardContent + } + .buttonStyle(GlassProjectCardButtonStyle(isSelected: isSelected)) + .glassEffectID(project.id.uuidString, in: namespace) + } else { + Button { + onSelect?() + } label: { + cardContent + } + .buttonStyle(GlassProjectCardButtonStyle(isSelected: isSelected)) + .glassEffectID(project.id.uuidString, in: namespace) + } + } + + private var cardContent: some View { + HStack(spacing: 14) { + // Project icon + projectIcon + + // Content + VStack(alignment: .leading, spacing: 6) { + // Title Text(project.name) - .font(.body) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(.primary) .lineLimit(1) + // Path Text(Self.truncatedPath(project.path)) - .font(.caption) + .font(.system(size: 12)) .foregroundStyle(.tertiary) .lineLimit(1) + + // Metadata row - uses FlowLayout to wrap gracefully on narrow sidebars + FlowLayout(spacing: 8) { + // Thread count + if threadCount > 0 { + HStack(spacing: 4) { + Image(systemName: "bubble.left.and.bubble.right") + .font(.system(size: 10, weight: .medium)) + Text("\(threadCount)") + .font(.system(size: 12, weight: .medium)) + } + .foregroundStyle(.secondary) + } + + // Active jobs + if activeJobCount > 0 { + HStack(spacing: 4) { + Circle() + .fill(.green) + .frame(width: 6, height: 6) + Text("\(activeJobCount) active") + .font(.system(size: 12, weight: .medium)) + } + .foregroundStyle(.green) + } + + // Last activity + if let lastActivity { + HStack(spacing: 4) { + Image(systemName: "clock") + .font(.system(size: 10)) + Text(compactElapsed(since: lastActivity)) + .font(.system(size: 12)) + } + .foregroundStyle(.tertiary) + } + } } + + Spacer(minLength: 0) + + // Chevron + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.tertiary) + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + + private var projectIcon: some View { + ZStack { + Circle() + .fill(accentGradient.opacity(0.15)) + .frame(width: 44, height: 44) + + Image(systemName: "folder.fill") + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(accentGradient) } - .padding(.vertical, 2) + } + + private var accentGradient: LinearGradient { + LinearGradient( + colors: [ + Color(red: 0.95, green: 0.6, blue: 0.4), + Color(red: 0.85, green: 0.5, blue: 0.55) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) } private static func truncatedPath(_ path: String) -> String { @@ -200,4 +492,361 @@ struct ProjectsSidebar: View { guard let slash = afterUsers.firstIndex(of: "/") else { return path } return "~" + afterUsers[slash...] } + + private func compactElapsed(since date: Date, now: Date = Date()) -> String { + let seconds = max(0, Int(now.timeIntervalSince(date))) + if seconds < 60 { return "now" } + + let minutes = seconds / 60 + if minutes < 60 { return "\(minutes)m" } + + let hours = minutes / 60 + if hours < 24 { return "\(hours)h" } + + let days = hours / 24 + if days < 7 { return "\(days)d" } + + let weeks = days / 7 + if weeks < 52 { return "\(weeks)w" } + + return "\(days / 365)y" + } +} + +// MARK: - Search Thread Hit Card + +struct SearchThreadHitCard: View { + let hit: SearchHit + let project: Project? + let namespace: Namespace.ID + + private var title: String { + hit.title.isEmpty ? ChatSession.defaultTitle : hit.title + } + + private var showSnippet: Bool { + !hit.snippet.isEmpty && hit.snippet != title + } + + var body: some View { + NavigationLink(value: hit.sessionID) { + HStack(spacing: 12) { + Image(systemName: "bubble.left.fill") + .font(.system(size: 14)) + .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(.primary) + .lineLimit(1) + + if showSnippet { + Text(hit.snippet) + .font(.system(size: 13)) + .foregroundStyle(.secondary) + .lineLimit(2) + } + + if let project { + Text(project.name) + .font(.system(size: 11)) + .foregroundStyle(.tertiary) + .lineLimit(1) + } + } + + Spacer(minLength: 0) + + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.tertiary) + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(GlassProjectCardButtonStyle(isSelected: false)) + .glassEffectID("search-\(hit.sessionID)", in: namespace) + } +} + +// MARK: - Glass Project Card Button Style + +private struct GlassProjectCardButtonStyle: ButtonStyle { + let isSelected: Bool + @Environment(\.colorScheme) private var colorScheme + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .background { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(backgroundColor(isPressed: configuration.isPressed)) + } + .glassEffect( + glassConfig(isPressed: configuration.isPressed), + in: .rect(cornerRadius: 16) + ) + .scaleEffect(configuration.isPressed ? 0.98 : 1.0) + .animation(.spring(duration: 0.2), value: configuration.isPressed) + } + + private func backgroundColor(isPressed: Bool) -> Color { + if isSelected { + return ClaudeTheme.accent.opacity(0.15) + } else if isPressed { + return Color.primary.opacity(0.05) + } else { + return .clear + } + } + + private func glassConfig(isPressed: Bool) -> Glass { + if isSelected { + return .regular.tint(ClaudeTheme.accent.opacity(0.3)).interactive() + } else { + return .regular.interactive() + } + } +} + +// MARK: - Flow Layout + +/// A layout that arranges views horizontally and wraps to the next line when needed. +/// Used for metadata rows that need to adapt to narrow sidebar widths on iPad. +private struct FlowLayout: Layout { + var spacing: CGFloat = 8 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let sizes = subviews.map { $0.sizeThatFits(.unspecified) } + return layout(sizes: sizes, containerWidth: proposal.width ?? .infinity).size + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + let sizes = subviews.map { $0.sizeThatFits(.unspecified) } + let offsets = layout(sizes: sizes, containerWidth: bounds.width).offsets + + for (index, subview) in subviews.enumerated() { + subview.place( + at: CGPoint(x: bounds.minX + offsets[index].x, y: bounds.minY + offsets[index].y), + proposal: ProposedViewSize(sizes[index]) + ) + } + } + + private func layout(sizes: [CGSize], containerWidth: CGFloat) -> (offsets: [CGPoint], size: CGSize) { + var offsets: [CGPoint] = [] + var currentX: CGFloat = 0 + var currentY: CGFloat = 0 + var lineHeight: CGFloat = 0 + var maxWidth: CGFloat = 0 + + for size in sizes { + if currentX + size.width > containerWidth && currentX > 0 { + // Wrap to next line + currentX = 0 + currentY += lineHeight + spacing + lineHeight = 0 + } + + offsets.append(CGPoint(x: currentX, y: currentY)) + lineHeight = max(lineHeight, size.height) + currentX += size.width + spacing + maxWidth = max(maxWidth, currentX - spacing) + } + + return (offsets, CGSize(width: maxWidth, height: currentY + lineHeight)) + } +} + +// MARK: - Preview + +#Preview("Projects Sidebar") { + @Previewable @State var selected: UUID? = nil + @Previewable @State var showingBriefing = false + + let state = MobileAppState() + let projectIDs = [UUID(), UUID(), UUID()] + + state.projects = [ + Project( + id: projectIDs[0], + name: "RxCode", + path: "/Users/developer/Desktop/rxlab/RxCode" + ), + Project( + id: projectIDs[1], + name: "MyApp", + path: "/Users/developer/Projects/MyApp" + ), + Project( + id: projectIDs[2], + name: "SwiftPackage", + path: "/Users/developer/Code/SwiftPackage" + ) + ] + + state.sessions = [ + SessionSummary( + id: "1", + projectId: projectIDs[0], + title: "Building feature", + updatedAt: Date(), + isPinned: false, + isArchived: false, + isStreaming: true, + attention: nil, + todos: nil, + hasUncheckedCompletion: false + ), + SessionSummary( + id: "2", + projectId: projectIDs[0], + title: "Fix bug", + updatedAt: Date().addingTimeInterval(-3600), + isPinned: false, + isArchived: false, + isStreaming: false, + attention: nil, + todos: nil, + hasUncheckedCompletion: false + ), + SessionSummary( + id: "3", + projectId: projectIDs[1], + title: "Review PR", + updatedAt: Date().addingTimeInterval(-86400), + isPinned: false, + isArchived: false, + isStreaming: false, + attention: nil, + todos: nil, + hasUncheckedCompletion: false + ) + ] + + return NavigationStack { + ProjectsSidebar( + selected: $selected, + showingBriefing: $showingBriefing + ) + } + .environmentObject(state) +} + +#Preview("Projects Sidebar - Empty") { + @Previewable @State var selected: UUID? = nil + @Previewable @State var showingBriefing = false + + let state = MobileAppState() + state.projects = [] + + return NavigationStack { + ProjectsSidebar( + selected: $selected, + showingBriefing: $showingBriefing + ) + } + .environmentObject(state) +} + +#Preview("Projects Sidebar - Selected") { + @Previewable @State var showingBriefing = false + @Previewable @State var selected: UUID? = nil + + let state = MobileAppState() + let projectID = UUID() + + state.projects = [ + Project( + id: projectID, + name: "RxCode", + path: "/Users/developer/Desktop/rxlab/RxCode" + ) + ] + + state.sessions = [ + SessionSummary( + id: "1", + projectId: projectID, + title: "Building feature", + updatedAt: Date(), + isPinned: false, + isArchived: false, + isStreaming: true, + attention: nil, + todos: nil, + hasUncheckedCompletion: false + ), + SessionSummary( + id: "2", + projectId: projectID, + title: "Another thread", + updatedAt: Date().addingTimeInterval(-600), + isPinned: false, + isArchived: false, + isStreaming: false, + attention: nil, + todos: nil, + hasUncheckedCompletion: false + ) + ] + + return NavigationStack { + ProjectsSidebar( + selected: .constant(projectID), + showingBriefing: $showingBriefing + ) + } + .environmentObject(state) +} + +#Preview("Projects Sidebar - iPhone (NavigationLink)") { + @Previewable @State var selected: UUID? = nil + @Previewable @State var showingBriefing = false + + let state = MobileAppState() + let projectIDs = [UUID(), UUID()] + + state.projects = [ + Project( + id: projectIDs[0], + name: "RxCode", + path: "/Users/developer/Desktop/rxlab/RxCode" + ), + Project( + id: projectIDs[1], + name: "MyApp", + path: "/Users/developer/Projects/MyApp" + ) + ] + + state.sessions = [ + SessionSummary( + id: "1", + projectId: projectIDs[0], + title: "Building feature", + updatedAt: Date(), + isPinned: false, + isArchived: false, + isStreaming: true, + attention: nil, + todos: nil, + hasUncheckedCompletion: false + ) + ] + + return NavigationStack { + ProjectsSidebar( + selected: $selected, + showingBriefing: $showingBriefing, + showsBriefingItem: false, + usesSelection: false + ) + .navigationDestination(for: UUID.self) { projectID in + Text("Sessions for project \(projectID)") + } + } + .environmentObject(state) } diff --git a/RxCodeMobile/Views/RemoteFolderPickerView.swift b/RxCodeMobile/Views/RemoteFolderPickerView.swift index 9e173bb..ed38966 100644 --- a/RxCodeMobile/Views/RemoteFolderPickerView.swift +++ b/RxCodeMobile/Views/RemoteFolderPickerView.swift @@ -1,18 +1,86 @@ import SwiftUI import RxCodeSync +/// Browses the paired Mac's folder tree. Reused for three jobs: adding a new +/// project, picking an arbitrary folder (e.g. a run profile working +/// directory), and picking an entry such as an `.xcodeproj` bundle or a +/// Makefile. The chosen value is handed back to the caller. struct RemoteFolderPickerView: View { + /// Describes a "pick an entry" job — a file or bundle the user taps to + /// select, rather than navigating into. + struct EntryPick { + let title: String + let startPath: String? + /// Ask the desktop to include plain files in the tree. + let includeFiles: Bool + /// `true` for child nodes that are the pick target — tapping one + /// selects it instead of navigating into it. + let isTarget: (RemoteFolderNode) -> Bool + let onSelect: (String) -> Void + } + + /// What the picker does with the entry the user confirms. + enum Mode { + /// Adds the chosen folder as a new project (original behavior). + case addProject + /// Hands the chosen folder path back to the caller. `startPath` is where + /// browsing begins (`nil` = the picker roots). + case pickFolder(title: String, startPath: String?, onSelect: (String) -> Void) + /// Hands back a file or bundle the user taps in the tree. + case pickEntry(EntryPick) + } + @EnvironmentObject private var state: MobileAppState @Environment(\.dismiss) private var dismiss @State private var navigationPath: [FolderLocation] = [] + var mode: Mode = .addProject + private var currentNode: RemoteFolderNode? { state.remoteFolderRoot } - private var canAddCurrentFolder: Bool { - guard let currentNode else { return false } - return currentNode.isSelectable && !currentNode.path.isEmpty + private var isPickFolder: Bool { + if case .pickFolder = mode { return true } + return false + } + + private var entryPick: EntryPick? { + if case .pickEntry(let pick) = mode { return pick } + return nil + } + + private var navigationTitle: String { + switch mode { + case .addProject: return "Add Project" + case .pickFolder(let title, _, _): return title + case .pickEntry(let pick): return pick.title + } + } + + private var startPath: String? { + switch mode { + case .addProject: return nil + case .pickFolder(_, let startPath, _): return startPath + case .pickEntry(let pick): return pick.startPath + } + } + + private var includeFiles: Bool { + entryPick?.includeFiles ?? false + } + + /// `true` while browsing for a single folder result (working directory) or + /// a project — the toolbar carries a "confirm current folder" button. + private var hasConfirmButton: Bool { + entryPick == nil + } + + private var canConfirmCurrentFolder: Bool { + guard let currentNode, !currentNode.path.isEmpty else { return false } + // Any folder is a valid working directory; project creation keeps the + // desktop's `isSelectable` gate. + return isPickFolder ? true : currentNode.isSelectable } var body: some View { @@ -23,12 +91,16 @@ struct RemoteFolderPickerView: View { } } .task { - if state.remoteFolderRoot == nil { + if entryPick != nil || isPickFolder { + await state.requestRemoteFolder(path: startPath, includeFiles: includeFiles) + } else if state.remoteFolderRoot == nil { await state.requestRemoteFolder() } } .onChange(of: navigationPath) { _, newValue in - Task { await state.requestRemoteFolder(path: newValue.last?.path) } + Task { + await state.requestRemoteFolder(path: newValue.last?.path, includeFiles: includeFiles) + } } .alert("Unable to Load Folder", isPresented: folderErrorBinding) { Button("OK", role: .cancel) { state.remoteFolderError = nil } @@ -41,7 +113,7 @@ struct RemoteFolderPickerView: View { Text(state.remoteProjectCreateError ?? "Unknown error.") } .onChange(of: state.lastCreatedProjectID) { _, newValue in - if newValue != nil { + if case .addProject = mode, newValue != nil { dismiss() } } @@ -54,23 +126,21 @@ struct RemoteFolderPickerView: View { currentFolderRow(currentNode) } - Section("Folders") { + Section(includeFiles ? "Contents" : "Folders") { if currentNode.children.isEmpty, !state.remoteFolderIsLoading { ContentUnavailableView( - "No Folders", + includeFiles ? "Empty Folder" : "No Folders", systemImage: "folder", - description: Text("This location has no visible folders.") + description: Text( + includeFiles + ? "This location has nothing to show." + : "This location has no visible folders." + ) ) .listRowBackground(Color.clear) } else { ForEach(currentNode.children) { child in - Button { - open(child) - } label: { - folderRow(child) - } - .buttonStyle(.plain) - .disabled(state.remoteFolderIsLoading) + childRow(child) } } } @@ -85,7 +155,7 @@ struct RemoteFolderPickerView: View { .listRowBackground(Color.clear) } } - .navigationTitle("Add Project") + .navigationTitle(navigationTitle) .navigationBarTitleDisplayMode(.inline) .overlay { if state.remoteFolderIsLoading { @@ -95,13 +165,15 @@ struct RemoteFolderPickerView: View { } .toolbar { ToolbarItemGroup(placement: .topBarTrailing) { - Button { - addCurrentFolder() - } label: { - Image(systemName: "plus") + if hasConfirmButton { + Button { + confirmCurrentFolder() + } label: { + Image(systemName: isPickFolder ? "checkmark" : "plus") + } + .accessibilityLabel(isPickFolder ? "Select Folder" : "Add Project") + .disabled(!canConfirmCurrentFolder || state.remoteProjectCreateInFlight) } - .accessibilityLabel("Add Project") - .disabled(!canAddCurrentFolder || state.remoteProjectCreateInFlight) Button { dismiss() @@ -112,7 +184,7 @@ struct RemoteFolderPickerView: View { } ToolbarItem(placement: .bottomBar) { - if state.remoteProjectCreateInFlight { + if hasConfirmButton, state.remoteProjectCreateInFlight { ProgressView() } } @@ -127,6 +199,32 @@ struct RemoteFolderPickerView: View { } } + /// Renders one child node: a selectable entry (file / bundle), a navigable + /// folder, or — rarely — a non-target file shown dimmed. + @ViewBuilder + private func childRow(_ child: RemoteFolderNode) -> some View { + if let entryPick, entryPick.isTarget(child) { + Button { + entryPick.onSelect(child.path) + dismiss() + } label: { + entryTargetRow(child) + } + .buttonStyle(.plain) + .disabled(state.remoteFolderIsLoading) + } else if child.isDirectory { + Button { + open(child) + } label: { + folderRow(child) + } + .buttonStyle(.plain) + .disabled(state.remoteFolderIsLoading) + } else { + fileRow(child) + } + } + private func currentFolderRow(_ node: RemoteFolderNode) -> some View { VStack(alignment: .leading, spacing: 4) { Label(node.name, systemImage: node.path.isEmpty ? "macbook" : "folder.fill") @@ -167,6 +265,49 @@ struct RemoteFolderPickerView: View { .contentShape(Rectangle()) } + /// A tappable file / bundle the caller is picking. + private func entryTargetRow(_ node: RemoteFolderNode) -> some View { + HStack(spacing: 10) { + Image(systemName: entryIcon(node)) + .foregroundStyle(Color.accentColor) + + VStack(alignment: .leading, spacing: 2) { + Text(node.name) + .foregroundStyle(.primary) + .lineLimit(1) + + Text(node.path) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer() + + Image(systemName: "checkmark.circle") + .foregroundStyle(Color.accentColor) + } + .contentShape(Rectangle()) + } + + /// A non-target file — shown dimmed so the list stays readable. + private func fileRow(_ node: RemoteFolderNode) -> some View { + HStack(spacing: 10) { + Image(systemName: "doc") + .foregroundStyle(.tertiary) + Text(node.name) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + + private func entryIcon(_ node: RemoteFolderNode) -> String { + if node.name.hasSuffix(".xcodeproj") || node.name.hasSuffix(".xcworkspace") { + return "hammer.fill" + } + return node.isDirectory ? "folder.fill" : "doc.text.fill" + } + private var folderErrorBinding: Binding { Binding( get: { state.remoteFolderError != nil }, @@ -185,9 +326,17 @@ struct RemoteFolderPickerView: View { navigationPath = folderNavigationPath(for: node.path) } - private func addCurrentFolder() { - guard let currentNode, canAddCurrentFolder else { return } - Task { await state.createProjectFromRemoteFolder(path: currentNode.path) } + private func confirmCurrentFolder() { + guard let currentNode, canConfirmCurrentFolder else { return } + switch mode { + case .addProject: + Task { await state.createProjectFromRemoteFolder(path: currentNode.path) } + case .pickFolder(_, _, let onSelect): + onSelect(currentNode.path) + dismiss() + case .pickEntry: + break + } } private func folderNavigationPath(for path: String) -> [FolderLocation] { diff --git a/RxCodeMobile/Views/RootView.swift b/RxCodeMobile/Views/RootView.swift index b003c83..baaedfe 100644 --- a/RxCodeMobile/Views/RootView.swift +++ b/RxCodeMobile/Views/RootView.swift @@ -1,5 +1,8 @@ import SwiftUI import RxCodeSync +import os.log + +private let logger = Logger(subsystem: "com.idealapp.RxCode", category: "RootView") private enum MobileRootTab: Hashable { case briefing @@ -14,10 +17,14 @@ struct RootView: View { @EnvironmentObject private var state: MobileAppState @State private var selectedProject: UUID? @State private var selectedSession: String? + @State private var selectedBriefingGroup: BriefingGroupKey? + @State private var briefingDetailPath = NavigationPath() @State private var showingBriefing = true @State private var showSettings = false @State private var selectedTab: MobileRootTab = .briefing @State private var projectsPath = NavigationPath() + @State private var minimumLoadingTimeElapsed = false + @State private var connectionTimedOut = false var body: some View { Group { @@ -34,17 +41,37 @@ struct RootView: View { .mobileDismissesKeyboardOnScroll() } + /// Whether the loading splash should be dismissed (data loaded AND minimum time elapsed, but NOT timed out) + private var shouldShowContent: Bool { + let result = state.hasReceivedInitialSnapshot && minimumLoadingTimeElapsed && !connectionTimedOut + logger.debug("shouldShowContent: \(result) (hasSnapshot: \(self.state.hasReceivedInitialSnapshot), minTimeElapsed: \(self.minimumLoadingTimeElapsed), timedOut: \(self.connectionTimedOut))") + return result + } + private var paired: some View { - Group { - if compactClass == .compact { - phoneTabs - } else { - ipadSplitView + ZStack { + // Main content - always present but may be hidden + mainContent + .opacity(shouldShowContent ? 1 : 0) + + // Loading splash - shown until first snapshot AND minimum 2 seconds + if !shouldShowContent { + SyncLoadingView( + isTimedOut: connectionTimedOut, + onRetry: { + connectionTimedOut = false + Task { + await retryConnection() + } + } + ) + .transition(.splashTransition) + .zIndex(1) } } + .animation(.easeInOut(duration: 0.5), value: shouldShowContent) .task { - consumePendingDeepLink() - await state.refreshSnapshot() + await initialLoad() } .onChange(of: state.activeSessionID) { _, newValue in openActiveSession(newValue) @@ -52,6 +79,79 @@ struct RootView: View { .onChange(of: state.pendingDeepLink) { _, _ in consumePendingDeepLink() } + .onChange(of: state.hasReceivedInitialSnapshot) { oldValue, newValue in + // Reset states when snapshot state resets (e.g., switching paired desktops) + if oldValue && !newValue { + minimumLoadingTimeElapsed = false + connectionTimedOut = false + Task { + await initialLoad() + } + } + } + } + + /// Performs initial load with timeout handling + private func initialLoad() async { + logger.info("initialLoad started, current hasReceivedInitialSnapshot: \(self.state.hasReceivedInitialSnapshot)") + + // Send the snapshot request (returns immediately, snapshot arrives async) + consumePendingDeepLink() + await state.refreshSnapshot() + logger.info("Snapshot request sent") + + // Wait for either snapshot to arrive or timeout + let timeoutSeconds = 15 + let pollIntervalMs: UInt64 = 100 + let maxPolls = (timeoutSeconds * 1000) / Int(pollIntervalMs) + + var pollCount = 0 + while !state.hasReceivedInitialSnapshot && pollCount < maxPolls { + try? await Task.sleep(for: .milliseconds(pollIntervalMs)) + pollCount += 1 + if pollCount % 50 == 0 { // Log every 5 seconds + logger.debug("Still waiting for snapshot... polls=\(pollCount)/\(maxPolls)") + } + } + + let hasSnapshot = state.hasReceivedInitialSnapshot + logger.info("Wait completed: hasSnapshot=\(hasSnapshot), polls=\(pollCount)/\(maxPolls)") + + // Ensure minimum 2 second display time for smooth UX + if pollCount < 20 { // Less than 2 seconds elapsed + let remainingMs = (20 - pollCount) * Int(pollIntervalMs) + logger.debug("Waiting additional \(remainingMs)ms for minimum display time") + try? await Task.sleep(for: .milliseconds(remainingMs)) + } + + if hasSnapshot { + logger.info("Connection successful - showing content") + withAnimation { + minimumLoadingTimeElapsed = true + } + } else { + logger.warning("Connection timed out after \(timeoutSeconds)s - showing timeout screen") + withAnimation { + connectionTimedOut = true + minimumLoadingTimeElapsed = true + } + } + } + + /// Retry connection after timeout + private func retryConnection() async { + logger.info("Retry connection requested") + await initialLoad() + } + + private var mainContent: some View { + Group { + if compactClass == .compact { + phoneTabs + } else { + ipadSplitView + } + } } private var phoneTabs: some View { @@ -119,13 +219,44 @@ struct RootView: View { private var briefingSplitView: some View { NavigationSplitView { projectSidebar + } content: { + BriefingListView(selectedGroup: $selectedBriefingGroup) + .onChange(of: selectedBriefingGroup) { _, _ in + // Clear navigation path when switching briefing groups + briefingDetailPath.removeLast(briefingDetailPath.count) + } } detail: { - NavigationStack { - MobileBriefingView() + NavigationStack(path: $briefingDetailPath) { + Group { + if let groupKey = selectedBriefingGroup { + MobileBriefingDetailView(groupKey: groupKey) + } else { + ContentUnavailableView { + Label("No Selection", systemImage: "doc.text") + } description: { + Text("Select a briefing to view details") + } + } + } + .navigationDestination(for: String.self) { sessionID in + MobileChatView(sessionID: sessionID, onClose: { closeBriefingChat() }) + .id(sessionID) + .task(id: sessionID) { + if !MobileDraftSessionID.isDraft(sessionID) { + await state.subscribe(to: sessionID) + } + } + } } } } + private func closeBriefingChat() { + if !briefingDetailPath.isEmpty { + briefingDetailPath.removeLast() + } + } + private var projectSplitView: some View { NavigationSplitView { projectSidebar @@ -182,6 +313,10 @@ struct RootView: View { /// desktop-driven focus changes). private func openActiveSession(_ sessionID: String?) { guard let sessionID else { return } + // Skip navigation if we're already viewing this session in briefing detail + if showingBriefing && briefingDetailPath.count > 0 { + return + } navigate(toSession: sessionID, projectID: nil) } diff --git a/RxCodeMobile/Views/SessionsList.swift b/RxCodeMobile/Views/SessionsList.swift index d358367..14d4250 100644 --- a/RxCodeMobile/Views/SessionsList.swift +++ b/RxCodeMobile/Views/SessionsList.swift @@ -38,6 +38,7 @@ struct SessionsList: View { glassThreadList .navigationTitle("Threads") .toolbar { toolbarContent } + .toolbar(usesSelection ? .automatic : .hidden, for: .tabBar) .sheet(isPresented: $showingNewThread) { NewThreadSheet(projectID: projectID) { newSessionID in selected = newSessionID @@ -47,16 +48,22 @@ struct SessionsList: View { .searchable( text: $searchText, placement: .navigationBarDrawer(displayMode: .automatic), - prompt: "Search threads" + prompt: "Global search" ) .autocorrectionDisabled(true) .textInputAutocapitalization(.never) - .onChange(of: searchText) { _, _ in + .onChange(of: searchText) { _, newValue in // Restart paging so search results always begin at the top. displayLimit = Self.pageSize + state.updateSearchQuery(newValue) + } + .onDisappear { + state.updateSearchQuery("") } .overlay { - if filtered.isEmpty && !searchText.isEmpty { + if usesDesktopSearch, !state.isSearching, desktopSearchHits.isEmpty { + ContentUnavailableView.search(text: searchText) + } else if !usesDesktopSearch, filtered.isEmpty && !searchText.isEmpty { ContentUnavailableView.search(text: searchText) } else if filtered.isEmpty && searchText.isEmpty { emptyStateView @@ -83,26 +90,30 @@ struct SessionsList: View { private var glassThreadList: some View { ScrollView { LazyVStack(spacing: 10) { - GlassEffectContainer(spacing: 12) { - ForEach(visible) { session in - GlassThreadCard( - session: session, - isSelected: selected == session.id, - usesNavigationLink: !usesSelection, - onSelect: usesSelection ? { selected = session.id } : nil - ) - .glassEffectID(session.id, in: glassNamespace) - .onAppear { - if session.id == visible.last?.id { loadMore() } - } - .contextMenu { - threadContextMenu(for: session) + if usesDesktopSearch { + desktopSearchResults + } else { + GlassEffectContainer(spacing: 12) { + ForEach(visible) { session in + GlassThreadCard( + session: session, + isSelected: selected == session.id, + usesNavigationLink: !usesSelection, + onSelect: usesSelection ? { selected = session.id } : nil + ) + .glassEffectID(session.id, in: glassNamespace) + .onAppear { + if session.id == visible.last?.id { loadMore() } + } + .contextMenu { + threadContextMenu(for: session) + } } } - } - if displayLimit < filtered.count { - loadingIndicator + if displayLimit < filtered.count { + loadingIndicator + } } } .padding(.horizontal, 16) @@ -112,6 +123,29 @@ struct SessionsList: View { .animation(.spring(duration: 0.3), value: filtered.map(\.id)) } + @ViewBuilder + private var desktopSearchResults: some View { + if state.isSearching { + HStack(spacing: 8) { + ProgressView() + Text("Searching…") + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.top, 40) + } else if !desktopSearchHits.isEmpty { + GlassEffectContainer(spacing: 12) { + ForEach(desktopSearchHits) { hit in + SearchThreadHitCard( + hit: hit, + project: projectsByID[hit.projectID], + namespace: glassNamespace + ) + } + } + } + } + // MARK: - Context Menu @ViewBuilder @@ -177,6 +211,18 @@ struct SessionsList: View { Array(filtered.prefix(displayLimit)) } + private var usesDesktopSearch: Bool { + !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private var desktopSearchHits: [SearchHit] { + state.searchThreadHits + } + + private var projectsByID: [UUID: Project] { + Dictionary(uniqueKeysWithValues: state.projects.map { ($0.id, $0) }) + } + private func loadMore() { guard displayLimit < filtered.count else { return } displayLimit = min(displayLimit + Self.pageSize, filtered.count) @@ -216,6 +262,12 @@ struct MobileRunProfilesView: View { state.runTasks(for: projectID) } + /// Desktop-detected runnables for this project, or an empty set until the + /// detection request resolves. + private var detected: DetectedRunnables { + state.detectedRunnablesByProject[projectID] ?? DetectedRunnables() + } + var body: some View { List { if !tasks.isEmpty { @@ -246,15 +298,15 @@ struct MobileRunProfilesView: View { Button("Done") { dismiss() } } ToolbarItem(placement: .primaryAction) { - Button { - editingProfile = Self.newProfile(projectID: projectID) - } label: { - Image(systemName: "plus") - } + addMenu } } + .task { + await state.requestRunnableDetection(projectID: projectID) + } .refreshable { await state.refreshSnapshot() + await state.requestRunnableDetection(projectID: projectID) } .sheet(item: $editingProfile) { profile in NavigationStack { @@ -398,133 +450,284 @@ struct MobileRunProfilesView: View { } } - private static func newProfile(projectID: UUID) -> RunProfile { - let now = Date() - return RunProfile( - projectId: projectID, - name: "New Bash Configuration", - type: .bash, - bash: BashRunConfig(), - createdAt: now, - updatedAt: now - ) - } -} - -private struct MobileRunProfileEditorView: View { - @EnvironmentObject private var state: MobileAppState - @Environment(\.dismiss) private var dismiss - @State private var draft: RunProfile - let projectID: UUID - - init(profile: RunProfile, projectID: UUID) { - self._draft = State(initialValue: profile) - self.projectID = projectID - } - - var body: some View { - Form { - Section("Configuration") { - TextField("Name", text: $draft.name) - Picker("Type", selection: Binding( - get: { draft.type }, - set: { type in - draft.type = type - if type == .xcode, draft.xcode == nil { draft.xcode = XcodeRunConfig() } - if type == .make, draft.make == nil { draft.make = MakeRunConfig() } - } - )) { - Text("Bash").tag(RunProfileType.bash) - Text("Xcode").tag(RunProfileType.xcode) - Text("Make").tag(RunProfileType.make) + /// "+" menu: blank profiles plus everything the desktop auto-detected for + /// this project. Picking a detected entry materializes a pre-filled profile. + private var addMenu: some View { + Menu { + Section("New") { + Button { + editingProfile = Self.newProfile(projectID: projectID, type: .bash) + } label: { + Label("Bash Configuration", systemImage: "terminal") + } + Button { + editingProfile = Self.newProfile(projectID: projectID, type: .xcode) + } label: { + Label("Xcode Configuration", systemImage: "hammer.fill") + } + Button { + editingProfile = Self.newProfile(projectID: projectID, type: .make) + } label: { + Label("Make Configuration", systemImage: "wrench.and.screwdriver.fill") } } - switch draft.type { - case .bash: - bashSection - case .xcode: - xcodeSection - case .make: - makeSection + if !detected.xcode.isEmpty { + Section("Detected · Xcode") { + ForEach(detected.xcode) { runnable in + Button { + editingProfile = Self.profile(from: runnable, projectID: projectID) + } label: { + Label(runnable.displayName, systemImage: "hammer.fill") + } + } + } } - } - .navigationTitle(draft.name.isEmpty ? "Run Profile" : draft.name) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { dismiss() } + if !detected.make.isEmpty { + Section("Detected · Make") { + ForEach(detected.make) { runnable in + Button { + editingProfile = Self.profile(from: runnable, projectID: projectID) + } label: { + Label(runnable.displayName, systemImage: "wrench.and.screwdriver.fill") + } + } + } } - ToolbarItem(placement: .confirmationAction) { - Button("Save") { - var saved = draft - saved.projectId = projectID - saved.updatedAt = Date() - Task { - await state.saveRunProfile(saved, projectID: projectID) - dismiss() + if !detected.npm.isEmpty { + Section("Detected · Scripts") { + ForEach(detected.npm) { runnable in + Button { + editingProfile = Self.profile(from: runnable, projectID: projectID) + } label: { + Label(runnable.displayName, systemImage: "shippingbox.fill") + } } } - .disabled(draft.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } + } label: { + Image(systemName: "plus") } } - private var bashSection: some View { - Section("Command") { - TextEditor(text: $draft.bash.command) - .font(.system(.body, design: .monospaced)) - .frame(minHeight: 90) - TextField("Working Directory", text: $draft.bash.workingDirectory, prompt: Text("Project root")) - .textInputAutocapitalization(.never) - .autocorrectionDisabled(true) + private static func newProfile(projectID: UUID, type: RunProfileType) -> RunProfile { + let now = Date() + switch type { + case .bash: + return RunProfile( + projectId: projectID, + name: "New Bash Configuration", + type: .bash, + bash: BashRunConfig(), + createdAt: now, + updatedAt: now + ) + case .xcode: + return RunProfile( + projectId: projectID, + name: "New Xcode Configuration", + type: .xcode, + xcode: XcodeRunConfig(), + createdAt: now, + updatedAt: now + ) + case .make: + return RunProfile( + projectId: projectID, + name: "New Make Configuration", + type: .make, + make: MakeRunConfig(), + createdAt: now, + updatedAt: now + ) } } - private var xcodeSection: some View { - Section("Xcode") { - let xcode = Binding( - get: { draft.xcode ?? XcodeRunConfig() }, - set: { draft.xcode = $0 } + /// Materialize a desktop-detected runnable into an editable `RunProfile`, + /// mirroring the desktop's `RunConfigurationsView.addProfile(from:)`. + private static func profile(from runnable: DetectedRunnable, projectID: UUID) -> RunProfile { + let now = Date() + if let xcode = runnable.xcode { + return RunProfile( + projectId: projectID, + name: runnable.displayName, + type: .xcode, + xcode: xcode, + createdAt: now, + updatedAt: now + ) + } + if let make = runnable.make { + return RunProfile( + projectId: projectID, + name: runnable.displayName, + type: .make, + make: make, + createdAt: now, + updatedAt: now ) - TextField("Project / Workspace", text: xcode.container, prompt: Text("App.xcodeproj")) - .textInputAutocapitalization(.never) - .autocorrectionDisabled(true) - Toggle("Use Workspace", isOn: xcode.isWorkspace) - TextField("Scheme", text: xcode.scheme) - .textInputAutocapitalization(.never) - .autocorrectionDisabled(true) - TextField("Configuration", text: xcode.configuration) - .textInputAutocapitalization(.never) - .autocorrectionDisabled(true) - Picker("Action", selection: xcode.action) { - ForEach(XcodeAction.allCases, id: \.self) { action in - Text(action.rawValue.capitalized).tag(action) - } - } - TextField("Destination", text: xcode.destination, prompt: Text("Optional xcodebuild destination")) - .textInputAutocapitalization(.never) - .autocorrectionDisabled(true) } + return RunProfile( + projectId: projectID, + name: runnable.displayName, + bash: BashRunConfig(command: runnable.command), + createdAt: now, + updatedAt: now + ) } +} - private var makeSection: some View { - Section("Make") { - let make = Binding( - get: { draft.make ?? MakeRunConfig() }, - set: { draft.make = $0 } +// MARK: - Previews + +#if DEBUG +extension MobileAppState { + /// A preview-ready MobileAppState with sample projects and sessions. + static var preview: MobileAppState { + let state = MobileAppState() + let projectID = UUID() + + state.projects = [ + Project(id: projectID, name: "RxCode", path: "/Users/dev/RxCode") + ] + + state.sessions = [ + SessionSummary( + id: "session-1", + projectId: projectID, + title: "Building new feature module", + updatedAt: Date(), + isPinned: false, + isArchived: false, + isStreaming: true, + attention: nil, + todos: [ + TodoItem(id: 1, content: "Create models", activeForm: "Creating models", status: .completed), + TodoItem(id: 2, content: "Add views", activeForm: "Adding views", status: .inProgress), + TodoItem(id: 3, content: "Write tests", activeForm: "Writing tests", status: .pending) + ], + hasUncheckedCompletion: false + ), + SessionSummary( + id: "session-2", + projectId: projectID, + title: "Implement Liquid Glass UI", + updatedAt: Date().addingTimeInterval(-300), + isPinned: true, + isArchived: false, + isStreaming: false, + attention: nil, + todos: [ + TodoItem(id: 1, content: "Task 1", activeForm: "Doing task 1", status: .completed), + TodoItem(id: 2, content: "Task 2", activeForm: "Doing task 2", status: .completed), + TodoItem(id: 3, content: "Task 3", activeForm: "Doing task 3", status: .completed) + ], + hasUncheckedCompletion: false + ), + SessionSummary( + id: "session-3", + projectId: projectID, + title: "Deploy to production", + updatedAt: Date().addingTimeInterval(-120), + isPinned: false, + isArchived: false, + isStreaming: false, + attention: .permission, + todos: nil, + hasUncheckedCompletion: false + ), + SessionSummary( + id: "session-4", + projectId: projectID, + title: "Code review assistance", + updatedAt: Date().addingTimeInterval(-600), + isPinned: false, + isArchived: false, + isStreaming: false, + attention: .question, + todos: nil, + hasUncheckedCompletion: false + ), + SessionSummary( + id: "session-5", + projectId: projectID, + title: "Fix scrolling performance", + updatedAt: Date().addingTimeInterval(-3600), + isPinned: false, + isArchived: false, + isStreaming: false, + attention: nil, + todos: nil, + hasUncheckedCompletion: true + ), + SessionSummary( + id: "session-6", + projectId: projectID, + title: "Review pull request", + updatedAt: Date().addingTimeInterval(-86400), + isPinned: false, + isArchived: false, + isStreaming: false, + attention: nil, + todos: nil, + hasUncheckedCompletion: false ) - TextField("Makefile", text: make.makefile, prompt: Text("Makefile")) - .textInputAutocapitalization(.never) - .autocorrectionDisabled(true) - TextField("Target", text: make.target) - .textInputAutocapitalization(.never) - .autocorrectionDisabled(true) - TextField("Arguments", text: make.arguments) - .textInputAutocapitalization(.never) - .autocorrectionDisabled(true) - TextField("Working Directory", text: $draft.bash.workingDirectory, prompt: Text("Project root")) - .textInputAutocapitalization(.never) - .autocorrectionDisabled(true) - } + ] + + return state + } + + /// A preview-ready MobileAppState with no sessions (empty state). + static var previewEmpty: MobileAppState { + let state = MobileAppState() + let projectID = UUID() + + state.projects = [ + Project(id: projectID, name: "New Project", path: "/Users/dev/NewProject") + ] + state.sessions = [] + + return state + } +} +#endif + +#Preview("Sessions List") { + let state = MobileAppState.preview + let projectID = state.projects.first!.id + + return NavigationStack { + SessionsList( + projectID: projectID, + selected: .constant(nil) + ) + } + .environmentObject(state) +} + +#Preview("Sessions List - With Selection") { + let state = MobileAppState.preview + let projectID = state.projects.first!.id + let selectedID = state.sessions.first?.id + + return NavigationStack { + SessionsList( + projectID: projectID, + selected: .constant(selectedID), + usesSelection: true + ) + } + .environmentObject(state) +} + +#Preview("Sessions List - Empty") { + let state = MobileAppState.previewEmpty + let projectID = state.projects.first!.id + + return NavigationStack { + SessionsList( + projectID: projectID, + selected: .constant(nil) + ) } + .environmentObject(state) } diff --git a/RxCodeMobile/Views/SyncLoadingView.swift b/RxCodeMobile/Views/SyncLoadingView.swift new file mode 100644 index 0000000..885dd38 --- /dev/null +++ b/RxCodeMobile/Views/SyncLoadingView.swift @@ -0,0 +1,491 @@ +import SwiftUI +import RxCodeCore +import os.log + +private let logger = Logger(subsystem: "com.idealapp.RxCode", category: "SyncLoadingView") + +/// A beautiful full-screen liquid glass loading splash shown while syncing with the paired Mac. +/// Features animated gradient orbs, a pulsing progress indicator, and smooth transitions. +/// When timed out, shows a timeout screen with retry button. +struct SyncLoadingView: View { + var isTimedOut: Bool = false + var onRetry: (() -> Void)? + + @State private var pulseScale: CGFloat = 1.0 + @State private var orbRotation: Double = 0 + @State private var shimmerOffset: CGFloat = -200 + @State private var appeared = false + + var body: some View { + let _ = logger.debug("SyncLoadingView body: isTimedOut=\(self.isTimedOut)") + if isTimedOut { + timeoutView + } else { + loadingView + } + } + + private var loadingView: some View { + ZStack { + // Background gradient + backgroundGradient + .ignoresSafeArea() + + // Floating background orbs + floatingOrbs + .ignoresSafeArea() + + // Main content + VStack(spacing: 32) { + Spacer() + + // Animated orb container + animatedOrbView + .scaleEffect(appeared ? 1 : 0.8) + .opacity(appeared ? 1 : 0) + + // Text content + VStack(spacing: 16) { + Text("Syncing with Mac") + .font(.title.weight(.semibold)) + .foregroundStyle(.primary) + + HStack(spacing: 6) { + Text("Connecting to your desktop") + .font(.body) + .foregroundStyle(.secondary) + + AnimatedDotsView() + } + } + .opacity(appeared ? 1 : 0) + .offset(y: appeared ? 0 : 20) + + // Shimmer bar + shimmerBar + .padding(.top, 8) + .opacity(appeared ? 1 : 0) + + Spacer() + Spacer() + } + .padding(.horizontal, 40) + } + .onAppear { + startAnimations() + withAnimation(.easeOut(duration: 0.6)) { + appeared = true + } + } + } + + // MARK: - Timeout View + + private var timeoutView: some View { + ZStack { + // Background gradient (same as loading) + backgroundGradient + .ignoresSafeArea() + + // Main content + VStack(spacing: 32) { + Spacer() + + // Error icon with glass effect + timeoutIconView + + // Text content + VStack(spacing: 16) { + Text("Connection Timed Out") + .font(.title.weight(.semibold)) + .foregroundStyle(.primary) + + Text("Unable to connect to your Mac. Make sure RxCode is running on your desktop and both devices are on the same network.") + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + + // Retry button + Button { + onRetry?() + } label: { + HStack(spacing: 8) { + Image(systemName: "arrow.clockwise") + .font(.system(size: 16, weight: .semibold)) + Text("Try Again") + .font(.body.weight(.semibold)) + } + .foregroundStyle(.white) + .padding(.horizontal, 32) + .padding(.vertical, 14) + .background( + LinearGradient( + colors: [ + ClaudeTheme.accent, + ClaudeTheme.accent.opacity(0.85) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .clipShape(Capsule()) + .shadow(color: ClaudeTheme.accent.opacity(0.3), radius: 12, x: 0, y: 6) + } + .buttonStyle(.plain) + .padding(.top, 8) + + Spacer() + Spacer() + } + .padding(.horizontal, 40) + } + } + + private var timeoutIconView: some View { + ZStack { + // Outer glow (dimmer than loading state) + Circle() + .fill( + RadialGradient( + colors: [ + Color.orange.opacity(0.15), + Color.orange.opacity(0.05), + .clear + ], + center: .center, + startRadius: 40, + endRadius: 100 + ) + ) + .frame(width: 200, height: 200) + + // Central glass circle + ZStack { + // Glass background with blur + Circle() + .fill(.ultraThinMaterial) + .frame(width: 110, height: 110) + + // Warning ring + Circle() + .strokeBorder( + LinearGradient( + colors: [ + Color.orange.opacity(0.8), + Color.orange.opacity(0.5) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + lineWidth: 3 + ) + .frame(width: 95, height: 95) + + // Warning icon + Image(systemName: "wifi.exclamationmark") + .font(.system(size: 36, weight: .medium)) + .foregroundStyle( + LinearGradient( + colors: [ + Color.orange, + Color.orange.opacity(0.75) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + } + .shadow(color: Color.orange.opacity(0.15), radius: 20, x: 0, y: 8) + .glassEffect(.regular.tint(Color.orange.opacity(0.05)), in: .circle) + } + .frame(width: 200, height: 200) + } + + // MARK: - Background + + private var backgroundGradient: some View { + ZStack { + // Base gradient + LinearGradient( + colors: [ + Color(UIColor.systemBackground), + Color(UIColor.systemBackground).opacity(0.95) + ], + startPoint: .top, + endPoint: .bottom + ) + + // Accent color wash + RadialGradient( + colors: [ + ClaudeTheme.accent.opacity(0.08), + ClaudeTheme.accent.opacity(0.03), + .clear + ], + center: .center, + startRadius: 100, + endRadius: 400 + ) + } + } + + private var floatingOrbs: some View { + GeometryReader { geo in + ZStack { + // Top-right orb + Circle() + .fill( + RadialGradient( + colors: [ + ClaudeTheme.accent.opacity(0.15), + ClaudeTheme.accent.opacity(0.05), + .clear + ], + center: .center, + startRadius: 20, + endRadius: 120 + ) + ) + .frame(width: 240, height: 240) + .offset(x: geo.size.width * 0.3, y: -geo.size.height * 0.15) + .scaleEffect(pulseScale * 0.9 + 0.1) + + // Bottom-left orb + Circle() + .fill( + RadialGradient( + colors: [ + Color(red: 0.4, green: 0.6, blue: 0.9).opacity(0.12), + Color(red: 0.5, green: 0.4, blue: 0.85).opacity(0.04), + .clear + ], + center: .center, + startRadius: 30, + endRadius: 150 + ) + ) + .frame(width: 300, height: 300) + .offset(x: -geo.size.width * 0.35, y: geo.size.height * 0.25) + .scaleEffect(1.1 - (pulseScale - 1) * 0.5) + } + } + } + + // MARK: - Animated Orb + + private var animatedOrbView: some View { + ZStack { + // Outer glow + Circle() + .fill( + RadialGradient( + colors: [ + ClaudeTheme.accent.opacity(0.25), + ClaudeTheme.accent.opacity(0.08), + .clear + ], + center: .center, + startRadius: 40, + endRadius: 120 + ) + ) + .frame(width: 240, height: 240) + .scaleEffect(pulseScale) + + // Orbiting dots + ForEach(0..<3, id: \.self) { index in + Circle() + .fill(orbGradient(for: index)) + .frame(width: 14, height: 14) + .shadow(color: orbShadowColor(for: index), radius: 8, x: 0, y: 2) + .offset(x: 55) + .rotationEffect(.degrees(orbRotation + Double(index) * 120)) + } + + // Central glass circle + ZStack { + // Glass background with blur + Circle() + .fill(.ultraThinMaterial) + .frame(width: 110, height: 110) + + // Inner gradient ring + Circle() + .strokeBorder( + AngularGradient( + colors: [ + ClaudeTheme.accent.opacity(0.9), + ClaudeTheme.accent.opacity(0.3), + Color.white.opacity(0.4), + ClaudeTheme.accent.opacity(0.7), + ClaudeTheme.accent.opacity(0.9) + ], + center: .center, + startAngle: .degrees(0), + endAngle: .degrees(360) + ), + lineWidth: 3.5 + ) + .frame(width: 95, height: 95) + .rotationEffect(.degrees(-orbRotation * 0.5)) + + // Mac icon + Image(systemName: "laptopcomputer") + .font(.system(size: 36, weight: .medium)) + .foregroundStyle( + LinearGradient( + colors: [ + ClaudeTheme.accent, + ClaudeTheme.accent.opacity(0.75) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .scaleEffect(0.95 + (pulseScale - 1) * 0.3) + } + .shadow(color: ClaudeTheme.accent.opacity(0.2), radius: 20, x: 0, y: 8) + .glassEffect(.regular.tint(ClaudeTheme.accent.opacity(0.08)), in: .circle) + } + .frame(width: 240, height: 240) + } + + // MARK: - Shimmer Bar + + private var shimmerBar: some View { + RoundedRectangle(cornerRadius: 6) + .fill(Color.secondary.opacity(0.15)) + .frame(width: 160, height: 6) + .overlay { + RoundedRectangle(cornerRadius: 6) + .fill( + LinearGradient( + colors: [ + .clear, + ClaudeTheme.accent.opacity(0.5), + .clear + ], + startPoint: .leading, + endPoint: .trailing + ) + ) + .frame(width: 80) + .offset(x: shimmerOffset) + } + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + + // MARK: - Helpers + + private func orbGradient(for index: Int) -> LinearGradient { + let colors: [[Color]] = [ + [ClaudeTheme.accent, ClaudeTheme.accent.opacity(0.7)], + [Color(red: 0.4, green: 0.6, blue: 0.9), Color(red: 0.5, green: 0.4, blue: 0.85)], + [Color(red: 0.9, green: 0.5, blue: 0.6), Color(red: 0.95, green: 0.6, blue: 0.4)] + ] + return LinearGradient( + colors: colors[index % colors.count], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + + private func orbShadowColor(for index: Int) -> Color { + let colors: [Color] = [ + ClaudeTheme.accent.opacity(0.4), + Color(red: 0.4, green: 0.6, blue: 0.9).opacity(0.4), + Color(red: 0.9, green: 0.5, blue: 0.6).opacity(0.4) + ] + return colors[index % colors.count] + } + + private func startAnimations() { + // Pulse animation + withAnimation( + .easeInOut(duration: 2.0) + .repeatForever(autoreverses: true) + ) { + pulseScale = 1.12 + } + + // Orbit rotation - use a very large target value with proportionally long duration + // 5 seconds per 360 degrees = 5000 seconds for 360,000 degrees + withAnimation( + .linear(duration: 5000) + .repeatForever(autoreverses: false) + ) { + orbRotation = 360 * 1000 + } + + // Shimmer animation - reset and loop manually for smooth continuous effect + startShimmerLoop() + } + + private func startShimmerLoop() { + shimmerOffset = -200 + withAnimation(.easeInOut(duration: 1.8)) { + shimmerOffset = 200 + } + // Schedule next loop + DispatchQueue.main.asyncAfter(deadline: .now() + 1.8) { [self] in + if appeared && !isTimedOut { + startShimmerLoop() + } + } + } +} + +/// Animated ellipsis dots for loading text +private struct AnimatedDotsView: View { + @State private var dotCount = 0 + + var body: some View { + HStack(spacing: 3) { + ForEach(0..<3, id: \.self) { index in + Circle() + .fill(Color.secondary) + .frame(width: 5, height: 5) + .opacity(index < dotCount ? 1 : 0.3) + } + } + .onAppear { + startAnimation() + } + } + + private func startAnimation() { + Timer.scheduledTimer(withTimeInterval: 0.4, repeats: true) { _ in + withAnimation(.easeInOut(duration: 0.2)) { + dotCount = (dotCount + 1) % 4 + } + } + } +} + +// MARK: - Transition Modifier + +extension AnyTransition { + /// A smooth transition for the loading splash screen + static var splashTransition: AnyTransition { + .asymmetric( + insertion: .opacity.animation(.easeOut(duration: 0.3)), + removal: .opacity.combined(with: .scale(scale: 1.05)).animation(.easeIn(duration: 0.4)) + ) + } + + /// A smooth transition for the main content appearing after loading + static var contentTransition: AnyTransition { + .asymmetric( + insertion: .opacity.combined(with: .scale(scale: 0.98)).animation(.spring(duration: 0.5, bounce: 0.1)), + removal: .opacity.animation(.easeOut(duration: 0.2)) + ) + } +} + +// MARK: - Preview + +#Preview { + SyncLoadingView() +} diff --git a/relay-server/push.go b/relay-server/push.go index 73e99d1..2e05746 100644 --- a/relay-server/push.go +++ b/relay-server/push.go @@ -70,7 +70,9 @@ const ( // consumes it directly, so `apns_payload` is forwarded verbatim. pushModeLiveActivity = "liveactivity" // pushModeBackground is a silent content-available push used to refresh - // the home-screen widget; `apns_payload` is forwarded verbatim. + // the home-screen widget. The widget snapshot inside `apns_payload` is + // E2E-encrypted to the recipient device (under `encWidget`); the relay + // forwards `apns_payload` verbatim and never decrypts it. pushModeBackground = "background" ) @@ -95,10 +97,11 @@ type PushRequest struct { // pushHandler returns an http.HandlerFunc that signs and forwards APNs pushes. // -// Auth is intentionally minimal in v1: any client may submit, since the alert -// payload itself is E2E-encrypted to the recipient device. Live Activity and -// widget payloads are not encrypted (ActivityKit / WidgetKit consume them -// directly); a future hardening pass should require a signed sender token. +// Auth is intentionally minimal in v1: any client may submit, since both the +// alert and the widget payloads are themselves E2E-encrypted to the recipient +// device. Only Live Activity content-state is unencrypted (ActivityKit +// consumes it directly); a future hardening pass should require a signed +// sender token. func pushHandler(sender *PushSender) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { @@ -219,9 +222,11 @@ func buildAlertNotification(sender *PushSender, req *PushRequest) (*apns2.Notifi } // buildRawNotification forwards the desktop-built APNs payload verbatim. Used -// for Live Activity and background (widget) pushes, whose payloads cannot be -// E2E encrypted because the OS consumes them directly. When `liveActivityTopic` -// is set, the APNs topic is suffixed with `.push-type.liveactivity` as Apple +// for Live Activity and background (widget) pushes. Live Activity content-state +// cannot be E2E encrypted (ActivityKit consumes it directly); the widget +// background push carries an E2E-encrypted blob the app decrypts itself. Either +// way the relay forwards `apns_payload` untouched. When `liveActivityTopic` is +// set, the APNs topic is suffixed with `.push-type.liveactivity` as Apple // requires for Live Activity pushes. func buildRawNotification( sender *PushSender,