feat: add SwiftUI GitHub mobile app inspired by Claude Code

- Create new iOS app at apps/ios-github-mobile
- Session list view with repository info and status
- Chat view with collapsible tool executions (Write, Bash, Read)
- Message input with model/repo/branch selection chips
- PR creation flow simulation
- Dark theme matching Claude Code aesthetic
- Mock data for repos, sessions, and tool calls
- Modern SwiftUI with @Observable, NavigationStack

https://claude.ai/code/session_018qmTB96S8xvNRQFrhcvzJp
This commit is contained in:
Claude 2026-01-23 08:22:15 +00:00
parent 86e0916fa3
commit cffaf0b904
No known key found for this signature in database
17 changed files with 1620 additions and 0 deletions

View File

@ -0,0 +1,22 @@
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "GitHubMobile",
platforms: [
.iOS(.v17),
.macOS(.v14)
],
products: [
.library(
name: "GitHubMobile",
targets: ["GitHubMobile"]
)
],
targets: [
.target(
name: "GitHubMobile",
path: "Sources"
)
]
)

View File

@ -0,0 +1,96 @@
# GitHub Mobile - SwiftUI App
A beautiful SwiftUI mobile app inspired by the Claude Code mobile experience. This app provides a clean interface for managing GitHub repositories and sending code requests through an AI assistant.
## Features
- **Session Management**: View and manage coding sessions organized by repository
- **Chat Interface**: Send messages and view AI responses with tool execution details
- **Repository Selection**: Browse and select from your GitHub repositories
- **Model Selection**: Choose between different AI models (Sonnet, Opus, Haiku)
- **Dark Theme**: Beautiful dark theme matching the Claude Code aesthetic
- **PR Creation**: Simulate creating pull requests from the app
## Screenshots
The app is inspired by the Claude Code mobile app UI:
- Session list with repository information
- Chat view with collapsible tool executions (Write, Bash, Read, etc.)
- Bottom input with model/repo selection chips
## Requirements
- iOS 17.0+
- Xcode 15.0+
- Swift 5.9+
## Project Structure
```
Sources/
├── GitHubMobileApp.swift # App entry point
├── ContentView.swift # Main navigation container
├── Models/
│ ├── Models.swift # Data models (Session, Message, Repository, etc.)
│ └── AppState.swift # Observable app state
├── Views/
│ ├── SessionListView.swift # Session list screen
│ └── ChatView.swift # Chat/conversation screen
├── Components/
│ ├── MessageInputView.swift # Message input component
│ ├── PickerSheets.swift # Model and repo picker sheets
│ └── StatusIndicator.swift # Status indicators and badges
├── Theme/
│ └── AppTheme.swift # Colors, spacing, and styling
└── Assets.xcassets/ # App icons and colors
```
## Architecture
The app uses modern SwiftUI patterns:
- **@Observable** macro for state management (iOS 17+)
- **NavigationStack** with typed navigation paths
- **Environment** for dependency injection
- **View modifiers** for reusable styling
## Building
### Using Xcode
1. Open `Package.swift` in Xcode
2. Select your target device
3. Build and run (Cmd+R)
### Using Swift Package Manager
```bash
swift build
```
## Mock Data
The app includes mock data for demonstration:
- Sample repositories (clawdbot, systemss, Awesome-Prompts, etc.)
- Sample sessions with various titles
- Mock tool executions (Write, Bash commands)
## Customization
### Colors
Edit `Theme/AppTheme.swift` to customize:
- Background colors
- Accent colors (warm orange by default)
- Text colors
- Tool execution colors
### Mock Data
Edit `Models/Models.swift` to customize:
- `Repository.mockList` - Available repositories
- `Session.mockList` - Session list items
- `Session.mockActiveSession()` - Active session with tool calls
## License
Open source - feel free to use and modify.

View File

@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.350",
"green" : "0.550",
"red" : "0.850"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,18 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"idiom" : "universal",
"platform" : "macos",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.080",
"green" : "0.080",
"red" : "0.080"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,70 @@
import SwiftUI
struct MessageInputView: View {
@Binding var text: String
let placeholder: String
let onSend: () -> Void
@FocusState private var isFocused: Bool
var body: some View {
HStack(alignment: .bottom, spacing: AppTheme.spacingSM) {
// Add attachment button
Button {
// Add attachment action
} label: {
Image(systemName: "plus")
.font(.title3)
.foregroundStyle(AppTheme.secondaryText)
.frame(width: 36, height: 36)
}
// Text input
ZStack(alignment: .leading) {
if text.isEmpty {
Text(placeholder)
.foregroundStyle(AppTheme.tertiaryText)
.padding(.horizontal, AppTheme.spacingMD)
.padding(.vertical, AppTheme.spacingMD)
}
TextEditor(text: $text)
.scrollContentBackground(.hidden)
.padding(.horizontal, AppTheme.spacingSM)
.padding(.vertical, AppTheme.spacingSM)
.frame(minHeight: 40, maxHeight: 120)
.focused($isFocused)
}
.background(AppTheme.cardBackground)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radiusLG))
// Send button
Button(action: onSend) {
Image(systemName: "arrow.up")
.font(.body)
.fontWeight(.semibold)
.foregroundStyle(text.isEmpty ? AppTheme.tertiaryText : AppTheme.buttonPrimaryText)
.frame(width: 36, height: 36)
.background(text.isEmpty ? AppTheme.chipBackground : AppTheme.accent)
.clipShape(Circle())
}
.disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
.padding(.horizontal, AppTheme.spacingMD)
}
}
#Preview {
ZStack {
AppTheme.background.ignoresSafeArea()
VStack {
Spacer()
MessageInputView(
text: .constant(""),
placeholder: "Ask Claude to help with code...",
onSend: {}
)
}
}
.preferredColorScheme(.dark)
}

View File

@ -0,0 +1,155 @@
import SwiftUI
// MARK: - Model Picker Sheet
struct ModelPickerSheet: View {
@Environment(\.dismiss) private var dismiss
@Binding var selectedModel: AIModel
var body: some View {
NavigationStack {
ZStack {
AppTheme.background.ignoresSafeArea()
List {
ForEach(AIModel.all) { model in
Button {
selectedModel = model
dismiss()
} label: {
HStack {
VStack(alignment: .leading, spacing: AppTheme.spacingXS) {
Text(model.displayName)
.font(.body)
.foregroundStyle(AppTheme.primaryText)
Text(modelDescription(for: model))
.font(.caption)
.foregroundStyle(AppTheme.secondaryText)
}
Spacer()
if model.id == selectedModel.id {
Image(systemName: "checkmark")
.foregroundStyle(AppTheme.accent)
}
}
.padding(.vertical, AppTheme.spacingXS)
}
}
.listRowBackground(AppTheme.cardBackground)
}
.scrollContentBackground(.hidden)
}
.navigationTitle("Select Model")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") {
dismiss()
}
.foregroundStyle(AppTheme.primaryText)
}
}
}
}
private func modelDescription(for model: AIModel) -> String {
switch model.name {
case "sonnet":
return "Balanced performance and speed"
case "opus":
return "Most capable, complex tasks"
case "haiku":
return "Fast and efficient"
default:
return ""
}
}
}
// MARK: - Repository Picker Sheet
struct RepositoryPickerSheet: View {
@Environment(\.dismiss) private var dismiss
let repositories: [Repository]
@Binding var selectedRepository: Repository?
@State private var searchText = ""
private var filteredRepositories: [Repository] {
if searchText.isEmpty {
return repositories
}
return repositories.filter {
$0.name.localizedCaseInsensitiveContains(searchText) ||
$0.owner.localizedCaseInsensitiveContains(searchText)
}
}
var body: some View {
NavigationStack {
ZStack {
AppTheme.background.ignoresSafeArea()
List {
ForEach(filteredRepositories) { repo in
Button {
selectedRepository = repo
dismiss()
} label: {
HStack {
Image(systemName: "folder")
.foregroundStyle(AppTheme.secondaryText)
VStack(alignment: .leading, spacing: AppTheme.spacingXS) {
Text(repo.name)
.font(.body)
.foregroundStyle(AppTheme.primaryText)
Text(repo.owner)
.font(.caption)
.foregroundStyle(AppTheme.secondaryText)
}
Spacer()
if repo.id == selectedRepository?.id {
Image(systemName: "checkmark")
.foregroundStyle(AppTheme.accent)
}
}
.padding(.vertical, AppTheme.spacingXS)
}
}
.listRowBackground(AppTheme.cardBackground)
}
.scrollContentBackground(.hidden)
.searchable(text: $searchText, prompt: "Search repositories")
}
.navigationTitle("Select Repository")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") {
dismiss()
}
.foregroundStyle(AppTheme.primaryText)
}
}
}
}
}
#Preview("Model Picker") {
ModelPickerSheet(selectedModel: .constant(.sonnet))
.preferredColorScheme(.dark)
}
#Preview("Repository Picker") {
RepositoryPickerSheet(
repositories: Repository.mockList,
selectedRepository: .constant(nil)
)
.preferredColorScheme(.dark)
}

View File

@ -0,0 +1,108 @@
import SwiftUI
struct StatusIndicator: View {
let status: SessionStatus
var body: some View {
HStack(spacing: AppTheme.spacingXS) {
Circle()
.fill(statusColor)
.frame(width: 8, height: 8)
Text(status.rawValue)
.font(.subheadline)
.foregroundStyle(AppTheme.secondaryText)
}
}
private var statusColor: Color {
switch status {
case .idle:
return AppTheme.secondaryText
case .running:
return AppTheme.accent
case .completed:
return AppTheme.success
case .error:
return AppTheme.error
}
}
}
// MARK: - Loading Indicator
struct LoadingIndicator: View {
@State private var isAnimating = false
var body: some View {
HStack(spacing: AppTheme.spacingXS) {
ForEach(0..<3) { index in
Circle()
.fill(AppTheme.accent)
.frame(width: 6, height: 6)
.scaleEffect(isAnimating ? 1.0 : 0.5)
.animation(
.easeInOut(duration: 0.6)
.repeatForever()
.delay(Double(index) * 0.2),
value: isAnimating
)
}
}
.onAppear {
isAnimating = true
}
}
}
// MARK: - Tool Status Badge
struct ToolStatusBadge: View {
let status: ToolCallStatus
var body: some View {
HStack(spacing: AppTheme.spacingXS) {
switch status {
case .pending:
Image(systemName: "clock")
.foregroundStyle(AppTheme.secondaryText)
case .running:
ProgressView()
.scaleEffect(0.7)
.tint(AppTheme.accent)
case .completed:
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(AppTheme.success)
case .failed:
Image(systemName: "xmark.circle.fill")
.foregroundStyle(AppTheme.error)
}
}
.font(.caption)
}
}
#Preview {
VStack(spacing: 20) {
StatusIndicator(status: .idle)
StatusIndicator(status: .running)
StatusIndicator(status: .completed)
StatusIndicator(status: .error)
Divider()
LoadingIndicator()
Divider()
HStack(spacing: 20) {
ToolStatusBadge(status: .pending)
ToolStatusBadge(status: .running)
ToolStatusBadge(status: .completed)
ToolStatusBadge(status: .failed)
}
}
.padding()
.background(AppTheme.background)
.preferredColorScheme(.dark)
}

View File

@ -0,0 +1,20 @@
import SwiftUI
struct ContentView: View {
@Environment(AppState.self) private var appState
var body: some View {
NavigationStack(path: Bindable(appState).navigationPath) {
SessionListView()
.navigationDestination(for: Session.self) { session in
ChatView(session: session)
}
}
}
}
#Preview {
ContentView()
.environment(AppState())
.preferredColorScheme(.dark)
}

View File

@ -0,0 +1,14 @@
import SwiftUI
@main
struct GitHubMobileApp: App {
@State private var appState = AppState()
var body: some Scene {
WindowGroup {
ContentView()
.environment(appState)
.preferredColorScheme(.dark)
}
}
}

View File

@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Code</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<true/>
</dict>
<key>UILaunchScreen</key>
<dict>
<key>UIColorName</key>
<string>LaunchBackground</string>
</dict>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIUserInterfaceStyle</key>
<string>Dark</string>
</dict>
</plist>

View File

@ -0,0 +1,92 @@
import Foundation
import Observation
import SwiftUI
@Observable
@MainActor
final class AppState {
var sessions: [Session] = Session.mockList
var repositories: [Repository] = Repository.mockList
var selectedModel: AIModel = .sonnet
var currentSession: Session?
var navigationPath = NavigationPath()
// Current input state
var messageInput: String = ""
var selectedRepository: Repository?
var selectedBranch: String = "main"
init() {
self.selectedRepository = repositories.first
}
func createNewSession(title: String, repository: Repository) -> Session {
let session = Session(
id: UUID().uuidString,
title: title,
repository: repository,
createdAt: Date(),
status: .idle,
messages: []
)
sessions.insert(session, at: 0)
return session
}
func sendMessage(_ content: String, to session: Session) {
guard let index = sessions.firstIndex(where: { $0.id == session.id }) else { return }
let message = Message(
id: UUID().uuidString,
role: .user,
content: content,
timestamp: Date(),
toolCalls: []
)
sessions[index].messages.append(message)
sessions[index].status = .running
// Simulate assistant response after a delay
simulateResponse(for: sessions[index])
}
private func simulateResponse(for session: Session) {
guard let index = sessions.firstIndex(where: { $0.id == session.id }) else { return }
// Create a simulated assistant response
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
guard let self else { return }
let assistantMessage = Message(
id: UUID().uuidString,
role: .assistant,
content: "Anladım! Şimdi bu değişiklikleri yapmaya başlıyorum...",
timestamp: Date(),
toolCalls: [
ToolCall(
id: UUID().uuidString,
type: .read,
name: "Read",
input: "/home/user/\(session.repository.name)/README.md",
output: "# \(session.repository.name)\n\nProject description...",
status: .completed
)
]
)
self.sessions[index].messages.append(assistantMessage)
self.sessions[index].status = .idle
}
}
func openSession(_ session: Session) {
currentSession = session
navigationPath.append(session)
}
func selectRepository(_ repository: Repository) {
selectedRepository = repository
selectedBranch = repository.defaultBranch
}
}

View File

@ -0,0 +1,277 @@
import Foundation
// MARK: - Repository
struct Repository: Identifiable, Hashable, Codable {
let id: String
let name: String
let owner: String
let defaultBranch: String
var fullName: String {
"\(owner)/\(name)"
}
static let mock = Repository(
id: "1",
name: "clawdbot",
owner: "semihpolat",
defaultBranch: "main"
)
static let mockList: [Repository] = [
Repository(id: "1", name: "clawdbot", owner: "semihpolat", defaultBranch: "main"),
Repository(id: "2", name: "systemss", owner: "semihpolat", defaultBranch: "main"),
Repository(id: "3", name: "Awesome-Prompts", owner: "semihpolat", defaultBranch: "main"),
Repository(id: "4", name: "statue", owner: "semihpolat", defaultBranch: "main"),
Repository(id: "5", name: "littleagents", owner: "semihpolat", defaultBranch: "main"),
Repository(id: "6", name: "ultimateaiscraper", owner: "semihpolat", defaultBranch: "main"),
]
}
// MARK: - Session
struct Session: Identifiable, Hashable {
let id: String
let title: String
let repository: Repository
let createdAt: Date
var status: SessionStatus
var messages: [Message]
static func == (lhs: Session, rhs: Session) -> Bool {
lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
enum SessionStatus: String, Codable {
case idle = "Idle"
case running = "Running"
case completed = "Completed"
case error = "Error"
}
// MARK: - Message
struct Message: Identifiable, Hashable {
let id: String
let role: MessageRole
let content: String
let timestamp: Date
var toolCalls: [ToolCall]
static func == (lhs: Message, rhs: Message) -> Bool {
lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
enum MessageRole: String, Codable {
case user
case assistant
case system
}
// MARK: - Tool Call
struct ToolCall: Identifiable, Hashable {
let id: String
let type: ToolType
let name: String
let input: String
var output: String?
var status: ToolCallStatus
static func == (lhs: ToolCall, rhs: ToolCall) -> Bool {
lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
enum ToolType: String, Codable {
case write = "Write"
case bash = "Bash"
case read = "Read"
case edit = "Edit"
case glob = "Glob"
case grep = "Grep"
var color: SwiftUI.Color {
switch self {
case .write, .edit:
return AppTheme.toolWrite
case .bash:
return AppTheme.toolBash
case .read, .glob, .grep:
return AppTheme.toolRead
}
}
}
enum ToolCallStatus: String, Codable {
case pending
case running
case completed
case failed
}
// MARK: - AI Model
struct AIModel: Identifiable, Hashable {
let id: String
let name: String
let displayName: String
static let sonnet = AIModel(id: "sonnet-4.5", name: "sonnet", displayName: "Sonnet 4.5")
static let opus = AIModel(id: "opus-4.5", name: "opus", displayName: "Opus 4.5")
static let haiku = AIModel(id: "haiku", name: "haiku", displayName: "Haiku")
static let all: [AIModel] = [.sonnet, .opus, .haiku]
}
// MARK: - Mock Data
extension Session {
static let mockList: [Session] = [
Session(
id: "1",
title: "Design Claude Code system architecture for mobile",
repository: Repository.mockList[1],
createdAt: Date().addingTimeInterval(-3600),
status: .idle,
messages: []
),
Session(
id: "2",
title: "Add popular tweets with images to platform showcase",
repository: Repository.mockList[2],
createdAt: Date().addingTimeInterval(-7200),
status: .idle,
messages: []
),
Session(
id: "3",
title: "Add popular tweets with images to platform library",
repository: Repository.mockList[2],
createdAt: Date().addingTimeInterval(-10800),
status: .idle,
messages: []
),
Session(
id: "4",
title: "Evaluate migrating SvelteKit project to Next.js",
repository: Repository.mockList[3],
createdAt: Date().addingTimeInterval(-14400),
status: .idle,
messages: []
),
Session(
id: "5",
title: "Update resources list with current tools and links",
repository: Repository.mockList[4],
createdAt: Date().addingTimeInterval(-18000),
status: .idle,
messages: []
),
Session(
id: "6",
title: "Sync fork with original repository",
repository: Repository.mockList[3],
createdAt: Date().addingTimeInterval(-21600),
status: .idle,
messages: []
),
Session(
id: "7",
title: "Improve UI Design with Shadcn Style",
repository: Repository.mockList[5],
createdAt: Date().addingTimeInterval(-25200),
status: .idle,
messages: []
),
]
static func mockActiveSession() -> Session {
Session(
id: "active-1",
title: "Add popular tweets with images to platform",
repository: Repository.mockList[2],
createdAt: Date().addingTimeInterval(-1800),
status: .running,
messages: [
Message(
id: "m1",
role: .assistant,
content: "Haklisın, hemen README.md'yi oluşturup push yapıyorum!",
timestamp: Date().addingTimeInterval(-1200),
toolCalls: [
ToolCall(
id: "tc1",
type: .write,
name: "Write",
input: "/home/user/Awesome-Prompts/README.md",
output: """
+ # Awesome AI Prompts
+
+ A curated collection of powerful AI prompts that went viral on X/Twitter.
+ These prompts have been battle-tested by thousands of users and proven to deliver
+ exceptional results with ChatGPT, Claude, and other LLMs.
+
""",
status: .completed
)
]
),
Message(
id: "m2",
role: .assistant,
content: "Şimdi commit ve push yapıyorum:",
timestamp: Date().addingTimeInterval(-900),
toolCalls: [
ToolCall(
id: "tc2",
type: .bash,
name: "Bash",
input: "git add README.md && git status",
output: nil,
status: .completed
),
ToolCall(
id: "tc3",
type: .bash,
name: "Bash",
input: "git commit -m \"$(cat <<'EOF'\nAdd comprehensive Awesome AI Prompts collection...\nEOF\n)\"",
output: nil,
status: .completed
),
ToolCall(
id: "tc4",
type: .bash,
name: "Bash",
input: "git push -u origin claude/add-popular-tweets-dy04J",
output: nil,
status: .completed
)
]
),
Message(
id: "m3",
role: .assistant,
content: "Push tamam! Şimdi PR oluşturuyorum:",
timestamp: Date().addingTimeInterval(-600),
toolCalls: []
),
]
)
}
}

View File

@ -0,0 +1,86 @@
import SwiftUI
enum AppTheme {
// Background colors
static let background = Color(red: 0.08, green: 0.08, blue: 0.08)
static let surfaceBackground = Color(red: 0.12, green: 0.12, blue: 0.12)
static let cardBackground = Color(red: 0.16, green: 0.16, blue: 0.16)
static let inputBackground = Color(red: 0.14, green: 0.14, blue: 0.14)
// Text colors
static let primaryText = Color.white
static let secondaryText = Color(white: 0.6)
static let tertiaryText = Color(white: 0.45)
// Accent colors
static let accent = Color(red: 0.85, green: 0.55, blue: 0.35) // Warm orange like Claude
static let accentLight = Color(red: 0.95, green: 0.75, blue: 0.55)
// Status colors
static let success = Color(red: 0.3, green: 0.7, blue: 0.4)
static let warning = Color(red: 0.9, green: 0.7, blue: 0.3)
static let error = Color(red: 0.9, green: 0.4, blue: 0.4)
// Tool colors
static let toolWrite = Color(red: 0.4, green: 0.7, blue: 0.4)
static let toolBash = Color(red: 0.5, green: 0.6, blue: 0.8)
static let toolRead = Color(red: 0.7, green: 0.6, blue: 0.8)
// Chip/Tag colors
static let chipBackground = Color(red: 0.2, green: 0.2, blue: 0.2)
static let chipBorder = Color(white: 0.3)
// Button colors
static let buttonPrimary = Color(white: 0.95)
static let buttonPrimaryText = Color.black
// Spacing
static let spacingXS: CGFloat = 4
static let spacingSM: CGFloat = 8
static let spacingMD: CGFloat = 12
static let spacingLG: CGFloat = 16
static let spacingXL: CGFloat = 24
// Corner radius
static let radiusSM: CGFloat = 6
static let radiusMD: CGFloat = 10
static let radiusLG: CGFloat = 16
static let radiusXL: CGFloat = 24
static let radiusFull: CGFloat = 100
}
// MARK: - View Modifiers
struct CardStyle: ViewModifier {
func body(content: Content) -> some View {
content
.background(AppTheme.cardBackground)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radiusLG))
}
}
struct ChipStyle: ViewModifier {
func body(content: Content) -> some View {
content
.font(.subheadline)
.foregroundStyle(AppTheme.primaryText)
.padding(.horizontal, AppTheme.spacingMD)
.padding(.vertical, AppTheme.spacingSM)
.background(AppTheme.chipBackground)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radiusFull))
.overlay(
RoundedRectangle(cornerRadius: AppTheme.radiusFull)
.stroke(AppTheme.chipBorder, lineWidth: 1)
)
}
}
extension View {
func cardStyle() -> some View {
modifier(CardStyle())
}
func chipStyle() -> some View {
modifier(ChipStyle())
}
}

View File

@ -0,0 +1,341 @@
import SwiftUI
struct ChatView: View {
@Environment(AppState.self) private var appState
let session: Session
@State private var messageText = ""
@State private var showModelPicker = false
@State private var selectedModel: AIModel = .sonnet
@State private var showCreatePR = false
private var currentSession: Session {
appState.sessions.first { $0.id == session.id } ?? session
}
var body: some View {
ZStack {
AppTheme.background.ignoresSafeArea()
VStack(spacing: 0) {
// Messages scroll view
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: AppTheme.spacingLG) {
ForEach(currentSession.messages) { message in
MessageView(message: message)
.id(message.id)
}
// Create PR button if there are tool calls
if currentSession.messages.contains(where: { !$0.toolCalls.isEmpty }) {
CreatePRButton(
branchName: "claude/add-popular-tweets-dy04J",
showCreatePR: $showCreatePR
)
.padding(.horizontal, AppTheme.spacingLG)
}
}
.padding(.vertical, AppTheme.spacingMD)
}
.onChange(of: currentSession.messages.count) { _, _ in
if let lastMessage = currentSession.messages.last {
withAnimation {
proxy.scrollTo(lastMessage.id, anchor: .bottom)
}
}
}
}
// Input area
VStack(spacing: AppTheme.spacingMD) {
MessageInputView(
text: $messageText,
placeholder: "Add feedback...",
onSend: sendMessage
)
// Bottom chips
HStack(spacing: AppTheme.spacingSM) {
// Branch indicator
HStack(spacing: AppTheme.spacingXS) {
Text("Branch")
.foregroundStyle(AppTheme.secondaryText)
Text("claude/add-popular-tweets...")
.foregroundStyle(AppTheme.primaryText)
}
.font(.caption)
.padding(.horizontal, AppTheme.spacingMD)
.padding(.vertical, AppTheme.spacingSM)
.background(AppTheme.chipBackground)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radiusMD))
// Create PR button inline
Button {
showCreatePR = true
} label: {
HStack(spacing: AppTheme.spacingXS) {
Text("Create PR")
.foregroundStyle(AppTheme.primaryText)
Image(systemName: "arrow.up.right")
.font(.caption)
Image(systemName: "ellipsis")
.font(.caption2)
}
}
.font(.caption)
.padding(.horizontal, AppTheme.spacingMD)
.padding(.vertical, AppTheme.spacingSM)
.background(AppTheme.chipBackground)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radiusMD))
Spacer()
}
.padding(.horizontal, AppTheme.spacingMD)
}
.padding(.bottom, AppTheme.spacingMD)
.background(AppTheme.background)
}
}
.navigationTitle(session.repository.fullName)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .principal) {
VStack(spacing: 2) {
Text(String(session.title.prefix(30)) + (session.title.count > 30 ? "..." : ""))
.font(.subheadline)
.fontWeight(.medium)
.foregroundStyle(AppTheme.primaryText)
Text("\(session.repository.fullName) · Default")
.font(.caption2)
.foregroundStyle(AppTheme.secondaryText)
}
}
ToolbarItem(placement: .topBarTrailing) {
Menu {
Button("View on GitHub") {}
Button("Copy branch name") {}
Divider()
Button("Delete session", role: .destructive) {}
} label: {
Image(systemName: "ellipsis.circle")
.foregroundStyle(AppTheme.primaryText)
}
}
}
.sheet(isPresented: $showCreatePR) {
CreatePRSheet(session: currentSession)
.presentationDetents([.medium])
}
}
private func sendMessage() {
guard !messageText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
appState.sendMessage(messageText, to: currentSession)
messageText = ""
}
}
// MARK: - Message View
struct MessageView: View {
let message: Message
@State private var expandedToolCalls: Set<String> = []
var body: some View {
VStack(alignment: .leading, spacing: AppTheme.spacingMD) {
// Message content
if !message.content.isEmpty {
Text(message.content)
.font(.body)
.foregroundStyle(AppTheme.primaryText)
.padding(.horizontal, AppTheme.spacingLG)
}
// Tool calls
ForEach(message.toolCalls) { toolCall in
ToolCallView(
toolCall: toolCall,
isExpanded: expandedToolCalls.contains(toolCall.id),
onToggle: {
if expandedToolCalls.contains(toolCall.id) {
expandedToolCalls.remove(toolCall.id)
} else {
expandedToolCalls.insert(toolCall.id)
}
}
)
.padding(.horizontal, AppTheme.spacingLG)
}
}
}
}
// MARK: - Tool Call View
struct ToolCallView: View {
let toolCall: ToolCall
let isExpanded: Bool
let onToggle: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Header
Button(action: onToggle) {
HStack(spacing: AppTheme.spacingSM) {
// Tool type badge
Text(toolCall.type.rawValue)
.font(.subheadline)
.fontWeight(.medium)
.foregroundStyle(toolCall.type.color)
// File path or command
Text(toolCall.input)
.font(.subheadline)
.foregroundStyle(AppTheme.secondaryText)
.lineLimit(1)
Spacer()
// Expand indicator
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
.font(.caption)
.foregroundStyle(AppTheme.tertiaryText)
}
.padding(.horizontal, AppTheme.spacingMD)
.padding(.vertical, AppTheme.spacingSM)
}
.buttonStyle(.plain)
// Expanded content
if isExpanded, let output = toolCall.output {
ScrollView(.horizontal, showsIndicators: false) {
Text(output)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(AppTheme.secondaryText)
.padding(AppTheme.spacingMD)
}
.frame(maxHeight: 200)
.background(AppTheme.surfaceBackground)
}
}
.background(AppTheme.cardBackground)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radiusMD))
}
}
// MARK: - Create PR Button
struct CreatePRButton: View {
let branchName: String
@Binding var showCreatePR: Bool
var body: some View {
HStack {
Text(branchName)
.font(.subheadline)
.foregroundStyle(AppTheme.secondaryText)
.lineLimit(1)
Spacer()
Button {
showCreatePR = true
} label: {
HStack(spacing: AppTheme.spacingXS) {
Text("Create PR")
.fontWeight(.medium)
Image(systemName: "arrow.up.right")
Image(systemName: "ellipsis")
}
.font(.subheadline)
.foregroundStyle(AppTheme.primaryText)
}
}
.padding(AppTheme.spacingMD)
.background(AppTheme.cardBackground)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radiusMD))
}
}
// MARK: - Create PR Sheet
struct CreatePRSheet: View {
@Environment(\.dismiss) private var dismiss
let session: Session
@State private var prTitle = ""
@State private var prDescription = ""
var body: some View {
NavigationStack {
ZStack {
AppTheme.background.ignoresSafeArea()
VStack(spacing: AppTheme.spacingLG) {
VStack(alignment: .leading, spacing: AppTheme.spacingSM) {
Text("Title")
.font(.subheadline)
.foregroundStyle(AppTheme.secondaryText)
TextField("PR title", text: $prTitle)
.textFieldStyle(.plain)
.padding(AppTheme.spacingMD)
.background(AppTheme.inputBackground)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radiusMD))
}
VStack(alignment: .leading, spacing: AppTheme.spacingSM) {
Text("Description")
.font(.subheadline)
.foregroundStyle(AppTheme.secondaryText)
TextEditor(text: $prDescription)
.scrollContentBackground(.hidden)
.padding(AppTheme.spacingMD)
.background(AppTheme.inputBackground)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radiusMD))
.frame(minHeight: 120)
}
Spacer()
Button {
// Create PR action
dismiss()
} label: {
Text("Create Pull Request")
.font(.body)
.fontWeight(.medium)
.foregroundStyle(AppTheme.buttonPrimaryText)
.frame(maxWidth: .infinity)
.padding(.vertical, AppTheme.spacingMD)
.background(AppTheme.buttonPrimary)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radiusFull))
}
}
.padding(AppTheme.spacingLG)
}
.navigationTitle("Create PR")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") {
dismiss()
}
.foregroundStyle(AppTheme.primaryText)
}
}
.onAppear {
prTitle = session.title
}
}
}
}
#Preview {
NavigationStack {
ChatView(session: Session.mockActiveSession())
}
.environment(AppState())
.preferredColorScheme(.dark)
}

View File

@ -0,0 +1,220 @@
import SwiftUI
struct SessionListView: View {
@Environment(AppState.self) private var appState
@State private var showingNewSessionSheet = false
var body: some View {
ZStack {
AppTheme.background.ignoresSafeArea()
VStack(spacing: 0) {
// Session list
ScrollView {
LazyVStack(spacing: 0) {
// Status indicator
HStack {
Text("Idle")
.font(.subheadline)
.foregroundStyle(AppTheme.secondaryText)
Spacer()
}
.padding(.horizontal, AppTheme.spacingLG)
.padding(.top, AppTheme.spacingMD)
.padding(.bottom, AppTheme.spacingSM)
// Sessions
ForEach(appState.sessions) { session in
SessionRowView(session: session)
.onTapGesture {
appState.openSession(session)
}
}
}
}
// New session button
Button {
showingNewSessionSheet = true
} label: {
Text("New session")
.font(.body)
.fontWeight(.medium)
.foregroundStyle(AppTheme.buttonPrimaryText)
.frame(maxWidth: .infinity)
.padding(.vertical, AppTheme.spacingMD)
.background(AppTheme.buttonPrimary)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radiusFull))
}
.padding(.horizontal, AppTheme.spacingLG)
.padding(.vertical, AppTheme.spacingMD)
.background(AppTheme.background)
}
}
.navigationTitle("Code")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button {
// Settings/filter menu
} label: {
Image(systemName: "line.3.horizontal.decrease")
.foregroundStyle(AppTheme.primaryText)
}
}
}
.sheet(isPresented: $showingNewSessionSheet) {
NewSessionSheet()
}
}
}
// MARK: - Session Row
struct SessionRowView: View {
let session: Session
var body: some View {
HStack(alignment: .center, spacing: AppTheme.spacingMD) {
VStack(alignment: .leading, spacing: AppTheme.spacingXS) {
Text(session.title)
.font(.body)
.foregroundStyle(AppTheme.primaryText)
.lineLimit(2)
Text(session.repository.fullName)
.font(.subheadline)
.foregroundStyle(AppTheme.secondaryText)
}
Spacer()
Image(systemName: "chevron.right")
.font(.footnote)
.foregroundStyle(AppTheme.tertiaryText)
}
.padding(.horizontal, AppTheme.spacingLG)
.padding(.vertical, AppTheme.spacingMD)
.contentShape(Rectangle())
}
}
// MARK: - New Session Sheet
struct NewSessionSheet: View {
@Environment(AppState.self) private var appState
@Environment(\.dismiss) private var dismiss
@State private var messageText = ""
@State private var selectedRepository: Repository?
@State private var selectedBranch = "main"
@State private var selectedModel: AIModel = .sonnet
@State private var showModelPicker = false
@State private var showRepoPicker = false
var body: some View {
NavigationStack {
ZStack {
AppTheme.background.ignoresSafeArea()
VStack(spacing: 0) {
Spacer()
// Message input area
VStack(spacing: AppTheme.spacingMD) {
// Text input
MessageInputView(
text: $messageText,
placeholder: "Ask Claude to help with code...",
onSend: createSession
)
// Bottom chips
HStack(spacing: AppTheme.spacingSM) {
// Model picker
Button {
showModelPicker = true
} label: {
HStack(spacing: AppTheme.spacingXS) {
Image(systemName: "cube")
Text(selectedModel.displayName)
Image(systemName: "chevron.down")
.font(.caption2)
}
}
.chipStyle()
// Repository picker
Button {
showRepoPicker = true
} label: {
HStack(spacing: AppTheme.spacingXS) {
Image(systemName: "folder")
Text(selectedRepository?.name ?? "Select repo")
}
}
.chipStyle()
// Branch
if selectedRepository != nil {
HStack(spacing: AppTheme.spacingXS) {
Image(systemName: "arrow.triangle.branch")
Text(selectedBranch)
}
.chipStyle()
}
Spacer()
}
.padding(.horizontal, AppTheme.spacingMD)
}
.padding(.bottom, AppTheme.spacingLG)
}
}
.navigationTitle("New Session")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") {
dismiss()
}
.foregroundStyle(AppTheme.primaryText)
}
}
.onAppear {
selectedRepository = appState.repositories.first
}
.sheet(isPresented: $showModelPicker) {
ModelPickerSheet(selectedModel: $selectedModel)
.presentationDetents([.medium])
}
.sheet(isPresented: $showRepoPicker) {
RepositoryPickerSheet(
repositories: appState.repositories,
selectedRepository: $selectedRepository
)
.presentationDetents([.medium, .large])
}
}
}
private func createSession() {
guard !messageText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
let repository = selectedRepository else { return }
let session = appState.createNewSession(
title: String(messageText.prefix(50)),
repository: repository
)
appState.sendMessage(messageText, to: session)
dismiss()
appState.openSession(session)
}
}
#Preview {
NavigationStack {
SessionListView()
}
.environment(AppState())
.preferredColorScheme(.dark)
}