diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 86d51e81..3d0f8961 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -10,7 +10,9 @@ "WebSearch", "Skill(superpowers:brainstorming)", "Bash(python3:*)", - "WebFetch(domain:github.com)" + "WebFetch(domain:github.com)", + "Bash(xargs ls:*)", + "Bash(grep:*)" ], "deny": [], "ask": [] diff --git a/xcode/catnip/Components/AdaptiveNavigationContainer.swift b/xcode/catnip/Components/AdaptiveNavigationContainer.swift new file mode 100644 index 00000000..b49ed95e --- /dev/null +++ b/xcode/catnip/Components/AdaptiveNavigationContainer.swift @@ -0,0 +1,109 @@ +// +// AdaptiveNavigationContainer.swift +// catnip +// +// Adaptive navigation: NavigationStack on iPhone, NavigationSplitView on iPad +// + +import SwiftUI + +/// Adaptive navigation container that uses stack navigation on iPhone and split view on iPad +struct AdaptiveNavigationContainer: View { + @Environment(\.adaptiveTheme) private var adaptiveTheme + + let sidebar: () -> Sidebar + let detail: (Binding, String?) -> Detail + let emptyDetail: () -> EmptyDetail + + @State private var navigationPath = NavigationPath() + @Binding var columnVisibility: NavigationSplitViewVisibility + + init( + columnVisibility: Binding = .constant(.all), + @ViewBuilder sidebar: @escaping () -> Sidebar, + @ViewBuilder detail: @escaping (Binding, String?) -> Detail, + @ViewBuilder emptyDetail: @escaping () -> EmptyDetail + ) { + self._columnVisibility = columnVisibility + self.sidebar = sidebar + self.detail = detail + self.emptyDetail = emptyDetail + } + + var body: some View { + if adaptiveTheme.prefersSplitView { + // iPad/Mac: Use NavigationSplitView with sidebar + detail + NavigationSplitView(columnVisibility: $columnVisibility) { + NavigationStack { + sidebar() + } + .navigationSplitViewColumnWidth(adaptiveTheme.sidebarWidth) + } detail: { + NavigationStack(path: $navigationPath) { + detail($navigationPath, nil) + } + } + .navigationSplitViewStyle(.balanced) + } else { + // iPhone: Use NavigationStack with full-screen push + NavigationStack(path: $navigationPath) { + sidebar() + } + .navigationDestination(for: String.self) { workspaceId in + detail($navigationPath, workspaceId) + } + } + } +} + +// MARK: - Preview + +#Preview("iPhone") { + AdaptiveNavigationContainer { + List(0..<10) { index in + NavigationLink("Item \(index)", value: "item-\(index)") + } + .navigationTitle("Sidebar") + } detail: { _, workspaceId in + Text("Detail View") + .navigationTitle("Detail") + } emptyDetail: { + VStack { + Image(systemName: "square.stack") + .font(.largeTitle) + .foregroundStyle(.secondary) + Text("Select an item") + .font(.headline) + } + } + .environment(\.adaptiveTheme, AdaptiveTheme( + horizontalSizeClass: .compact, + verticalSizeClass: .regular, + idiom: .phone + )) +} + +#Preview("iPad") { + AdaptiveNavigationContainer { + List(0..<10) { index in + NavigationLink("Item \(index)", value: "item-\(index)") + } + .navigationTitle("Sidebar") + } detail: { _, workspaceId in + Text("Detail View") + .navigationTitle("Detail") + } emptyDetail: { + VStack { + Image(systemName: "square.stack") + .font(.largeTitle) + .foregroundStyle(.secondary) + Text("Select an item") + .font(.headline) + } + } + .environment(\.adaptiveTheme, AdaptiveTheme( + horizontalSizeClass: .regular, + verticalSizeClass: .regular, + idiom: .pad + )) +} diff --git a/xcode/catnip/Components/AdaptiveSplitView.swift b/xcode/catnip/Components/AdaptiveSplitView.swift new file mode 100644 index 00000000..c13d6462 --- /dev/null +++ b/xcode/catnip/Components/AdaptiveSplitView.swift @@ -0,0 +1,249 @@ +// +// AdaptiveSplitView.swift +// catnip +// +// Adaptive split view: single pane with toggle on iPhone, side-by-side on iPad +// + +import SwiftUI + +/// Display mode for split content +enum AdaptiveSplitMode { + case leading // Show only leading content + case trailing // Show only trailing content + case split // Show both side-by-side +} + +/// Adaptive split view that shows single pane on iPhone and side-by-side on iPad +struct AdaptiveSplitView: View { + @Environment(\.adaptiveTheme) private var adaptiveTheme + + let defaultMode: AdaptiveSplitMode + let allowModeToggle: Bool + let contextTokens: Int64? + let leading: () -> Leading + let trailing: () -> Trailing + + @State private var currentMode: AdaptiveSplitMode + + init( + defaultMode: AdaptiveSplitMode = .split, + allowModeToggle: Bool = true, + contextTokens: Int64? = nil, + @ViewBuilder leading: @escaping () -> Leading, + @ViewBuilder trailing: @escaping () -> Trailing + ) { + self.defaultMode = defaultMode + self.allowModeToggle = allowModeToggle + self.contextTokens = contextTokens + self.leading = leading + self.trailing = trailing + _currentMode = State(initialValue: defaultMode) + } + + var body: some View { + Group { + if adaptiveTheme.context.isPhone { + // iPhone: Single pane with toggle + singlePaneLayout + } else { + // iPad (both portrait and landscape): Vertical split (top/bottom) + // Terminal on top, chat on bottom for better code visibility + verticalSplitLayout + } + } + .onChange(of: adaptiveTheme.context) { _, _ in + // Reset to default mode when context changes + currentMode = defaultMode + } + } + + // MARK: - Horizontal Split Layout (iPad Landscape/Mac) + + private var horizontalSplitLayout: some View { + HStack(spacing: 0) { + if currentMode == .leading || currentMode == .split { + leading() + .frame(maxWidth: currentMode == .split ? adaptiveTheme.maxContentWidth : .infinity) + + if currentMode == .split { + Divider() + } + } + + if currentMode == .trailing || currentMode == .split { + trailing() + .frame(maxWidth: .infinity) + } + } + .toolbar { + if allowModeToggle { + ToolbarItem(placement: .topBarTrailing) { + modeToggleMenu + } + } + } + } + + // MARK: - Vertical Split Layout (iPad Portrait) + + private var verticalSplitLayout: some View { + VStack(spacing: 0) { + if currentMode == .leading || currentMode == .split { + leading() + .frame(maxHeight: currentMode == .split ? .infinity : .infinity) + + if currentMode == .split { + Divider() + } + } + + if currentMode == .trailing || currentMode == .split { + trailing() + .frame(maxHeight: .infinity) + } + } + .toolbar { + if allowModeToggle { + ToolbarItem(placement: .topBarTrailing) { + modeToggleMenu + } + } + } + } + + // MARK: - Single Pane Layout (iPhone) + + private var singlePaneLayout: some View { + ZStack { + if currentMode == .leading || currentMode == .split { + leading() + .opacity(currentMode == .leading ? 1 : 0) + .zIndex(currentMode == .leading ? 1 : 0) + } + + if currentMode == .trailing || currentMode == .split { + trailing() + .opacity(currentMode == .trailing ? 1 : 0) + .zIndex(currentMode == .trailing ? 1 : 0) + } + } + .toolbar { + if allowModeToggle { + ToolbarItem(placement: .topBarTrailing) { + simpleToggleButton + } + } + } + } + + // MARK: - Controls + + private var modeToggleMenu: some View { + Menu { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + currentMode = .split + } + } label: { + Label("Split View", systemImage: "rectangle.split.2x1") + } + + Button { + withAnimation(.easeInOut(duration: 0.2)) { + currentMode = .leading + } + } label: { + Label("Terminal Only", systemImage: "rectangle.leadinghalf.filled") + } + + Button { + withAnimation(.easeInOut(duration: 0.2)) { + currentMode = .trailing + } + } label: { + Label("Overview Only", systemImage: "rectangle.trailinghalf.filled") + } + } label: { + ContextProgressRing(contextTokens: contextTokens) { + Image(systemName: currentMode == .split ? "rectangle.split.2x1" : + currentMode == .leading ? "rectangle.leadinghalf.filled" : + "rectangle.trailinghalf.filled") + .font(.system(size: 11, weight: .medium)) + } + } + } + + private var simpleToggleButton: some View { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + // Simple toggle between leading and trailing on iPhone + if currentMode == .leading { + currentMode = .trailing + } else { + currentMode = .leading + } + } + } label: { + Image(systemName: currentMode == .leading ? "rectangle.leadinghalf.filled" : "rectangle.trailinghalf.filled") + } + } +} + +// MARK: - Preview + +#Preview("iPhone") { + NavigationStack { + AdaptiveSplitView { + VStack { + Text("Leading Content") + .font(.largeTitle) + List(0..<20) { index in + Text("Leading Item \(index)") + } + } + } trailing: { + VStack { + Text("Trailing Content") + .font(.largeTitle) + List(0..<20) { index in + Text("Trailing Item \(index)") + } + } + } + .navigationTitle("Split View") + } + .environment(\.adaptiveTheme, AdaptiveTheme( + horizontalSizeClass: .compact, + verticalSizeClass: .regular, + idiom: .phone + )) +} + +#Preview("iPad") { + NavigationStack { + AdaptiveSplitView { + VStack { + Text("Leading Content") + .font(.largeTitle) + List(0..<20) { index in + Text("Leading Item \(index)") + } + } + } trailing: { + VStack { + Text("Trailing Content") + .font(.largeTitle) + List(0..<20) { index in + Text("Trailing Item \(index)") + } + } + } + .navigationTitle("Split View") + } + .environment(\.adaptiveTheme, AdaptiveTheme( + horizontalSizeClass: .regular, + verticalSizeClass: .regular, + idiom: .pad + )) +} diff --git a/xcode/catnip/Components/ClaudeAuthSheet.swift b/xcode/catnip/Components/ClaudeAuthSheet.swift index 1bcd29ba..f7e44046 100644 --- a/xcode/catnip/Components/ClaudeAuthSheet.swift +++ b/xcode/catnip/Components/ClaudeAuthSheet.swift @@ -52,6 +52,7 @@ struct ClaudeAuthSheet: View { } .padding(.horizontal, 20) } + .scrollDismissesKeyboard(.interactively) .scrollBounceBehavior(.basedOnSize) .background(Color(uiColor: .systemGroupedBackground)) .navigationBarTitleDisplayMode(.inline) @@ -65,6 +66,8 @@ struct ClaudeAuthSheet: View { } } } + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) .interactiveDismissDisabled(isProcessing || status?.parsedState == .complete) .onAppear { if status == nil { diff --git a/xcode/catnip/Components/TerminalView.swift b/xcode/catnip/Components/TerminalView.swift index a4303d70..97e216af 100644 --- a/xcode/catnip/Components/TerminalView.swift +++ b/xcode/catnip/Components/TerminalView.swift @@ -175,6 +175,8 @@ class TerminalViewWrapper: UIView { private var navigationPad: NavigationPadView? private var keyboardHeight: CGFloat = 0 private var keyboardObservers: [NSObjectProtocol] = [] + private var resizeObservers: [NSObjectProtocol] = [] + private var lastBounds: CGRect = .zero func setup(with terminalView: SwiftTerm.TerminalView, controller: TerminalController) { self.terminalView = terminalView @@ -186,22 +188,27 @@ class TerminalViewWrapper: UIView { addSubview(terminalView) terminalView.translatesAutoresizingMaskIntoConstraints = false - // Calculate minimum width needed for minCols - let font = terminalView.font ?? UIFont.monospacedSystemFont(ofSize: 12, weight: .regular) - let minWidth = TerminalController.calculateMinWidth(font: font) + // Configure scrollbars to not consume width + terminalView.showsVerticalScrollIndicator = true + terminalView.showsHorizontalScrollIndicator = false + // Ensure scrollbars are overlay style (don't reduce content area) + terminalView.scrollIndicatorInsets = .zero - // Set width to at least minWidth, but remove trailing constraint so it can extend + // Pin terminal to all edges with minimal padding + // Reduced from 12pt to 8pt to compensate for any SwiftTerm internal spacing + // This ensures cols/rows match visual width more accurately NSLayoutConstraint.activate([ terminalView.topAnchor.constraint(equalTo: topAnchor), terminalView.bottomAnchor.constraint(equalTo: bottomAnchor), - terminalView.leadingAnchor.constraint(equalTo: leadingAnchor), - terminalView.widthAnchor.constraint(equalToConstant: minWidth) + terminalView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8), // Left padding + terminalView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8) // Right padding to balance ]) - // Add bottom content inset so terminal content extends behind the glass toolbar + // Add content insets: bottom for glass toolbar + // Left padding is handled by constraints, not content inset (to ensure cols/rows match visual width) terminalView.contentInset.bottom = 56 - // Also set scroll indicator insets so they don't appear behind the toolbar + // Set scroll indicator insets so they don't appear behind the toolbar terminalView.scrollIndicatorInsets.bottom = 56 // Create floating glass toolbar that overlays the terminal @@ -225,6 +232,9 @@ class TerminalViewWrapper: UIView { // Observe keyboard to position toolbar above it setupKeyboardObservers() + // Observe orientation and bounds changes for terminal resize + setupResizeObservers() + // Add pull-to-refresh for reconnecting WebSocket setupRefreshControl(for: terminalView, controller: controller) } @@ -232,16 +242,17 @@ class TerminalViewWrapper: UIView { override func didMoveToWindow() { super.didMoveToWindow() - // Add toolbar to window so it's positioned relative to screen, not the scrollable terminal - if let window = window, let toolbar = floatingToolbar, toolbar.superview == nil { - window.addSubview(toolbar) + // Add toolbar to this view so it's positioned relative to terminal view bounds, not screen + if let toolbar = floatingToolbar, toolbar.superview == nil { + addSubview(toolbar) + toolbar.alpha = 1 // Always visible at bottom of terminal view positionToolbar() } - // Add navigation pad to window (initially hidden) - if let window = window, let navPad = navigationPad, navPad.superview == nil { + // Add navigation pad to this view (initially hidden) + if let navPad = navigationPad, navPad.superview == nil { navPad.alpha = 0 // Hidden until toggled - window.addSubview(navPad) + addSubview(navPad) positionNavigationPad() } } @@ -250,37 +261,41 @@ class TerminalViewWrapper: UIView { super.layoutSubviews() positionToolbar() positionNavigationPad() + + // layoutSubviews is called frequently, use handleBoundsChange to deduplicate + handleBoundsChange() } private func positionToolbar() { - guard let toolbar = floatingToolbar, let window = window else { return } + guard let toolbar = floatingToolbar else { return } - let screenWidth = window.bounds.width + // Use wrapper's visible width (viewport width), not terminal's scrollable width + let toolbarWidth = bounds.width let toolbarHeight: CGFloat = 56 - let safeAreaBottom = window.safeAreaInsets.bottom + let safeAreaBottom = safeAreaInsets.bottom - // Position toolbar at bottom of screen, above keyboard if visible + // Position toolbar at bottom of visible viewport, above keyboard if visible let bottomOffset = keyboardHeight > 0 ? keyboardHeight : safeAreaBottom - let toolbarY = window.bounds.height - bottomOffset - toolbarHeight + let toolbarY = bounds.height - bottomOffset - toolbarHeight toolbar.frame = CGRect( x: 0, y: toolbarY, - width: screenWidth, + width: toolbarWidth, height: toolbarHeight ) } private func positionNavigationPad() { - guard let navPad = navigationPad, let window = window else { return } + guard let navPad = navigationPad else { return } let size = NavigationPadView.size let margin: CGFloat = 16 let gapAboveToolbar: CGFloat = 2 // Very close to toolbar for tight visual grouping // Position in lower-right corner, above toolbar - let x = window.bounds.width - size - margin - let y = (floatingToolbar?.frame.minY ?? window.bounds.height) - size - gapAboveToolbar + let x = bounds.width - size - margin + let y = (floatingToolbar?.frame.minY ?? bounds.height) - size - gapAboveToolbar navPad.frame = CGRect(x: x, y: y, width: size, height: size) } @@ -320,17 +335,56 @@ class TerminalViewWrapper: UIView { keyboardObservers = [showObserver, hideObserver, changeObserver] } + private func setupResizeObservers() { + // Observe device orientation changes + let orientationObserver = NotificationCenter.default.addObserver( + forName: UIDevice.orientationDidChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.handleOrientationChange() + } + + resizeObservers = [orientationObserver] + } + + private func handleOrientationChange() { + // Force layout and trigger resize after orientation change + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in + self?.forceTerminalResize() + } + } + + private func handleBoundsChange() { + // Bounds changed, check if width changed significantly + if abs(bounds.width - lastBounds.width) > 1.0 { + forceTerminalResize() + } + } + + private func forceTerminalResize() { + lastBounds = bounds + + // Force terminal to relayout with new bounds + terminalView?.setNeedsLayout() + terminalView?.layoutIfNeeded() + + // Small delay to ensure SwiftTerm has recalculated cols based on new width + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + self?.controller?.handleResize() + } + } + private func handleKeyboardWillShow(_ notification: Notification) { guard let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect, let duration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double, - let curve = notification.userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] as? UInt, - let window = window else { + let curve = notification.userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] as? UInt else { return } - // Convert keyboard frame to window coordinates for proper landscape handling - let keyboardFrameInWindow = window.convert(keyboardFrame, from: nil) - keyboardHeight = max(0, window.bounds.height - keyboardFrameInWindow.origin.y) + // Convert keyboard frame to this view's coordinates + let keyboardFrameInView = convert(keyboardFrame, from: nil) + keyboardHeight = max(0, bounds.height - keyboardFrameInView.origin.y) UIView.animate( withDuration: duration, @@ -338,8 +392,6 @@ class TerminalViewWrapper: UIView { options: UIView.AnimationOptions(rawValue: curve << 16), animations: { self.positionToolbar() - // Show toolbar when keyboard appears - self.floatingToolbar?.alpha = 1 } ) } @@ -352,20 +404,12 @@ class TerminalViewWrapper: UIView { keyboardHeight = 0 - // Check if we're in landscape mode - let isLandscape = UIDevice.current.orientation.isLandscape || - (window?.bounds.width ?? 0) > (window?.bounds.height ?? 0) - UIView.animate( withDuration: duration, delay: 0, options: UIView.AnimationOptions(rawValue: curve << 16), animations: { self.positionToolbar() - // Hide toolbar in landscape when keyboard is dismissed - if isLandscape { - self.floatingToolbar?.alpha = 0 - } } ) } @@ -373,14 +417,13 @@ class TerminalViewWrapper: UIView { private func handleKeyboardWillChangeFrame(_ notification: Notification) { guard let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect, let duration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double, - let curve = notification.userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] as? UInt, - let window = window else { + let curve = notification.userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] as? UInt else { return } - // Convert keyboard frame to window coordinates for proper landscape handling - let keyboardFrameInWindow = window.convert(keyboardFrame, from: nil) - keyboardHeight = max(0, window.bounds.height - keyboardFrameInWindow.origin.y) + // Convert keyboard frame to this view's coordinates + let keyboardFrameInView = convert(keyboardFrame, from: nil) + keyboardHeight = max(0, bounds.height - keyboardFrameInView.origin.y) UIView.animate( withDuration: duration, @@ -394,6 +437,7 @@ class TerminalViewWrapper: UIView { deinit { keyboardObservers.forEach { NotificationCenter.default.removeObserver($0) } + resizeObservers.forEach { NotificationCenter.default.removeObserver($0) } } private func setupRefreshControl(for terminalView: SwiftTerm.TerminalView, controller: TerminalController) { @@ -959,10 +1003,10 @@ class GlassTerminalAccessory: UIInputView { // Update toggle button state updateButtonTextHighlight(navPadToggleButton, active: true, color: .systemBlue) - // Get button position in window coordinates for morph animation + // Get button position in parent view coordinates for morph animation var originPoint: CGPoint? - if let button = navPadToggleButton, let window = window { - let buttonCenter = button.convert(CGPoint(x: button.bounds.midX, y: button.bounds.midY), to: window) + if let button = navPadToggleButton, let parent = superview { + let buttonCenter = button.convert(CGPoint(x: button.bounds.midX, y: button.bounds.midY), to: parent) originPoint = buttonCenter } @@ -978,10 +1022,10 @@ class GlassTerminalAccessory: UIInputView { // Update toggle button state updateButtonTextHighlight(navPadToggleButton, active: false, color: .label) - // Get button position in window coordinates for morph animation + // Get button position in parent view coordinates for morph animation var targetPoint: CGPoint? - if let button = navPadToggleButton, let window = window { - let buttonCenter = button.convert(CGPoint(x: button.bounds.midX, y: button.bounds.midY), to: window) + if let button = navPadToggleButton, let parent = superview { + let buttonCenter = button.convert(CGPoint(x: button.bounds.midX, y: button.bounds.midY), to: parent) targetPoint = buttonCenter } @@ -1899,35 +1943,61 @@ class TerminalController: NSObject, ObservableObject { } // Minimum terminal dimensions for TUI rendering - static let minCols: UInt16 = 65 + // iPad split view: ensure terminal has enough width even when sidebar is visible + static let minCols: UInt16 = 74 private static let minRows: UInt16 = 15 // Calculate minimum width for minCols (DRY helper) static func calculateMinWidth(font: UIFont) -> CGFloat { let charWidth = ("M" as NSString).size(withAttributes: [.font: font]).width - // Add fudge factor to account for padding/margins/scrollbar - return charWidth * CGFloat(minCols + 2) + // Add extra buffer to ensure we actually get minCols displayed + // Account for padding, margins, scrollbar, and rounding errors + return charWidth * CGFloat(minCols + 4) } private func sendReadySignal() { - // Get current terminal dimensions with minimums for TUI compatibility - let cols = max(UInt16(terminalView.getTerminal().cols), Self.minCols) - let rows = max(UInt16(terminalView.getTerminal().rows), Self.minRows) + // CRITICAL: Force layout pass to ensure we have correct dimensions + // Without this, portrait mode gets landscape dimensions on initial load + terminalView.setNeedsLayout() + terminalView.layoutIfNeeded() + + // Small delay to ensure layout has fully completed and SwiftTerm recalculated cols + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in + guard let self = self else { return } - // Note: Don't call showCursor() - let Claude's TUI control cursor visibility - // via escape sequences. Early showCursor() causes cursor to be visible at wrong position. + // Get current terminal dimensions - use actual size, no minimum enforcement + let terminal = self.terminalView.getTerminal() + let cols = UInt16(terminal.cols) + let rows = UInt16(terminal.rows) - // Send resize to ensure backend knows our dimensions - dataSource.sendResize(cols: cols, rows: rows) + // DEBUG: Log all dimension info + print("πŸ“ [sendReadySignal] Terminal buffer: \(terminal.cols)x\(terminal.rows)") + print("πŸ“ [sendReadySignal] TerminalView bounds: \(self.terminalView.bounds)") + print("πŸ“ [sendReadySignal] TerminalView frame: \(self.terminalView.frame)") + print("πŸ“ [sendReadySignal] Sending to backend: \(cols)x\(rows)") - // Send ready signal to trigger buffer replay - dataSource.sendReady() + // Note: Don't call showCursor() - let Claude's TUI control cursor visibility + // via escape sequences. Early showCursor() causes cursor to be visible at wrong position. + + // Send resize to ensure backend knows our dimensions + self.dataSource.sendResize(cols: cols, rows: rows) + + // Send ready signal to trigger buffer replay + self.dataSource.sendReady() + } } func handleResize() { - // Get current terminal dimensions with minimums for TUI compatibility - let cols = max(UInt16(terminalView.getTerminal().cols), Self.minCols) - let rows = max(UInt16(terminalView.getTerminal().rows), Self.minRows) + // Get current terminal dimensions - use actual size, no minimum enforcement + let terminal = terminalView.getTerminal() + let cols = UInt16(terminal.cols) + let rows = UInt16(terminal.rows) + + // DEBUG: Log all dimension info + print("πŸ“ [handleResize] Terminal buffer: \(terminal.cols)x\(terminal.rows)") + print("πŸ“ [handleResize] TerminalView bounds: \(terminalView.bounds)") + print("πŸ“ [handleResize] TerminalView frame: \(terminalView.frame)") + print("πŸ“ [handleResize] Sending to backend: \(cols)x\(rows)") dataSource.sendResize(cols: cols, rows: rows) } diff --git a/xcode/catnip/Theme/AdaptiveTheme.swift b/xcode/catnip/Theme/AdaptiveTheme.swift new file mode 100644 index 00000000..e9491cb6 --- /dev/null +++ b/xcode/catnip/Theme/AdaptiveTheme.swift @@ -0,0 +1,316 @@ +// +// AdaptiveTheme.swift +// catnip +// +// Adaptive theming system for iPhone, iPad, and macOS +// + +import SwiftUI +import Combine + +// MARK: - Device Context + +/// Represents the current device and size class context +enum DeviceContext { + case iPhoneCompact // iPhone portrait + case iPhoneLandscape // iPhone landscape + case iPadCompact // iPad in compact mode (1/3 split view, portrait) + case iPadRegular // iPad in regular mode (full screen, landscape) + case macCompact // macOS compact window + case macRegular // macOS regular/large window + + var isPhone: Bool { + switch self { + case .iPhoneCompact, .iPhoneLandscape: + return true + default: + return false + } + } + + var isTablet: Bool { + switch self { + case .iPadCompact, .iPadRegular: + return true + default: + return false + } + } + + var isMac: Bool { + switch self { + case .macCompact, .macRegular: + return true + default: + return false + } + } + + var isCompact: Bool { + switch self { + case .iPhoneCompact, .iPadCompact, .macCompact: + return true + default: + return false + } + } +} + +// MARK: - Adaptive Theme + +/// Provides device-appropriate layout values and preferences +class AdaptiveTheme: ObservableObject { + @Published var context: DeviceContext + + init(horizontalSizeClass: UserInterfaceSizeClass?, + verticalSizeClass: UserInterfaceSizeClass?, + idiom: UIUserInterfaceIdiom = UIDevice.current.userInterfaceIdiom) { + self.context = Self.determineContext( + horizontal: horizontalSizeClass, + vertical: verticalSizeClass, + idiom: idiom + ) + } + + // MARK: - Context Determination + + static func determineContext( + horizontal: UserInterfaceSizeClass?, + vertical: UserInterfaceSizeClass?, + idiom: UIUserInterfaceIdiom + ) -> DeviceContext { + switch idiom { + case .phone: + // iPhone: portrait = compact width, landscape = compact height + if horizontal == .compact { + return .iPhoneCompact // Portrait + } else { + return .iPhoneLandscape // Landscape + } + + case .pad: + // iPad: compact horizontal = split view or portrait, regular = full screen landscape + if horizontal == .compact { + return .iPadCompact // Split view or portrait + } else { + return .iPadRegular // Full screen landscape + } + + case .mac: + // macOS: based on window size + if horizontal == .compact { + return .macCompact + } else { + return .macRegular + } + + default: + // Default to phone compact for unknown devices + return .iPhoneCompact + } + } + + func update(horizontalSizeClass: UserInterfaceSizeClass?, + verticalSizeClass: UserInterfaceSizeClass?, + idiom: UIUserInterfaceIdiom = UIDevice.current.userInterfaceIdiom) { + let newContext = Self.determineContext( + horizontal: horizontalSizeClass, + vertical: verticalSizeClass, + idiom: idiom + ) + if newContext != context { + context = newContext + } + } + + // MARK: - Adaptive Spacing + + /// Container padding (outer edges) + var containerPadding: CGFloat { + switch context { + case .iPhoneCompact, .iPhoneLandscape: + return 16 + case .iPadCompact: + return 20 + case .iPadRegular: + return 24 + case .macCompact: + return 20 + case .macRegular: + return 32 + } + } + + /// Card/component internal padding + var cardPadding: CGFloat { + switch context { + case .iPhoneCompact, .iPhoneLandscape: + return 16 + case .iPadCompact: + return 20 + case .iPadRegular: + return 24 + case .macCompact: + return 20 + case .macRegular: + return 24 + } + } + + /// Spacing between sections + var sectionSpacing: CGFloat { + switch context { + case .iPhoneCompact, .iPhoneLandscape: + return 20 + case .iPadCompact: + return 24 + case .iPadRegular: + return 28 + case .macCompact: + return 24 + case .macRegular: + return 32 + } + } + + /// Minimum touch target size + var minimumTouchTarget: CGFloat { + switch context { + case .iPhoneCompact, .iPhoneLandscape, .iPadCompact, .iPadRegular: + return 48 // iOS HIG minimum + case .macCompact, .macRegular: + return 32 // macOS can be smaller (pointer precision) + } + } + + // MARK: - Layout Preferences + + /// Whether to use split view layout (sidebar + detail) + var prefersSplitView: Bool { + switch context { + case .iPhoneCompact, .iPhoneLandscape: + return false // iPhone uses stack navigation + case .iPadCompact, .iPadRegular, .macCompact, .macRegular: + return true // iPad and Mac use split view + } + } + + /// Whether to show persistent sidebar (vs overlay) + var prefersSidebar: Bool { + switch context { + case .iPhoneCompact, .iPhoneLandscape, .iPadCompact: + return false // Overlay on iPhone and compact iPad + case .iPadRegular, .macCompact, .macRegular: + return true // Persistent sidebar on regular iPad and Mac + } + } + + /// Preferred sidebar width + var sidebarWidth: CGFloat { + switch context { + case .iPhoneCompact, .iPhoneLandscape: + return 0 // No sidebar on iPhone + case .iPadCompact: + return 320 // Compact sidebar + case .iPadRegular: + return 360 // Comfortable sidebar + case .macCompact: + return 280 + case .macRegular: + return 320 + } + } + + /// Whether to show terminal and chat side-by-side + var prefersSideBySideTerminal: Bool { + switch context { + case .iPhoneCompact, .iPhoneLandscape, .iPadCompact: + return false // Too narrow for side-by-side + case .iPadRegular, .macCompact, .macRegular: + return true // Enough space for side-by-side + } + } + + /// Maximum content width for readability + var maxContentWidth: CGFloat { + switch context { + case .iPhoneCompact, .iPhoneLandscape: + return .infinity // Use full width on iPhone + case .iPadCompact: + return 400 + case .iPadRegular: + return 600 + case .macCompact: + return 500 + case .macRegular: + return 700 + } + } +} + +// MARK: - Environment Key + +private struct AdaptiveThemeKey: EnvironmentKey { + static let defaultValue = AdaptiveTheme( + horizontalSizeClass: .compact, + verticalSizeClass: .regular + ) +} + +extension EnvironmentValues { + var adaptiveTheme: AdaptiveTheme { + get { self[AdaptiveThemeKey.self] } + set { self[AdaptiveThemeKey.self] = newValue } + } +} + +// MARK: - Adaptive Theme Host + +/// Wrapper view that injects and updates adaptive theme based on size classes +struct AdaptiveThemeHost: View { + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @Environment(\.verticalSizeClass) private var verticalSizeClass + @StateObject private var adaptiveTheme: AdaptiveTheme + + let content: Content + + init(@ViewBuilder content: () -> Content) { + self.content = content() + _adaptiveTheme = StateObject(wrappedValue: AdaptiveTheme( + horizontalSizeClass: nil, + verticalSizeClass: nil + )) + } + + var body: some View { + content + .environment(\.adaptiveTheme, adaptiveTheme) + .onChange(of: horizontalSizeClass) { _, _ in + updateTheme() + } + .onChange(of: verticalSizeClass) { _, _ in + updateTheme() + } + .onAppear { + updateTheme() + } + } + + private func updateTheme() { + adaptiveTheme.update( + horizontalSizeClass: horizontalSizeClass, + verticalSizeClass: verticalSizeClass + ) + } +} + +// MARK: - View Extension + +extension View { + /// Wraps the view with adaptive theme support + func withAdaptiveTheme() -> some View { + AdaptiveThemeHost { + self + } + } +} diff --git a/xcode/catnip/Views/CodespaceView.swift b/xcode/catnip/Views/CodespaceView.swift index 1fcc271a..5caa1764 100644 --- a/xcode/catnip/Views/CodespaceView.swift +++ b/xcode/catnip/Views/CodespaceView.swift @@ -27,6 +27,7 @@ enum RepositoryListMode { struct CodespaceView: View { @Environment(\.scenePhase) private var scenePhase + @Environment(\.adaptiveTheme) private var adaptiveTheme @EnvironmentObject var authManager: AuthManager @StateObject private var installer = CatnipInstaller.shared @StateObject private var tracker = CodespaceCreationTracker.shared @@ -309,109 +310,116 @@ struct CodespaceView: View { } private var connectView: some View { - ScrollView { - VStack(spacing: 20) { - // Logo / brand - Image("logo") - .resizable() - .scaledToFit() - .frame(width: 80, height: 80) - .clipShape(RoundedRectangle(cornerRadius: 16)) - .shadow(color: Color.black.opacity(0.1), radius: 8, x: 0, y: 2) - .padding(.top, 40) - - Text("Access your GitHub Codespaces") - .font(.title2.weight(.semibold)) - .multilineTextAlignment(.center) - .padding(.bottom, 4) + ZStack { + Color(uiColor: .systemGroupedBackground) + .ignoresSafeArea() + + ScrollView { + VStack(spacing: 20) { + // Logo / brand + Image("logo") + .resizable() + .scaledToFit() + .frame(width: 80, height: 80) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .shadow(color: Color.black.opacity(0.1), radius: 8, x: 0, y: 2) + .padding(.top, 40) + + Text("Access your GitHub Codespaces") + .font(.title2.weight(.semibold)) + .multilineTextAlignment(.center) + .padding(.bottom, 4) - VStack(spacing: 16) { - Button { - // Determine action based on user's codespace and repository status - if installer.userStatus?.hasAnyCodespaces == false { - // No codespaces - check if they have repos with Catnip - if installer.hasRepositoriesWithCatnip { - // Has repos with Catnip β†’ Launch New Codespace - repositoryListMode = .launch + VStack(spacing: 16) { + Button { + // Determine action based on user's codespace and repository status + if installer.userStatus?.hasAnyCodespaces == false { + // No codespaces - check if they have repos with Catnip + if installer.hasRepositoriesWithCatnip { + // Has repos with Catnip β†’ Launch New Codespace + repositoryListMode = .launch + } else { + // No repos with Catnip β†’ Install Catnip + repositoryListMode = .installation + } + phase = .repositorySelection + Task { + do { + try await installer.fetchRepositories() + } catch { + errorMessage = "Failed to load repositories: \(error.localizedDescription)" + phase = .connect + } + } } else { - // No repos with Catnip β†’ Install Catnip - repositoryListMode = .installation + // Has codespaces β†’ Access My Codespace + handleConnect() } - phase = .repositorySelection - Task { - do { - try await installer.fetchRepositories() - } catch { - errorMessage = "Failed to load repositories: \(error.localizedDescription)" - phase = .connect + } label: { + HStack { + if phase == .connecting { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .padding(.trailing, 6) } + Text(phase == .connecting ? "Connecting…" : primaryButtonText) } - } else { - // Has codespaces β†’ Access My Codespace - handleConnect() - } - } label: { - HStack { - if phase == .connecting { - ProgressView() - .progressViewStyle(CircularProgressViewStyle(tint: .white)) - .padding(.trailing, 6) - } - Text(phase == .connecting ? "Connecting…" : primaryButtonText) } + .buttonStyle(ProminentButtonStyle(isDisabled: phase == .connecting)) + .disabled(phase == .connecting) + .accessibilityIdentifier("primaryActionButton") } - .buttonStyle(ProminentButtonStyle(isDisabled: phase == .connecting)) - .disabled(phase == .connecting) - .accessibilityIdentifier("primaryActionButton") - } - - // Inline status / error - if !statusMessage.isEmpty { - HStack(spacing: 10) { - Image(systemName: statusIcon) - .foregroundStyle(statusColor) - Text(statusMessage) - .font(.subheadline) - .foregroundStyle(.primary) - Spacer() + .frame(maxWidth: adaptiveTheme.maxContentWidth) + + // Inline status / error + if !statusMessage.isEmpty { + HStack(spacing: 10) { + Image(systemName: statusIcon) + .foregroundStyle(statusColor) + Text(statusMessage) + .font(.subheadline) + .foregroundStyle(.primary) + Spacer() + } + .padding(12) + .background(statusColor.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 10)) } - .padding(12) - .background(statusColor.opacity(0.08)) - .clipShape(RoundedRectangle(cornerRadius: 10)) - } - if !errorMessage.isEmpty { - HStack(spacing: 10) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(Color.red) - Text(errorMessage) - .font(.subheadline) - Spacer() + if !errorMessage.isEmpty { + HStack(spacing: 10) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(Color.red) + Text(errorMessage) + .font(.subheadline) + Spacer() + } + .foregroundStyle(Color.red) + .padding(12) + .background(Color.red.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 10)) } - .foregroundStyle(Color.red) - .padding(12) - .background(Color.red.opacity(0.08)) - .clipShape(RoundedRectangle(cornerRadius: 10)) - } - Spacer(minLength: 12) + Spacer(minLength: 12) - // Fun fact section - VStack(spacing: 6) { - HStack(spacing: 4) { - Text("🐾") - .font(.footnote) - Text(currentCatFact) - .font(.footnote) - .foregroundStyle(.secondary) + // Fun fact section + VStack(spacing: 6) { + HStack(spacing: 4) { + Text("🐾") + .font(.footnote) + Text(currentCatFact) + .font(.footnote) + .foregroundStyle(.secondary) + } + .multilineTextAlignment(.center) } - .multilineTextAlignment(.center) } + .frame(maxWidth: adaptiveTheme.maxContentWidth) + .frame(maxWidth: .infinity) + .padding(.horizontal, 20) } - .padding(.horizontal, 20) + .scrollBounceBehavior(.basedOnSize) } - .scrollBounceBehavior(.basedOnSize) - .background(Color(uiColor: .systemGroupedBackground)) .onAppear { if currentCatFact.isEmpty { currentCatFact = catFacts.randomElement() ?? catFacts[0] @@ -965,6 +973,7 @@ struct CodespaceView: View { .font(.subheadline) .foregroundStyle(.secondary) } + .frame(maxWidth: adaptiveTheme.maxContentWidth) .padding(.horizontal, 20) } } @@ -1186,100 +1195,106 @@ struct CodespaceView: View { } private var createRepositoryView: some View { - ScrollView { - VStack(spacing: 24) { - Spacer() + ZStack { + Color(uiColor: .systemGroupedBackground) + .ignoresSafeArea() - // Welcoming icon - Image(systemName: "plus.rectangle.on.folder") - .font(.system(size: 60)) - .foregroundStyle(Color.accentColor) + ScrollView { + VStack(spacing: 24) { + Spacer() - VStack(spacing: 12) { - Text("Create Your First Repository") - .font(.title2.weight(.semibold)) - .multilineTextAlignment(.center) + // Welcoming icon + Image(systemName: "plus.rectangle.on.folder") + .font(.system(size: 60)) + .foregroundStyle(Color.accentColor) - Text("Catnip needs a GitHub repository to work with. Create one to get started with agentic coding on your mobile device.") - .font(.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - } + VStack(spacing: 12) { + Text("Create Your First Repository") + .font(.title2.weight(.semibold)) + .multilineTextAlignment(.center) - Spacer() + Text("Catnip needs a GitHub repository to work with. Create one to get started with agentic coding on your mobile device.") + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + } - VStack(spacing: 12) { - // Primary action - Create on GitHub - Button { - if let url = URL(string: "https://github.com/new") { - UIApplication.shared.open(url) - } - } label: { - HStack { - Image(systemName: "plus.circle.fill") - Text("Create Repository on GitHub") + Spacer() + + VStack(spacing: 12) { + // Primary action - Create on GitHub + Button { + if let url = URL(string: "https://github.com/new") { + UIApplication.shared.open(url) + } + } label: { + HStack { + Image(systemName: "plus.circle.fill") + Text("Create Repository on GitHub") + } } - } - .buttonStyle(ProminentButtonStyle(isDisabled: false)) + .buttonStyle(ProminentButtonStyle(isDisabled: false)) - // Secondary action - Refresh to check - Button { - Task { - do { - // Force refresh both user status and repositories - // This will re-check GitHub state after user creates repo - try await installer.fetchUserStatus(forceRefresh: true) - try await installer.fetchRepositories(forceRefresh: true) - - // After refresh, determine next flow - await MainActor.run { - if installer.repositories.isEmpty { - // Still no repos - show error - errorMessage = "No repositories found yet. Create one on GitHub and try again." - } else { - // Success! Navigate to install flow - NSLog("βœ… User now has \(installer.repositories.count) repositories") - repositoryListMode = .installation - phase = .repositorySelection + // Secondary action - Refresh to check + Button { + Task { + do { + // Force refresh both user status and repositories + // This will re-check GitHub state after user creates repo + try await installer.fetchUserStatus(forceRefresh: true) + try await installer.fetchRepositories(forceRefresh: true) + + // After refresh, determine next flow + await MainActor.run { + if installer.repositories.isEmpty { + // Still no repos - show error + errorMessage = "No repositories found yet. Create one on GitHub and try again." + } else { + // Success! Navigate to install flow + NSLog("βœ… User now has \(installer.repositories.count) repositories") + repositoryListMode = .installation + phase = .repositorySelection + } + } + } catch { + await MainActor.run { + errorMessage = "Failed to check repositories: \(error.localizedDescription)" } - } - } catch { - await MainActor.run { - errorMessage = "Failed to check repositories: \(error.localizedDescription)" } } + } label: { + HStack { + Image(systemName: "arrow.clockwise") + Text("I Created a Repository") + } } - } label: { - HStack { - Image(systemName: "arrow.clockwise") - Text("I Created a Repository") - } + .buttonStyle(SecondaryButtonStyle(isDisabled: false)) } - .buttonStyle(SecondaryButtonStyle(isDisabled: false)) - } - .padding(.horizontal, 20) - // Show error if refresh found no repos - if !errorMessage.isEmpty { - HStack(spacing: 10) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(Color.orange) - Text(errorMessage) - .font(.subheadline) - Spacer() + // Show error if refresh found no repos + if !errorMessage.isEmpty { + HStack(spacing: 10) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(Color.orange) + Text(errorMessage) + .font(.subheadline) + Spacer() + } + .foregroundStyle(Color.orange) + .padding(12) + .background(Color.orange.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .padding(.horizontal, 20) } - .foregroundStyle(Color.orange) - .padding(12) - .background(Color.orange.opacity(0.08)) - .clipShape(RoundedRectangle(cornerRadius: 10)) - .padding(.horizontal, 20) } + .frame(maxWidth: adaptiveTheme.maxContentWidth) + .frame(maxWidth: .infinity) + .padding(.horizontal, 20) + .padding() } - .padding() + .scrollBounceBehavior(.basedOnSize) } - .scrollBounceBehavior(.basedOnSize) - .background(Color(uiColor: .systemGroupedBackground)) } } diff --git a/xcode/catnip/Views/WorkspaceDetailView.swift b/xcode/catnip/Views/WorkspaceDetailView.swift index ac0a4aa3..4ed1bd2f 100644 --- a/xcode/catnip/Views/WorkspaceDetailView.swift +++ b/xcode/catnip/Views/WorkspaceDetailView.swift @@ -8,7 +8,7 @@ import SwiftUI import MarkdownUI -enum WorkspacePhase { +enum WorkspacePhase: Equatable { case loading case input case working @@ -33,19 +33,17 @@ struct WorkspaceDetailView: View { @State private var isCreatingPR = false @State private var isUpdatingPR = false @State private var showingPRCreationSheet = false - @State private var isLandscape = false - @State private var showPortraitTerminal = false // Show terminal in portrait mode + @State private var showTerminalOnly = false // Show terminal only (iPhone toggle) // Codespace shutdown detection @State private var showShutdownAlert = false @State private var shutdownMessage: String? @Environment(\.dismiss) private var dismiss + @Environment(\.adaptiveTheme) private var adaptiveTheme // CatnipInstaller for status refresh @StateObject private var installer = CatnipInstaller.shared - @Environment(\.horizontalSizeClass) var horizontalSizeClass - @Environment(\.verticalSizeClass) var verticalSizeClass @EnvironmentObject var authManager: AuthManager init(workspaceId: String, initialWorkspace: WorkspaceInfo? = nil, pendingPrompt: String? = nil) { @@ -55,13 +53,6 @@ struct WorkspaceDetailView: View { // Set initial pending prompt if provided (e.g., from workspace creation flow) if let pendingPrompt = pendingPrompt, !pendingPrompt.isEmpty { _pendingUserPrompt = State(initialValue: pendingPrompt) - NSLog("πŸ”΅ WorkspaceDetailView init with pending prompt: \(pendingPrompt.prefix(50))...") - } - - if let initialWorkspace = initialWorkspace { - NSLog("πŸ”΅ WorkspaceDetailView init with pre-loaded workspace: \(workspaceId), activity: \(initialWorkspace.claudeActivityState?.rawValue ?? "nil")") - } else { - NSLog("🟑 WorkspaceDetailView init WITHOUT pre-loaded workspace: \(workspaceId) - will fetch") } } @@ -150,14 +141,19 @@ struct WorkspaceDetailView: View { return workspace?.displayName ?? "Workspace" } - private var mainContentView: some View { - Group { - // Show terminal in landscape or portrait terminal mode, normal UI otherwise - if isLandscape { - terminalView - } else if showPortraitTerminal { - portraitTerminalView - } else { + var body: some View { + // Determine phase during body evaluation if we have workspace data but haven't updated phase yet + if let workspace = poller.workspace, phase == .loading { + DispatchQueue.main.async { + self.determinePhase(for: workspace) + } + } + + return ZStack { + Color(uiColor: .systemBackground) + .ignoresSafeArea() + + Group { if phase == .loading { loadingView } else if phase == .error || workspace == nil { @@ -167,44 +163,16 @@ struct WorkspaceDetailView: View { } } } - } - - @ToolbarContentBuilder - private var toolbarContent: some ToolbarContent { - // Show terminal button when in portrait mode (not showing terminal) - // Wrapped in context progress ring to show Claude's token usage - if !isLandscape && !showPortraitTerminal { - ToolbarItem(placement: .topBarTrailing) { - Button { - showPortraitTerminal = true - } label: { - ContextProgressRing(contextTokens: sessionStats?.lastContextSizeTokens) { - Image(systemName: "terminal") - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(.primary) - } - } - .buttonStyle(.plain) - } - } - } - - var body: some View { - mainView - .task { + .task { await loadWorkspace() poller.start() - // Note: HealthCheckService is already running - WorkspacesView manages it as a singleton - // Start PTY after workspace is loaded if let workspace = workspace { Task { do { try await CatnipAPI.shared.startPTY(workspacePath: workspace.name, agent: "claude") - NSLog("βœ… Started PTY for workspace: \(workspace.name)") } catch { - NSLog("⚠️ Failed to start PTY: \(error)") // Non-fatal - PTY will be created on-demand if needed } } @@ -212,30 +180,15 @@ struct WorkspaceDetailView: View { } .onDisappear { poller.stop() - // Note: We don't stop HealthCheckService here because WorkspacesView manages it. - // WorkspacesView is still in the navigation stack when we're viewing a workspace detail. - } - .onChange(of: horizontalSizeClass) { - updateOrientation() - } - .onChange(of: verticalSizeClass) { - updateOrientation() - } - .onAppear { - updateOrientation() } .onChange(of: poller.workspace) { if let newWorkspace = poller.workspace { - NSLog("πŸ”„ Workspace updated - activity: \(newWorkspace.claudeActivityState?.rawValue ?? "nil"), title: \(newWorkspace.latestSessionTitle?.prefix(30) ?? "nil")") determinePhase(for: newWorkspace) - } else { - NSLog("⚠️ Workspace updated to nil") } } .onChange(of: poller.error) { if let newError = poller.error { - // Filter out "cancelled" errors (Code -999) - these are normal when requests are cancelled - // to make new ones and are not actionable for users + // Filter out "cancelled" errors - normal when requests are cancelled if !newError.lowercased().contains("cancelled") { error = newError } @@ -330,20 +283,6 @@ struct WorkspaceDetailView: View { } } - private var mainView: some View { - ZStack { - Color(uiColor: .systemBackground) - .ignoresSafeArea() - - mainContentView - } - .navigationTitle(navigationTitle) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - toolbarContent - } - } - private var loadingView: some View { VStack(spacing: 16) { ProgressView() @@ -377,13 +316,50 @@ struct WorkspaceDetailView: View { } private var contentView: some View { + Group { + if adaptiveTheme.prefersSideBySideTerminal { + // iPad/Mac: Vertical split - terminal on top, chat on bottom + AdaptiveSplitView( + defaultMode: .split, + allowModeToggle: true, + contextTokens: sessionStats?.lastContextSizeTokens, + leading: { terminalView }, + trailing: { chatInterfaceView } + ) + } else { + // iPhone: Single view with toggle + ZStack { + Color(uiColor: .systemBackground).ignoresSafeArea() + + if showTerminalOnly { + terminalView + } else { + chatInterfaceView + } + } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { showTerminalOnly.toggle() } label: { + ContextProgressRing(contextTokens: sessionStats?.lastContextSizeTokens) { + Image(systemName: showTerminalOnly ? "text.bubble" : "terminal") + .font(.system(size: 11, weight: .medium)) + } + } + .buttonStyle(.plain) + } + } + } + } + .navigationTitle(navigationTitle) + .navigationBarTitleDisplayMode(.inline) + } + + private var chatInterfaceView: some View { ScrollView { - VStack(spacing: 20) { - if phase == .input || (phase == .completed && !hasSessionContent) { - // Show empty state for input phase OR completed phase with no content - // (e.g., new workspace with commits but no Claude session) + VStack(spacing: adaptiveTheme.cardPadding) { + if phase == .input { emptyStateView - .padding(.horizontal, 16) + .padding(.horizontal, adaptiveTheme.containerPadding) } else if phase == .working { workingSection } else if phase == .completed { @@ -392,10 +368,10 @@ struct WorkspaceDetailView: View { if !error.isEmpty { errorBox - .padding(.horizontal, 16) + .padding(.horizontal, adaptiveTheme.containerPadding) } } - .padding(.top, 16) + .padding(.top, adaptiveTheme.containerPadding) } .safeAreaInset(edge: .bottom) { footerView @@ -633,9 +609,9 @@ struct WorkspaceDetailView: View { } private var footerView: some View { - Group { + VStack(spacing: 0) { if phase == .completed && hasSessionContent { - // Only show footer buttons if we have actual session content to show + // Footer buttons should fill the same horizontal space as scrollable content HStack(spacing: 12) { Button { showPromptSheet = true @@ -708,37 +684,29 @@ struct WorkspaceDetailView: View { .disabled((workspace?.commitCount ?? 0) == 0 || isUpdatingPR) .opacity(((workspace?.commitCount ?? 0) == 0 || isUpdatingPR) ? 0.5 : 1.0) } - .padding(16) + .padding(.vertical, 16) + .padding(.horizontal, adaptiveTheme.containerPadding) // Match scrollable content padding .background(.ultraThinMaterial) } } } + @MainActor private func loadWorkspace() async { // If poller already has workspace data (from initialWorkspace), skip fetch if let workspace = poller.workspace { - await MainActor.run { - NSLog("βœ… Using pre-loaded workspace data, skipping initial fetch for: \(workspaceId)") - determinePhase(for: workspace) - } - - // Always hydrate session data on initial load - // API will return appropriate data based on whether a session exists - NSLog("πŸ“Š Initial hydration: fetching session data for workspace") + determinePhase(for: workspace) await fetchSessionData() return } - NSLog("πŸ” No pre-loaded data, fetching workspace: \(workspaceId)") phase = .loading error = "" do { // On initial load, don't pass etag - we need the workspace data guard let result = try await CatnipAPI.shared.getWorkspace(id: workspaceId, ifNoneMatch: nil) else { - // This shouldn't happen on initial load without etag await MainActor.run { - NSLog("❌ getWorkspace returned nil (304 Not Modified?) for: \(workspaceId)") self.error = "Workspace not found" phase = .error } @@ -746,160 +714,96 @@ struct WorkspaceDetailView: View { } let workspace = result.workspace - NSLog("βœ… Successfully fetched workspace: \(workspaceId)") - - await MainActor.run { - // Poller will manage workspace state - determinePhase(for: workspace) - } - - // Always hydrate session data on initial load - // API will return appropriate data based on whether a session exists - NSLog("πŸ“Š Initial hydration: fetching session data for workspace") + determinePhase(for: workspace) await fetchSessionData() } catch let apiError as APIError { - await MainActor.run { - NSLog("❌ API error fetching workspace \(workspaceId): \(apiError.errorDescription ?? "unknown")") - self.error = apiError.errorDescription ?? "Unknown error" - phase = .error - } + self.error = apiError.errorDescription ?? "Unknown error" + phase = .error } catch { - await MainActor.run { - NSLog("❌ Error fetching workspace \(workspaceId): \(error.localizedDescription)") - self.error = error.localizedDescription - phase = .error - } + self.error = error.localizedDescription + phase = .error } } + @MainActor private func determinePhase(for workspace: WorkspaceInfo) { // Use effective values that prefer session data over workspace data let currentTitle = effectiveSessionTitle let currentTodos = effectiveTodos - - NSLog("πŸ“Š determinePhase - claudeActivityState: %@, latestSessionTitle: %@, todos: %d, isDirty: %@, commits: %d, pendingPrompt: %@", - workspace.claudeActivityState.map { "\($0)" } ?? "nil", - currentTitle ?? "nil", - currentTodos?.count ?? 0, - workspace.isDirty.map { "\($0)" } ?? "nil", - workspace.commitCount ?? 0, - pendingUserPrompt != nil ? "yes" : "no") - let previousPhase = phase // Clear pendingUserPrompt if backend has started processing, completed, or timed out - // This prevents getting stuck in "working" phase if pendingUserPrompt != nil { - // Backend received and started processing our prompt if workspace.claudeActivityState == .running { - NSLog("πŸ“Š Backend started processing - clearing pending prompt") pendingUserPrompt = nil pendingUserPromptTimestamp = nil - } - // Backend completed the session - else if currentTitle != nil { - NSLog("πŸ“Š Session created - clearing pending prompt") + } else if currentTitle != nil { pendingUserPrompt = nil pendingUserPromptTimestamp = nil - } - // Timeout: clear stale pending prompt after 30 seconds - else if let timestamp = pendingUserPromptTimestamp, + } else if let timestamp = pendingUserPromptTimestamp, Date().timeIntervalSince(timestamp) > 30 { - NSLog("⚠️ Pending prompt timed out after 30s - clearing") pendingUserPrompt = nil pendingUserPromptTimestamp = nil } } - // Show "working" phase when: - // 1. Claude is .active (actively processing), OR - // 2. We have a pending prompt (just sent a prompt but backend hasn't updated yet) - // State meanings: - // - .inactive: no PTY running - // - .running: PTY up and running, waiting for user action (Claude NOT working) - // - .active: PTY up and Claude is actively working + // Determine new phase based on Claude activity state and session content + let newPhase: WorkspacePhase if workspace.claudeActivityState == .active || pendingUserPrompt != nil { - phase = .working - - // Fetch latest message and diff while working - Task { - await fetchLatestMessage(for: workspace) - await fetchDiffIfNeeded(for: workspace) - } + newPhase = .working } else if currentTitle != nil || currentTodos?.isEmpty == false { - // Has a session title or todos - definitely completed - phase = .completed - - // Fetch the latest message for completed sessions - Task { - await fetchLatestMessage(for: workspace) - await fetchDiffIfNeeded(for: workspace) - } + newPhase = .completed } else if workspace.isDirty == true || (workspace.commitCount ?? 0) > 0 { - // Workspace has modifications or commits but no session title - // This can happen with old /messages endpoint usage - // Treat as completed to show the changes - phase = .completed - NSLog("πŸ“Š Workspace has changes but no session - treating as completed") + newPhase = .completed + } else { + newPhase = .input + } + + // Only update if changed + if newPhase != previousPhase { + phase = newPhase + } - // Try to fetch latest message in case there is one + // Fetch data based on phase + if newPhase == .working || newPhase == .completed { Task { await fetchLatestMessage(for: workspace) await fetchDiffIfNeeded(for: workspace) } - } else { - phase = .input } - - NSLog("πŸ“Š determinePhase - final phase: %@ (was: %@)", "\(phase)", "\(previousPhase)") } private func sendPrompt() async { guard let workspace = workspace, !prompt.trimmingCharacters(in: .whitespaces).isEmpty else { - NSLog("🐱 [WorkspaceDetailView] Cannot send prompt - workspace or prompt is empty") return } let promptToSend = prompt.trimmingCharacters(in: .whitespaces) - NSLog("🐱 [WorkspaceDetailView] Sending prompt to workspace: \(workspace.id)") - NSLog("🐱 [WorkspaceDetailView] Prompt length: \(promptToSend.count) chars") - NSLog("🐱 [WorkspaceDetailView] Workspace name (session ID): \(workspace.name)") - isSubmitting = true error = "" do { - NSLog("🐱 [WorkspaceDetailView] About to call sendPromptToPTY API...") try await CatnipAPI.shared.sendPromptToPTY( workspacePath: workspace.name, prompt: promptToSend, agent: "claude" ) - NSLog("🐱 [WorkspaceDetailView] βœ… Successfully sent prompt") await MainActor.run { - // Store the prompt we just sent for immediate display pendingUserPrompt = promptToSend pendingUserPromptTimestamp = Date() - NSLog("🐱 [WorkspaceDetailView] Stored pending prompt: \(promptToSend.prefix(50))...") - prompt = "" showPromptSheet = false phase = .working isSubmitting = false - - // Trigger immediate refresh after sending prompt - NSLog("🐱 [WorkspaceDetailView] Triggering poller refresh") poller.refresh() } } catch APIError.timeout { - NSLog("🐱 [WorkspaceDetailView] ⏰ PTY not ready (timeout)") await MainActor.run { self.error = "Claude is still starting up. Please try again in a moment." isSubmitting = false } } catch { - NSLog("🐱 [WorkspaceDetailView] ❌ Failed to send prompt: \(error)") await MainActor.run { self.error = error.localizedDescription isSubmitting = false @@ -917,60 +821,43 @@ struct WorkspaceDetailView: View { } } } catch { - NSLog("❌ Failed to fetch latest message: %@", error.localizedDescription) + // Silently fail - message fetch is best effort } } /// Fetch session data to hydrate context stats and other session info - /// Initial fetch doesn't use ETag - we always want fresh data on first load private func fetchSessionData() async { do { - NSLog("πŸ“Š Fetching session data for workspace: \(workspaceId)") - // Don't pass ETag for initial fetch - we want fresh data let result = try await CatnipAPI.shared.getSessionData(workspaceId: workspaceId, ifNoneMatch: nil) - await MainActor.run { if let result = result { poller.updateSessionData(result.sessionData) - NSLog("βœ… Hydrated session data - context tokens: \(result.sessionData.stats?.lastContextSizeTokens ?? 0)") - } else { - NSLog("⚠️ Session data fetch returned nil (no session yet)") } } } catch { - NSLog("❌ Failed to fetch session data: \(error.localizedDescription)") + // Silently fail - session data fetch is best effort } } private func fetchDiffIfNeeded(for workspace: WorkspaceInfo) async { // Only fetch if workspace has changes guard (workspace.isDirty == true || (workspace.commitCount ?? 0) > 0) else { - NSLog("πŸ“Š No changes to fetch diff for") return } // Skip if we already have a cached diff and Claude is still actively working - // We want to refetch periodically during active work, but avoid spamming requests - // When work completes, we'll refetch one final time from the completed phase let isActivelyWorking = workspace.claudeActivityState == .active if cachedDiff != nil && isActivelyWorking { - NSLog("πŸ“Š Diff already cached and Claude still actively working, skipping fetch to avoid spam") return } - NSLog("πŸ“Š Fetching diff for workspace with changes (dirty: %@, commits: %d, activelyWorking: %@)", - workspace.isDirty.map { "\($0)" } ?? "nil", - workspace.commitCount ?? 0, - isActivelyWorking ? "yes" : "no") - do { let diff = try await CatnipAPI.shared.getWorkspaceDiff(id: workspace.id) await MainActor.run { - NSLog("πŸ“Š Successfully fetched diff: %d files changed", diff.fileDiffs.count) self.cachedDiff = diff } } catch { - NSLog("❌ Failed to fetch diff: %@", error.localizedDescription) + // Silently fail - diff fetch is best effort } } @@ -1001,28 +888,23 @@ struct WorkspaceDetailView: View { private func updatePR() async { guard let workspace = workspace else { return } - - NSLog("πŸ”„ Updating PR for workspace: \(workspace.id)") + isUpdatingPR = true error = "" - + do { let prUrl = try await CatnipAPI.shared.updatePullRequest(workspaceId: workspace.id) - + await MainActor.run { - NSLog("βœ… Successfully updated PR: \(prUrl)") isUpdatingPR = false - - // Open the updated PR + if let url = URL(string: prUrl) { UIApplication.shared.open(url) } - - // Trigger refresh to update state (clear dirty flag etc if backend handles it) + poller.refresh() } } catch { - NSLog("❌ Failed to update PR: \(error)") await MainActor.run { self.error = "Failed to update PR: \(error.localizedDescription)" isUpdatingPR = false @@ -1033,29 +915,16 @@ struct WorkspaceDetailView: View { // MARK: - Terminal View private var terminalView: some View { - let codespaceName = UserDefaults.standard.string(forKey: "codespace_name") ?? "nil" - let token = authManager.sessionToken ?? "nil" let worktreeName = workspace?.name ?? "unknown" + let shouldConnect = adaptiveTheme.prefersSideBySideTerminal || showTerminalOnly - // πŸ” DEBUG: WebSocket connection info for testing - NSLog("πŸ”πŸ”πŸ” WEBSOCKET_DEBUG πŸ”πŸ”πŸ”") - NSLog("πŸ” Codespace: \(codespaceName)") - NSLog("πŸ” Session Token: \(token)") - NSLog("πŸ” WebSocket Base URL: \(websocketBaseURL)") - NSLog("πŸ” Workspace ID (UUID): \(workspaceId)") - NSLog("πŸ” Worktree Name (session): \(worktreeName)") - NSLog("πŸ”πŸ”πŸ” END WEBSOCKET_DEBUG πŸ”πŸ”πŸ”") - - // Terminal view with navigation bar - // Use worktree name (not UUID) as the session parameter - // Only connect when in landscape mode to prevent premature connections - // Let keyboard naturally push content up by not ignoring safe area return TerminalView( workspaceId: worktreeName, baseURL: websocketBaseURL, codespaceName: UserDefaults.standard.string(forKey: "codespace_name"), authToken: authManager.sessionToken, - shouldConnect: isLandscape + shouldConnect: shouldConnect, + showExitButton: false ) } @@ -1064,72 +933,6 @@ struct WorkspaceDetailView: View { return "wss://catnip.run" } - // MARK: - Portrait Terminal View - - private var portraitTerminalView: some View { - let codespaceName = UserDefaults.standard.string(forKey: "codespace_name") ?? "nil" - let worktreeName = workspace?.name ?? "unknown" - - NSLog("🐱 Portrait terminal - Codespace: \(codespaceName), Worktree: \(worktreeName)") - - // Terminal fills available space - glass accessory overlays it - // Add ~60 points width for approximately 7-8 extra columns at 12pt mono font - return GeometryReader { geometry in - ScrollView(.horizontal, showsIndicators: false) { - TerminalView( - workspaceId: worktreeName, - baseURL: websocketBaseURL, - codespaceName: UserDefaults.standard.string(forKey: "codespace_name"), - authToken: authManager.sessionToken, - shouldConnect: showPortraitTerminal, - showExitButton: false, - showDismissButton: false - ) - .frame(width: geometry.size.width + 60) - } - } - .background(Color.black) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button { - showPortraitTerminal = false - } label: { - ContextProgressRing(contextTokens: sessionStats?.lastContextSizeTokens) { - Image(systemName: "square.grid.2x2") - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(.primary) - } - } - .buttonStyle(.plain) - } - } - } - - private func updateOrientation() { - // Detect landscape: compact height OR regular width + compact height - // This works for both iPhone landscape and iPad landscape - let newIsLandscape = verticalSizeClass == .compact || - (horizontalSizeClass == .regular && verticalSizeClass == .compact) - - if newIsLandscape != isLandscape { - isLandscape = newIsLandscape - NSLog("πŸ“± Orientation changed - isLandscape: \(isLandscape)") - - // Close portrait terminal when rotating to landscape - if newIsLandscape && showPortraitTerminal { - showPortraitTerminal = false - } - } - } - - private func rotateToLandscape() { - // Request landscape orientation - if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene { - windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: .landscape)) { error in - NSLog("⚠️ Failed to rotate to landscape: \(error.localizedDescription)") - } - } - } } struct TodoListView: View { diff --git a/xcode/catnip/Views/WorkspacesView.swift b/xcode/catnip/Views/WorkspacesView.swift index 0bf028a7..1f8c761f 100644 --- a/xcode/catnip/Views/WorkspacesView.swift +++ b/xcode/catnip/Views/WorkspacesView.swift @@ -21,9 +21,11 @@ struct WorkspacesView: View { @State private var branchesLoading = false @State private var createSheetError: String? // Separate error for create sheet @State private var deleteConfirmation: WorkspaceInfo? // Workspace to delete - @State private var navigationWorkspace: WorkspaceInfo? // Workspace to navigate to + @State private var navigationWorkspace: WorkspaceInfo? // Workspace to navigate to (iPhone) + @State private var selectedWorkspaceId: String? // Selected workspace for split view (iPad) @State private var pendingPromptForNavigation: String? // Prompt to pass to detail view @State private var createdWorkspaceForRetry: WorkspaceInfo? // Track created workspace for retry on 408 timeout + @State private var columnVisibility: NavigationSplitViewVisibility = .all // Control sidebar visibility (iPad) // Claude authentication @State private var showClaudeAuthSheet = false @@ -34,6 +36,7 @@ struct WorkspacesView: View { @State private var shutdownMessage: String? @State private var isReconnecting = false // Track if we're in reconnection flow @Environment(\.dismiss) private var dismiss + @Environment(\.adaptiveTheme) private var adaptiveTheme // CatnipInstaller for status refresh @StateObject private var installer = CatnipInstaller.shared @@ -43,27 +46,16 @@ struct WorkspacesView: View { } var body: some View { - ZStack { - Color(uiColor: .systemGroupedBackground) - .ignoresSafeArea() - - if isLoading { - loadingView - } else if let error = error { - errorView(error) - } else if workspaces.isEmpty { - emptyView + // CRITICAL: Use different navigation patterns for iPhone vs iPad + // iPhone is already inside ContentView's NavigationStack - DON'T create another! + // iPad needs NavigationSplitView for sidebar/detail pattern + Group { + if adaptiveTheme.prefersSplitView { + // iPad: Use NavigationSplitView for sidebar + detail + iPadSplitView } else { - listView - } - } - .navigationTitle("Workspaces") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button(action: { showCreateSheet = true }) { - Image(systemName: "plus") - } + // iPhone: Simple content view - we're already inside NavigationStack from ContentView + iPhoneContentView } } .task { @@ -179,20 +171,7 @@ struct WorkspacesView: View { } } } - .navigationDestination(item: $navigationWorkspace) { workspace in - WorkspaceDetailView( - workspaceId: workspace.id, - initialWorkspace: workspace, - pendingPrompt: pendingPromptForNavigation - ) - } - .onChange(of: navigationWorkspace) { - // Clear pending prompt after navigation completes - if navigationWorkspace == nil && pendingPromptForNavigation != nil { - pendingPromptForNavigation = nil - NSLog("🐱 [WorkspacesView] Cleared pendingPromptForNavigation after navigation") - } - } + // Note: selectedWorkspaceId/navigationWorkspace change handlers are in platform-specific views .sheet(isPresented: $showClaudeAuthSheet) { let codespaceName = UserDefaults.standard.string(forKey: "codespace_name") ?? "unknown" ClaudeAuthSheet(isPresented: $showClaudeAuthSheet, codespaceName: codespaceName) { @@ -265,7 +244,52 @@ struct WorkspacesView: View { .padding() } - private var listView: some View { + // MARK: - Platform-Specific Navigation Views + + /// iPhone navigation - simple content view, relies on parent's NavigationStack + /// CRITICAL: Do NOT wrap this in a NavigationStack - ContentView already provides one! + private var iPhoneContentView: some View { + ZStack { + Color(uiColor: .systemGroupedBackground) + .ignoresSafeArea() + + if isLoading { + loadingView + } else if let error = error { + errorView(error) + } else if workspaces.isEmpty { + emptyView + } else { + iPhoneListView + } + } + .navigationTitle("Workspaces") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button(action: { showCreateSheet = true }) { + Image(systemName: "plus") + } + } + } + // Use the original navigation pattern - item binding, not NavigationLink(value:) + .navigationDestination(item: $navigationWorkspace) { workspace in + WorkspaceDetailView( + workspaceId: workspace.id, + initialWorkspace: workspace, + pendingPrompt: pendingPromptForNavigation + ) + } + .onChange(of: navigationWorkspace) { + // Clear pending prompt after navigation completes + if navigationWorkspace == nil && pendingPromptForNavigation != nil { + pendingPromptForNavigation = nil + } + } + } + + /// iPhone list view - uses Button to trigger navigation, not NavigationLink + private var iPhoneListView: some View { List { ForEach(workspaces) { workspace in Button { @@ -295,32 +319,154 @@ struct WorkspacesView: View { get: { deleteConfirmation != nil }, set: { if !$0 { deleteConfirmation = nil } } )) { - Button("Cancel", role: .cancel) { - deleteConfirmation = nil + deleteAlertButtons + } message: { + deleteAlertMessage + } + } + + /// iPad navigation - uses NavigationSplitView for sidebar/detail pattern + private var iPadSplitView: some View { + NavigationSplitView(columnVisibility: $columnVisibility) { + workspacesList + .navigationSplitViewColumnWidth(adaptiveTheme.sidebarWidth) + } detail: { + if let selectedId = selectedWorkspaceId, + let workspace = workspaces.first(where: { $0.id == selectedId }) { + WorkspaceDetailView( + workspaceId: workspace.id, + initialWorkspace: workspace, + pendingPrompt: pendingPromptForNavigation + ) + } else { + emptySelectionView } - Button("Delete", role: .destructive) { - if let workspace = deleteConfirmation { - Task { - await deleteWorkspace(workspace) + } + .navigationSplitViewStyle(.balanced) + .onChange(of: selectedWorkspaceId) { _, newValue in + // Auto-hide sidebar when workspace is selected on iPad + if newValue != nil { + columnVisibility = .detailOnly + } + // Clear pending prompt + if newValue == nil && pendingPromptForNavigation != nil { + pendingPromptForNavigation = nil + } + } + } + + // MARK: - Shared Content Views (iPad only uses workspacesList) + + /// iPad workspace list - used only inside iPadSplitView's sidebar + private var workspacesList: some View { + Group { + if isLoading { + loadingView + } else if let error = error { + errorView(error) + } else if workspaces.isEmpty { + emptyView + } else { + // iPad: Use selection binding for split view + List(selection: $selectedWorkspaceId) { + iPadWorkspaceListContent + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .navigationTitle("Workspaces") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button(action: { showCreateSheet = true }) { + Image(systemName: "plus") + } } } + .accessibilityIdentifier("workspacesList") + .alert("Delete Workspace", isPresented: Binding( + get: { deleteConfirmation != nil }, + set: { if !$0 { deleteConfirmation = nil } } + )) { + deleteAlertButtons + } message: { + deleteAlertMessage + } } - } message: { - if let workspace = deleteConfirmation { - let changesList = [ - workspace.isDirty == true ? "uncommitted changes" : nil, - (workspace.commitCount ?? 0) > 0 ? "\(workspace.commitCount ?? 0) commits" : nil - ].compactMap { $0 } + } + } - if !changesList.isEmpty { - Text("Delete workspace \"\(workspace.displayName)\"? This workspace has \(changesList.joined(separator: " and ")). This action cannot be undone.") - } else { - Text("Delete workspace \"\(workspace.displayName)\"? This action cannot be undone.") + /// iPad workspace list content - uses NavigationLink with value for split view selection + @ViewBuilder + private var iPadWorkspaceListContent: some View { + ForEach(workspaces) { workspace in + NavigationLink(value: workspace.id) { + WorkspaceCard(workspace: workspace) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + Color(uiColor: .secondarySystemBackground) + .ignoresSafeArea(edges: .horizontal) + ) + .contentShape(Rectangle()) + } + .listRowInsets(EdgeInsets()) + .listRowSeparator(.visible) + .listRowBackground(Color.clear) + .accessibilityIdentifier("workspace-\(workspace.id)") + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + deleteConfirmation = workspace + } label: { + Label("Delete", systemImage: "trash") + } + } + } + } + + @ViewBuilder + private var deleteAlertButtons: some View { + Button("Cancel", role: .cancel) { + deleteConfirmation = nil + } + Button("Delete", role: .destructive) { + if let workspace = deleteConfirmation { + Task { + await deleteWorkspace(workspace) } } } } + @ViewBuilder + private var deleteAlertMessage: some View { + if let workspace = deleteConfirmation { + let changesList = [ + workspace.isDirty == true ? "uncommitted changes" : nil, + (workspace.commitCount ?? 0) > 0 ? "\(workspace.commitCount ?? 0) commits" : nil + ].compactMap { $0 } + + if !changesList.isEmpty { + Text("Delete workspace \"\(workspace.displayName)\"? This workspace has \(changesList.joined(separator: " and ")). This action cannot be undone.") + } else { + Text("Delete workspace \"\(workspace.displayName)\"? This action cannot be undone.") + } + } + } + + private var emptySelectionView: some View { + VStack(spacing: 20) { + Image(systemName: "square.stack.3d.up") + .font(.system(size: 56)) + .foregroundStyle(.secondary) + Text("Select a Workspace") + .font(.title2.weight(.semibold)) + Text("Choose a workspace from the sidebar to view details") + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding() + } + private func loadWorkspaces() async { // Only show loading spinner if we have no data yet (initial load) // This allows refreshes to happen in the background without disrupting the UI @@ -546,7 +692,14 @@ struct WorkspacesView: View { showCreateSheet = false isCreating = false createdWorkspaceForRetry = nil // Clear retry state on success - navigationWorkspace = workspace + // Navigate using the appropriate mechanism for the current device + if adaptiveTheme.prefersSplitView { + // iPad: Use selection binding + selectedWorkspaceId = workspace.id + } else { + // iPhone: Use navigation item binding + navigationWorkspace = workspace + } NSLog("πŸš€ Navigating to newly created workspace: \(workspace.id)") } } catch { diff --git a/xcode/catnip/catnipApp.swift b/xcode/catnip/catnipApp.swift index 239ea701..c9f01c92 100644 --- a/xcode/catnip/catnipApp.swift +++ b/xcode/catnip/catnipApp.swift @@ -24,6 +24,7 @@ struct catnipApp: App { var body: some Scene { WindowGroup { ContentView() + .withAdaptiveTheme() .environmentObject(authManager) .environmentObject(notificationManager) .onAppear { diff --git a/xcode/test-ios-comparison.sh b/xcode/test-ios-comparison.sh new file mode 100755 index 00000000..ca8ae661 --- /dev/null +++ b/xcode/test-ios-comparison.sh @@ -0,0 +1,76 @@ +#!/bin/bash +set -e + +# Test on both iOS 18 and iOS 26 to ensure compatibility +# Using iPad Pro 13-inch on both versions for true OS comparison +DEVICE_IOS18="73B540BB-FE5C-4D99-A07D-7518DAFC4A90" # iPad Pro 13-inch (M4), iOS 18.5 +DEVICE_IOS26="FD3F1EFD-2EFD-4889-A484-008CB60E27B2" # iPad Pro 13-inch (M4), iOS 26.0 +APP_BUNDLE="com.wandb.catnip" +SCREENSHOT_DIR="$HOME/Desktop/catnip-screenshots" +TIMESTAMP=$(date +%Y%m%d-%H%M%S) + +echo "πŸš€ Testing iPad on iOS 18 and iOS 26 for compatibility..." + +# Create screenshot directory +mkdir -p "$SCREENSHOT_DIR" + +# Build app once for both devices +echo "πŸ”¨ Building app..." +xcodebuild -project catnip.xcodeproj \ + -scheme catnip \ + -configuration Debug \ + -sdk iphonesimulator \ + -quiet \ + build + +# Function to test a device +test_device() { + local DEVICE_ID=$1 + local IOS_VERSION=$2 + local DEVICE_NAME=$3 + + echo "" + echo "πŸ“± Testing on iOS ${IOS_VERSION} (${DEVICE_NAME})..." + + # Boot simulator if needed + xcrun simctl boot "$DEVICE_ID" 2>/dev/null || true + sleep 2 + + # Install app + echo "πŸ“¦ Installing app on iOS ${IOS_VERSION}..." + xcrun simctl install "$DEVICE_ID" \ + ~/Library/Developer/Xcode/DerivedData/Catnip-*/Build/Products/Debug-iphonesimulator/catnip.app + + # Launch app + echo "πŸš€ Launching app on iOS ${IOS_VERSION}..." + xcrun simctl launch "$DEVICE_ID" "$APP_BUNDLE" \ + -UITesting \ + -SkipAuthentication \ + -UseMockData \ + -ShowWorkspacesList + + # Wait for app to settle + sleep 5 + + # Take sidebar screenshot + SCREENSHOT_SIDEBAR="$SCREENSHOT_DIR/ios${IOS_VERSION}-sidebar-${TIMESTAMP}.png" + echo "πŸ“Έ Taking sidebar screenshot..." + xcrun simctl io "$DEVICE_ID" screenshot "$SCREENSHOT_SIDEBAR" + echo " Saved: $SCREENSHOT_SIDEBAR" + + # Terminate app + xcrun simctl terminate "$DEVICE_ID" "$APP_BUNDLE" 2>/dev/null || true +} + +# Test both versions (same device model, different iOS versions) +test_device "$DEVICE_IOS18" "18" "iPad Pro 13-inch (M4)" +test_device "$DEVICE_IOS26" "26" "iPad Pro 13-inch (M4)" + +echo "" +echo "βœ… Compatibility testing complete!" +echo "" +echo "πŸ“Έ Screenshots saved to: $SCREENSHOT_DIR" +echo " iOS 18: ios18-sidebar-${TIMESTAMP}.png" +echo " iOS 26: ios26-sidebar-${TIMESTAMP}.png" +echo "" +echo "Compare these screenshots to verify layout consistency across iOS versions." diff --git a/xcode/test-ipad-comparison.sh b/xcode/test-ipad-comparison.sh new file mode 100755 index 00000000..cf5e8dbf --- /dev/null +++ b/xcode/test-ipad-comparison.sh @@ -0,0 +1,91 @@ +#!/bin/bash +set -e + +# Test on both iOS 18 and iOS 26 to ensure compatibility +DEVICE_IOS18="73B540BB-FE5C-4D99-A07D-7518DAFC4A90" # iPad Pro 13-inch (M4), iOS 18.5 +DEVICE_IOS26="8059BBCF-6AB4-4C5F-AF9E-64A0B63BCE97" # iPad (A16), iOS 26.0 +APP_BUNDLE="com.wandb.catnip" +SCREENSHOT_DIR="$HOME/Desktop/catnip-screenshots" +TIMESTAMP=$(date +%Y%m%d-%H%M%S) + +echo "πŸš€ Testing iPad on iOS 18 and iOS 26 for compatibility..." + +# Create screenshot directory +mkdir -p "$SCREENSHOT_DIR" + +# Build app once for both devices +echo "πŸ”¨ Building app..." +xcodebuild -project catnip.xcodeproj \ + -scheme catnip \ + -configuration Debug \ + -sdk iphonesimulator \ + -quiet \ + build + +# Function to test a device +test_device() { + local DEVICE_ID=$1 + local IOS_VERSION=$2 + + echo "" + echo "πŸ“± Testing on iOS ${IOS_VERSION}..." + + # Boot simulator if needed + xcrun simctl boot "$DEVICE_ID" 2>/dev/null || true + sleep 2 + + # Install app + echo "πŸ“¦ Installing app on iOS ${IOS_VERSION}..." + xcrun simctl install "$DEVICE_ID" \ + ~/Library/Developer/Xcode/DerivedData/Catnip-*/Build/Products/Debug-iphonesimulator/catnip.app + + # Launch app + echo "πŸš€ Launching app on iOS ${IOS_VERSION}..." + xcrun simctl launch "$DEVICE_ID" "$APP_BUNDLE" \ + -UITesting \ + -SkipAuthentication \ + -UseMockData \ + -ShowWorkspacesList + + # Wait for app to settle + sleep 4 + + # Take sidebar screenshot + SCREENSHOT_SIDEBAR="$SCREENSHOT_DIR/ios${IOS_VERSION}-sidebar-${TIMESTAMP}.png" + echo "πŸ“Έ Taking sidebar screenshot..." + xcrun simctl io "$DEVICE_ID" screenshot "$SCREENSHOT_SIDEBAR" + echo " Saved: $SCREENSHOT_SIDEBAR" + + # Tap first workspace (coordinates vary by device) + echo "πŸ‘† Tapping first workspace..." + if [ "$IOS_VERSION" = "26" ]; then + # iPad (A16) has different resolution + xcrun simctl ui "$DEVICE_ID" tap 180 280 + else + # iPad Pro 13-inch + xcrun simctl ui "$DEVICE_ID" tap 180 300 + fi + + # Wait for detail view to load + sleep 4 + + # Take detail screenshot + SCREENSHOT_DETAIL="$SCREENSHOT_DIR/ios${IOS_VERSION}-detail-${TIMESTAMP}.png" + echo "πŸ“Έ Taking detail view screenshot..." + xcrun simctl io "$DEVICE_ID" screenshot "$SCREENSHOT_DETAIL" + echo " Saved: $SCREENSHOT_DETAIL" + + # Terminate app + xcrun simctl terminate "$DEVICE_ID" "$APP_BUNDLE" 2>/dev/null || true +} + +# Test both versions +test_device "$DEVICE_IOS18" "18" +test_device "$DEVICE_IOS26" "26" + +echo "" +echo "βœ… Compatibility testing complete!" +echo "" +echo "πŸ“Έ Screenshots saved to: $SCREENSHOT_DIR" +echo " iOS 18: ios18-sidebar-${TIMESTAMP}.png, ios18-detail-${TIMESTAMP}.png" +echo " iOS 26: ios26-sidebar-${TIMESTAMP}.png, ios26-detail-${TIMESTAMP}.png" diff --git a/xcode/test-ipad-sidebar.sh b/xcode/test-ipad-sidebar.sh new file mode 100755 index 00000000..198eb74e --- /dev/null +++ b/xcode/test-ipad-sidebar.sh @@ -0,0 +1,70 @@ +#!/bin/bash +set -e + +# Use iPad Pro 13-inch for testing +DEVICE_ID="73B540BB-FE5C-4D99-A07D-7518DAFC4A90" +APP_BUNDLE="com.wandb.catnip" +SCREENSHOT_DIR="$HOME/Desktop/catnip-screenshots" +TIMESTAMP=$(date +%Y%m%d-%H%M%S) + +echo "πŸš€ Testing iPad sidebar visibility..." + +# Create screenshot directory +mkdir -p "$SCREENSHOT_DIR" + +# Ensure simulator is booted +xcrun simctl boot "$DEVICE_ID" 2>/dev/null || true +sleep 2 + +# Build if needed (quick check) +if [ ! -d ~/Library/Developer/Xcode/DerivedData/Catnip-*/Build/Products/Debug-iphonesimulator/catnip.app ]; then + echo "πŸ”¨ Building app..." + xcodebuild -project catnip.xcodeproj \ + -scheme catnip \ + -configuration Debug \ + -sdk iphonesimulator \ + -destination "id=$DEVICE_ID" \ + -quiet \ + build +fi + +# Install app +echo "πŸ“¦ Installing app..." +xcrun simctl install "$DEVICE_ID" \ + ~/Library/Developer/Xcode/DerivedData/Catnip-*/Build/Products/Debug-iphonesimulator/catnip.app + +# Launch app with mock data +echo "πŸš€ Launching app..." +xcrun simctl launch "$DEVICE_ID" "$APP_BUNDLE" \ + -UITesting \ + -SkipAuthentication \ + -UseMockData \ + -ShowWorkspacesList + +# Wait for app to settle +sleep 5 + +# Take initial screenshot +SCREENSHOT_INITIAL="$SCREENSHOT_DIR/sidebar-initial-${TIMESTAMP}.png" +echo "πŸ“Έ Taking initial screenshot..." +xcrun simctl io "$DEVICE_ID" screenshot "$SCREENSHOT_INITIAL" +echo "Initial: $SCREENSHOT_INITIAL" + +# Tap the sidebar toggle button (upper left, approximate coordinates) +# For iPad Pro 13-inch (2048x2732), sidebar button is around x=40, y=90 +echo "πŸ‘† Tapping sidebar toggle button..." +xcrun simctl io "$DEVICE_ID" tap 40 90 + +# Wait for animation +sleep 2 + +# Take screenshot with sidebar visible +SCREENSHOT_SIDEBAR="$SCREENSHOT_DIR/sidebar-visible-${TIMESTAMP}.png" +echo "πŸ“Έ Taking sidebar screenshot..." +xcrun simctl io "$DEVICE_ID" screenshot "$SCREENSHOT_SIDEBAR" +echo "Sidebar visible: $SCREENSHOT_SIDEBAR" + +echo "" +echo "βœ… Screenshots saved:" +echo " Initial: $SCREENSHOT_INITIAL" +echo " Sidebar: $SCREENSHOT_SIDEBAR" diff --git a/xcode/test-ipad-workspace-detail.sh b/xcode/test-ipad-workspace-detail.sh new file mode 100755 index 00000000..9dbe9e90 --- /dev/null +++ b/xcode/test-ipad-workspace-detail.sh @@ -0,0 +1,70 @@ +#!/bin/bash +set -e + +# Use iPad Pro 13-inch for testing +DEVICE_ID="73B540BB-FE5C-4D99-A07D-7518DAFC4A90" +APP_BUNDLE="com.wandb.catnip" +SCREENSHOT_DIR="$HOME/Desktop/catnip-screenshots" +TIMESTAMP=$(date +%Y%m%d-%H%M%S) + +echo "πŸš€ Testing iPad workspace detail split view..." + +# Create screenshot directory +mkdir -p "$SCREENSHOT_DIR" + +# Ensure simulator is booted +xcrun simctl boot "$DEVICE_ID" 2>/dev/null || true +sleep 2 + +# Build if needed (quick check) +if [ ! -d ~/Library/Developer/Xcode/DerivedData/Catnip-*/Build/Products/Debug-iphonesimulator/catnip.app ]; then + echo "πŸ”¨ Building app..." + xcodebuild -project catnip.xcodeproj \ + -scheme catnip \ + -configuration Debug \ + -sdk iphonesimulator \ + -destination "id=$DEVICE_ID" \ + -quiet \ + build +fi + +# Install app +echo "πŸ“¦ Installing app..." +xcrun simctl install "$DEVICE_ID" \ + ~/Library/Developer/Xcode/DerivedData/Catnip-*/Build/Products/Debug-iphonesimulator/catnip.app + +# Launch app with mock data +echo "πŸš€ Launching app..." +xcrun simctl launch "$DEVICE_ID" "$APP_BUNDLE" \ + -UITesting \ + -SkipAuthentication \ + -UseMockData \ + -ShowWorkspacesList + +# Wait for app to settle +sleep 3 + +# Take initial screenshot (sidebar view) +SCREENSHOT_SIDEBAR="$SCREENSHOT_DIR/workspace-sidebar-${TIMESTAMP}.png" +echo "πŸ“Έ Taking sidebar screenshot..." +xcrun simctl io "$DEVICE_ID" screenshot "$SCREENSHOT_SIDEBAR" +echo "Sidebar: $SCREENSHOT_SIDEBAR" + +# Tap on first workspace in the list +# For iPad Pro 13-inch landscape (2732x2048), first workspace should be around x=180, y=300 +echo "πŸ‘† Tapping first workspace..." +xcrun simctl io "$DEVICE_ID" tap 180 300 + +# Wait for workspace detail to load +sleep 4 + +# Take screenshot with workspace detail visible +SCREENSHOT_DETAIL="$SCREENSHOT_DIR/workspace-detail-${TIMESTAMP}.png" +echo "πŸ“Έ Taking workspace detail screenshot..." +xcrun simctl io "$DEVICE_ID" screenshot "$SCREENSHOT_DETAIL" +echo "Detail view: $SCREENSHOT_DETAIL" + +echo "" +echo "βœ… Screenshots saved:" +echo " Sidebar: $SCREENSHOT_SIDEBAR" +echo " Detail: $SCREENSHOT_DETAIL" diff --git a/xcode/test-ipad.sh b/xcode/test-ipad.sh new file mode 100755 index 00000000..6b4e8abb --- /dev/null +++ b/xcode/test-ipad.sh @@ -0,0 +1,55 @@ +#!/bin/bash +set -e + +# Use iPad Pro 13-inch for testing +DEVICE_ID="73B540BB-FE5C-4D99-A07D-7518DAFC4A90" +APP_BUNDLE="com.wandb.catnip" +SCREENSHOT_DIR="$HOME/Desktop/catnip-screenshots" +TIMESTAMP=$(date +%Y%m%d-%H%M%S) + +echo "πŸš€ Starting iPad simulator test..." + +# Create screenshot directory +mkdir -p "$SCREENSHOT_DIR" + +# Boot simulator if not already booted +echo "πŸ“± Booting iPad simulator..." +xcrun simctl boot "$DEVICE_ID" 2>/dev/null || true + +# Wait for boot +sleep 3 + +# Build and install app +echo "πŸ”¨ Building app..." +xcodebuild -project catnip.xcodeproj \ + -scheme catnip \ + -configuration Debug \ + -sdk iphonesimulator \ + -destination "id=$DEVICE_ID" \ + -quiet \ + build + +echo "πŸ“¦ Installing app..." +xcrun simctl install "$DEVICE_ID" \ + ~/Library/Developer/Xcode/DerivedData/Catnip-*/Build/Products/Debug-iphonesimulator/catnip.app + +# Launch app in UI testing mode to bypass authentication +echo "πŸš€ Launching app in UI testing mode..." +xcrun simctl launch --console "$DEVICE_ID" "$APP_BUNDLE" \ + -UITesting \ + -SkipAuthentication \ + -UseMockData \ + -ShowWorkspacesList & + +# Wait for app to load +echo "⏳ Waiting 8 seconds for app to load..." +sleep 8 + +# Take screenshot +SCREENSHOT_PATH="$SCREENSHOT_DIR/ipad-${TIMESTAMP}.png" +echo "πŸ“Έ Taking screenshot..." +xcrun simctl io "$DEVICE_ID" screenshot "$SCREENSHOT_PATH" + +echo "βœ… Screenshot saved to: $SCREENSHOT_PATH" +echo "" +echo "To view: open '$SCREENSHOT_PATH'"