Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
cf50b91
Phase 1: Add adaptive theming infrastructure for iOS/iPadOS
Dec 4, 2025
8a9107f
Phase 2: Adapt WorkspacesView for iPhone/iPad split navigation
Dec 4, 2025
398ec4d
Phase 3: Adapt WorkspaceDetailView for adaptive terminal + chat split
Dec 4, 2025
e3dfa45
Fix keyboard obstruction in ClaudeAuthSheet on iPad
Dec 4, 2025
47dc5e2
Constrain button widths on iPad in CodespaceView
Dec 4, 2025
3990912
Fix button width constraints with proper background handling
Dec 4, 2025
2639416
Fix WorkspacesView navigation bar spacing on iPad
Dec 4, 2025
28aa095
Align WorkspacesView sidebar title with back button on iPad
Dec 4, 2025
99d7fc7
Replace system navigation bar with custom header in sidebar
Dec 4, 2025
e16195e
Use proper .sidebar list style for NavigationSplitView
Dec 4, 2025
7320cf7
Fix toolbar duplication by using List section header instead
Dec 4, 2025
da7cd6e
Fix WorkspacesView sidebar navigation bar layout
Dec 4, 2025
a72161b
Fix sidebar title display mode - use inline instead of large
Dec 4, 2025
a0d7fc8
Fix iPad layout issues: content width, sidebar padding, and list items
Dec 4, 2025
6633c4e
Fix iOS 26 NavigationSplitView compatibility and add comparison testing
Dec 4, 2025
7969460
Make workspace items edge-to-edge and add vertical split for iPad por…
Dec 4, 2025
eb76d34
Fix workspace item backgrounds to be truly edge-to-edge like iOS Mail
Dec 5, 2025
00ec3da
Fix iPhone navigation breaking from nested NavigationStack
Dec 5, 2025
651d352
Fix elliptical toolbar button by using plain button style
Dec 5, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": []
Expand Down
109 changes: 109 additions & 0 deletions xcode/catnip/Components/AdaptiveNavigationContainer.swift
Original file line number Diff line number Diff line change
@@ -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<Sidebar: View, Detail: View, EmptyDetail: View>: View {
@Environment(\.adaptiveTheme) private var adaptiveTheme

let sidebar: () -> Sidebar
let detail: (Binding<NavigationPath>, String?) -> Detail
let emptyDetail: () -> EmptyDetail

@State private var navigationPath = NavigationPath()
@Binding var columnVisibility: NavigationSplitViewVisibility

init(
columnVisibility: Binding<NavigationSplitViewVisibility> = .constant(.all),
@ViewBuilder sidebar: @escaping () -> Sidebar,
@ViewBuilder detail: @escaping (Binding<NavigationPath>, 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
))
}
249 changes: 249 additions & 0 deletions xcode/catnip/Components/AdaptiveSplitView.swift
Original file line number Diff line number Diff line change
@@ -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<Leading: View, Trailing: View>: 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
))
}
3 changes: 3 additions & 0 deletions xcode/catnip/Components/ClaudeAuthSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ struct ClaudeAuthSheet: View {
}
.padding(.horizontal, 20)
}
.scrollDismissesKeyboard(.interactively)
.scrollBounceBehavior(.basedOnSize)
.background(Color(uiColor: .systemGroupedBackground))
.navigationBarTitleDisplayMode(.inline)
Expand All @@ -65,6 +66,8 @@ struct ClaudeAuthSheet: View {
}
}
}
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
.interactiveDismissDisabled(isProcessing || status?.parsedState == .complete)
.onAppear {
if status == nil {
Expand Down
Loading