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:
parent
86e0916fa3
commit
cffaf0b904
22
apps/ios-github-mobile/Package.swift
Normal file
22
apps/ios-github-mobile/Package.swift
Normal 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"
|
||||
)
|
||||
]
|
||||
)
|
||||
96
apps/ios-github-mobile/README.md
Normal file
96
apps/ios-github-mobile/README.md
Normal 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.
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"platform" : "macos",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
155
apps/ios-github-mobile/Sources/Components/PickerSheets.swift
Normal file
155
apps/ios-github-mobile/Sources/Components/PickerSheets.swift
Normal 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)
|
||||
}
|
||||
108
apps/ios-github-mobile/Sources/Components/StatusIndicator.swift
Normal file
108
apps/ios-github-mobile/Sources/Components/StatusIndicator.swift
Normal 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)
|
||||
}
|
||||
20
apps/ios-github-mobile/Sources/ContentView.swift
Normal file
20
apps/ios-github-mobile/Sources/ContentView.swift
Normal 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)
|
||||
}
|
||||
14
apps/ios-github-mobile/Sources/GitHubMobileApp.swift
Normal file
14
apps/ios-github-mobile/Sources/GitHubMobileApp.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
55
apps/ios-github-mobile/Sources/Info.plist
Normal file
55
apps/ios-github-mobile/Sources/Info.plist
Normal 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>
|
||||
92
apps/ios-github-mobile/Sources/Models/AppState.swift
Normal file
92
apps/ios-github-mobile/Sources/Models/AppState.swift
Normal 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
|
||||
}
|
||||
}
|
||||
277
apps/ios-github-mobile/Sources/Models/Models.swift
Normal file
277
apps/ios-github-mobile/Sources/Models/Models.swift
Normal 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: []
|
||||
),
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
86
apps/ios-github-mobile/Sources/Theme/AppTheme.swift
Normal file
86
apps/ios-github-mobile/Sources/Theme/AppTheme.swift
Normal 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())
|
||||
}
|
||||
}
|
||||
341
apps/ios-github-mobile/Sources/Views/ChatView.swift
Normal file
341
apps/ios-github-mobile/Sources/Views/ChatView.swift
Normal 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)
|
||||
}
|
||||
220
apps/ios-github-mobile/Sources/Views/SessionListView.swift
Normal file
220
apps/ios-github-mobile/Sources/Views/SessionListView.swift
Normal 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)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user