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