mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 11:48:42 +02:00
Compare commits
1 Commits
fix/runtim
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31c2476a7b |
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"colors": [
|
||||
{
|
||||
"color": {
|
||||
"color-space": "srgb",
|
||||
"components": {
|
||||
"alpha": "1.000",
|
||||
"blue": "0.996",
|
||||
"green": "0.388",
|
||||
"red": "0.384"
|
||||
}
|
||||
},
|
||||
"idiom": "universal"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"idiom": "universal",
|
||||
"platform": "ios",
|
||||
"size": "1024x1024"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
6
apps/ios/Multica/Assets.xcassets/Contents.json
Normal file
6
apps/ios/Multica/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
37
apps/ios/Multica/Info.plist
Normal file
37
apps/ios/Multica/Info.plist
Normal file
@@ -0,0 +1,37 @@
|
||||
<?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>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>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
</dict>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
29
apps/ios/Multica/Models/Agent.swift
Normal file
29
apps/ios/Multica/Models/Agent.swift
Normal file
@@ -0,0 +1,29 @@
|
||||
import Foundation
|
||||
|
||||
struct Agent: Codable, Identifiable, Hashable, Sendable {
|
||||
let id: String
|
||||
let workspaceId: String
|
||||
let name: String
|
||||
let description: String
|
||||
let instructions: String?
|
||||
let avatarURL: String?
|
||||
let status: AgentStatus
|
||||
let createdAt: String
|
||||
let updatedAt: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, name, description, instructions, status
|
||||
case workspaceId = "workspace_id"
|
||||
case avatarURL = "avatar_url"
|
||||
case createdAt = "created_at"
|
||||
case updatedAt = "updated_at"
|
||||
}
|
||||
}
|
||||
|
||||
enum AgentStatus: String, Codable, Sendable {
|
||||
case idle
|
||||
case working
|
||||
case blocked
|
||||
case error
|
||||
case offline
|
||||
}
|
||||
61
apps/ios/Multica/Models/AgentTask.swift
Normal file
61
apps/ios/Multica/Models/AgentTask.swift
Normal file
@@ -0,0 +1,61 @@
|
||||
import Foundation
|
||||
|
||||
struct AgentTask: Codable, Identifiable, Sendable {
|
||||
let id: String
|
||||
let agentId: String
|
||||
let runtimeId: String?
|
||||
let issueId: String
|
||||
let status: TaskStatus
|
||||
let priority: Int?
|
||||
let dispatchedAt: String?
|
||||
let startedAt: String?
|
||||
let completedAt: String?
|
||||
let error: String?
|
||||
let createdAt: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, status, priority, error
|
||||
case agentId = "agent_id"
|
||||
case runtimeId = "runtime_id"
|
||||
case issueId = "issue_id"
|
||||
case dispatchedAt = "dispatched_at"
|
||||
case startedAt = "started_at"
|
||||
case completedAt = "completed_at"
|
||||
case createdAt = "created_at"
|
||||
}
|
||||
}
|
||||
|
||||
enum TaskStatus: String, Codable, Sendable {
|
||||
case queued
|
||||
case dispatched
|
||||
case running
|
||||
case completed
|
||||
case failed
|
||||
case cancelled
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .queued: "Queued"
|
||||
case .dispatched: "Dispatched"
|
||||
case .running: "Running"
|
||||
case .completed: "Completed"
|
||||
case .failed: "Failed"
|
||||
case .cancelled: "Cancelled"
|
||||
}
|
||||
}
|
||||
|
||||
var iconName: String {
|
||||
switch self {
|
||||
case .queued: "clock"
|
||||
case .dispatched: "arrow.right.circle"
|
||||
case .running: "play.circle.fill"
|
||||
case .completed: "checkmark.circle.fill"
|
||||
case .failed: "xmark.circle.fill"
|
||||
case .cancelled: "minus.circle.fill"
|
||||
}
|
||||
}
|
||||
|
||||
var isActive: Bool {
|
||||
self == .queued || self == .dispatched || self == .running
|
||||
}
|
||||
}
|
||||
46
apps/ios/Multica/Models/Comment.swift
Normal file
46
apps/ios/Multica/Models/Comment.swift
Normal file
@@ -0,0 +1,46 @@
|
||||
import Foundation
|
||||
|
||||
struct Comment: Codable, Identifiable, Sendable {
|
||||
let id: String
|
||||
let issueId: String
|
||||
let authorType: String
|
||||
let authorId: String
|
||||
let content: String
|
||||
let type: String
|
||||
let parentId: String?
|
||||
let attachments: [Attachment]?
|
||||
let createdAt: String
|
||||
let updatedAt: String
|
||||
|
||||
// Joined fields from server
|
||||
let authorName: String?
|
||||
let authorAvatarURL: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, content, type, attachments
|
||||
case issueId = "issue_id"
|
||||
case authorType = "author_type"
|
||||
case authorId = "author_id"
|
||||
case parentId = "parent_id"
|
||||
case createdAt = "created_at"
|
||||
case updatedAt = "updated_at"
|
||||
case authorName = "author_name"
|
||||
case authorAvatarURL = "author_avatar_url"
|
||||
}
|
||||
|
||||
var isFromAgent: Bool {
|
||||
authorType == "agent"
|
||||
}
|
||||
}
|
||||
|
||||
struct Attachment: Codable, Identifiable, Sendable {
|
||||
let id: String
|
||||
let filename: String
|
||||
let contentType: String?
|
||||
let url: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, filename, url
|
||||
case contentType = "content_type"
|
||||
}
|
||||
}
|
||||
152
apps/ios/Multica/Models/Issue.swift
Normal file
152
apps/ios/Multica/Models/Issue.swift
Normal file
@@ -0,0 +1,152 @@
|
||||
import Foundation
|
||||
|
||||
enum IssueStatus: String, Codable, CaseIterable, Sendable {
|
||||
case backlog
|
||||
case todo
|
||||
case inProgress = "in_progress"
|
||||
case inReview = "in_review"
|
||||
case done
|
||||
case blocked
|
||||
case cancelled
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .backlog: "Backlog"
|
||||
case .todo: "Todo"
|
||||
case .inProgress: "In Progress"
|
||||
case .inReview: "In Review"
|
||||
case .done: "Done"
|
||||
case .blocked: "Blocked"
|
||||
case .cancelled: "Cancelled"
|
||||
}
|
||||
}
|
||||
|
||||
var iconName: String {
|
||||
switch self {
|
||||
case .backlog: "circle.dashed"
|
||||
case .todo: "circle"
|
||||
case .inProgress: "circle.lefthalf.filled"
|
||||
case .inReview: "eye.circle"
|
||||
case .done: "checkmark.circle.fill"
|
||||
case .blocked: "xmark.circle"
|
||||
case .cancelled: "minus.circle"
|
||||
}
|
||||
}
|
||||
|
||||
var color: String {
|
||||
switch self {
|
||||
case .backlog: "gray"
|
||||
case .todo: "gray"
|
||||
case .inProgress: "yellow"
|
||||
case .inReview: "blue"
|
||||
case .done: "green"
|
||||
case .blocked: "red"
|
||||
case .cancelled: "gray"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum IssuePriority: String, Codable, CaseIterable, Sendable {
|
||||
case urgent
|
||||
case high
|
||||
case medium
|
||||
case low
|
||||
case none
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .urgent: "Urgent"
|
||||
case .high: "High"
|
||||
case .medium: "Medium"
|
||||
case .low: "Low"
|
||||
case .none: "None"
|
||||
}
|
||||
}
|
||||
|
||||
var iconName: String {
|
||||
switch self {
|
||||
case .urgent: "exclamationmark.3"
|
||||
case .high: "exclamationmark.2"
|
||||
case .medium: "exclamationmark"
|
||||
case .low: "minus"
|
||||
case .none: "minus"
|
||||
}
|
||||
}
|
||||
|
||||
var sortOrder: Int {
|
||||
switch self {
|
||||
case .urgent: 0
|
||||
case .high: 1
|
||||
case .medium: 2
|
||||
case .low: 3
|
||||
case .none: 4
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Issue: Codable, Identifiable, Hashable, Sendable {
|
||||
let id: String
|
||||
let workspaceId: String
|
||||
let number: Int
|
||||
let identifier: String
|
||||
let title: String
|
||||
let description: String?
|
||||
let status: IssueStatus
|
||||
let priority: IssuePriority
|
||||
let assigneeType: String?
|
||||
let assigneeId: String?
|
||||
let creatorType: String
|
||||
let creatorId: String
|
||||
let parentIssueId: String?
|
||||
let position: Int
|
||||
let dueDate: String?
|
||||
let createdAt: String
|
||||
let updatedAt: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, number, identifier, title, description, status, priority, position
|
||||
case workspaceId = "workspace_id"
|
||||
case assigneeType = "assignee_type"
|
||||
case assigneeId = "assignee_id"
|
||||
case creatorType = "creator_type"
|
||||
case creatorId = "creator_id"
|
||||
case parentIssueId = "parent_issue_id"
|
||||
case dueDate = "due_date"
|
||||
case createdAt = "created_at"
|
||||
case updatedAt = "updated_at"
|
||||
}
|
||||
|
||||
var isAssignedToAgent: Bool {
|
||||
assigneeType == "agent"
|
||||
}
|
||||
}
|
||||
|
||||
struct CreateIssueRequest: Codable, Sendable {
|
||||
let title: String
|
||||
let description: String?
|
||||
let status: String?
|
||||
let priority: String?
|
||||
let assigneeType: String?
|
||||
let assigneeId: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case title, description, status, priority
|
||||
case assigneeType = "assignee_type"
|
||||
case assigneeId = "assignee_id"
|
||||
}
|
||||
}
|
||||
|
||||
struct UpdateIssueRequest: Codable, Sendable {
|
||||
var title: String?
|
||||
var description: String?
|
||||
var status: String?
|
||||
var priority: String?
|
||||
var assigneeType: String?
|
||||
var assigneeId: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case title, description, status, priority
|
||||
case assigneeType = "assignee_type"
|
||||
case assigneeId = "assignee_id"
|
||||
}
|
||||
}
|
||||
55
apps/ios/Multica/Models/Member.swift
Normal file
55
apps/ios/Multica/Models/Member.swift
Normal file
@@ -0,0 +1,55 @@
|
||||
import Foundation
|
||||
|
||||
struct Member: Codable, Identifiable, Hashable, Sendable {
|
||||
let id: String
|
||||
let userId: String
|
||||
let workspaceId: String
|
||||
let role: String
|
||||
let user: User?
|
||||
let createdAt: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, role, user
|
||||
case userId = "user_id"
|
||||
case workspaceId = "workspace_id"
|
||||
case createdAt = "created_at"
|
||||
}
|
||||
|
||||
var displayName: String {
|
||||
user?.name ?? "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
/// Unified type for displaying assignees (either member or agent)
|
||||
enum Assignee: Identifiable, Hashable, Sendable {
|
||||
case member(Member)
|
||||
case agent(Agent)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .member(let m): m.id
|
||||
case .agent(let a): a.id
|
||||
}
|
||||
}
|
||||
|
||||
var name: String {
|
||||
switch self {
|
||||
case .member(let m): m.displayName
|
||||
case .agent(let a): a.name
|
||||
}
|
||||
}
|
||||
|
||||
var typeName: String {
|
||||
switch self {
|
||||
case .member: "member"
|
||||
case .agent: "agent"
|
||||
}
|
||||
}
|
||||
|
||||
var entityId: String {
|
||||
switch self {
|
||||
case .member(let m): m.userId
|
||||
case .agent(let a): a.id
|
||||
}
|
||||
}
|
||||
}
|
||||
71
apps/ios/Multica/Models/TaskMessage.swift
Normal file
71
apps/ios/Multica/Models/TaskMessage.swift
Normal file
@@ -0,0 +1,71 @@
|
||||
import Foundation
|
||||
|
||||
struct TaskMessage: Codable, Identifiable, Sendable {
|
||||
let taskId: String
|
||||
let issueId: String?
|
||||
let seq: Int
|
||||
let type: MessageType
|
||||
let tool: String?
|
||||
let content: String?
|
||||
let input: [String: AnyCodable]?
|
||||
let output: String?
|
||||
|
||||
var id: String { "\(taskId)-\(seq)" }
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case seq, type, tool, content, input, output
|
||||
case taskId = "task_id"
|
||||
case issueId = "issue_id"
|
||||
}
|
||||
}
|
||||
|
||||
enum MessageType: String, Codable, Sendable {
|
||||
case text
|
||||
case thinking
|
||||
case toolUse = "tool_use"
|
||||
case toolResult = "tool_result"
|
||||
case error
|
||||
}
|
||||
|
||||
// Simple wrapper for heterogeneous JSON values
|
||||
struct AnyCodable: Codable, @unchecked Sendable {
|
||||
let value: Any
|
||||
|
||||
init(_ value: Any) {
|
||||
self.value = value
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
if let string = try? container.decode(String.self) {
|
||||
value = string
|
||||
} else if let int = try? container.decode(Int.self) {
|
||||
value = int
|
||||
} else if let double = try? container.decode(Double.self) {
|
||||
value = double
|
||||
} else if let bool = try? container.decode(Bool.self) {
|
||||
value = bool
|
||||
} else if let dict = try? container.decode([String: AnyCodable].self) {
|
||||
value = dict.mapValues(\.value)
|
||||
} else if let array = try? container.decode([AnyCodable].self) {
|
||||
value = array.map(\.value)
|
||||
} else {
|
||||
value = NSNull()
|
||||
}
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
if let string = value as? String {
|
||||
try container.encode(string)
|
||||
} else if let int = value as? Int {
|
||||
try container.encode(int)
|
||||
} else if let double = value as? Double {
|
||||
try container.encode(double)
|
||||
} else if let bool = value as? Bool {
|
||||
try container.encode(bool)
|
||||
} else {
|
||||
try container.encodeNil()
|
||||
}
|
||||
}
|
||||
}
|
||||
22
apps/ios/Multica/Models/User.swift
Normal file
22
apps/ios/Multica/Models/User.swift
Normal file
@@ -0,0 +1,22 @@
|
||||
import Foundation
|
||||
|
||||
struct User: Codable, Identifiable, Hashable, Sendable {
|
||||
let id: String
|
||||
let name: String
|
||||
let email: String
|
||||
let avatarURL: String?
|
||||
let createdAt: String
|
||||
let updatedAt: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, name, email
|
||||
case avatarURL = "avatar_url"
|
||||
case createdAt = "created_at"
|
||||
case updatedAt = "updated_at"
|
||||
}
|
||||
}
|
||||
|
||||
struct AuthResponse: Codable, Sendable {
|
||||
let token: String
|
||||
let user: User
|
||||
}
|
||||
23
apps/ios/Multica/Models/Workspace.swift
Normal file
23
apps/ios/Multica/Models/Workspace.swift
Normal file
@@ -0,0 +1,23 @@
|
||||
import Foundation
|
||||
|
||||
struct Workspace: Codable, Identifiable, Sendable {
|
||||
let id: String
|
||||
let name: String
|
||||
let slug: String
|
||||
let description: String?
|
||||
let issuePrefix: String
|
||||
let createdAt: String
|
||||
let updatedAt: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, name, slug, description
|
||||
case issuePrefix = "issue_prefix"
|
||||
case createdAt = "created_at"
|
||||
case updatedAt = "updated_at"
|
||||
}
|
||||
}
|
||||
|
||||
struct WorkspaceRepo: Codable, Sendable {
|
||||
let url: String
|
||||
let description: String?
|
||||
}
|
||||
10
apps/ios/Multica/MulticaApp.swift
Normal file
10
apps/ios/Multica/MulticaApp.swift
Normal file
@@ -0,0 +1,10 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct MulticaApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
}
|
||||
}
|
||||
}
|
||||
274
apps/ios/Multica/Services/APIClient.swift
Normal file
274
apps/ios/Multica/Services/APIClient.swift
Normal file
@@ -0,0 +1,274 @@
|
||||
import Foundation
|
||||
|
||||
enum APIError: LocalizedError {
|
||||
case invalidURL
|
||||
case unauthorized
|
||||
case serverError(String)
|
||||
case networkError(Error)
|
||||
case decodingError(Error)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidURL: "Invalid URL"
|
||||
case .unauthorized: "Session expired. Please log in again."
|
||||
case .serverError(let msg): msg
|
||||
case .networkError(let err): err.localizedDescription
|
||||
case .decodingError(let err): "Failed to parse response: \(err.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class APIClient: Sendable {
|
||||
static let shared = APIClient()
|
||||
|
||||
// Configure these for your server
|
||||
#if DEBUG
|
||||
let baseURL = "http://localhost:8080"
|
||||
#else
|
||||
let baseURL = "https://api.multica.ai"
|
||||
#endif
|
||||
|
||||
private let session: URLSession
|
||||
private let decoder: JSONDecoder
|
||||
|
||||
private init() {
|
||||
let config = URLSessionConfiguration.default
|
||||
config.timeoutIntervalForRequest = 30
|
||||
session = URLSession(configuration: config)
|
||||
decoder = JSONDecoder()
|
||||
}
|
||||
|
||||
var token: String? {
|
||||
get { KeychainHelper.read(key: "auth_token") }
|
||||
set {
|
||||
if let newValue {
|
||||
KeychainHelper.save(key: "auth_token", value: newValue)
|
||||
} else {
|
||||
KeychainHelper.delete(key: "auth_token")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var workspaceId: String? {
|
||||
get { UserDefaults.standard.string(forKey: "workspace_id") }
|
||||
set { UserDefaults.standard.set(newValue, forKey: "workspace_id") }
|
||||
}
|
||||
|
||||
// MARK: - Auth
|
||||
|
||||
func sendCode(email: String) async throws {
|
||||
let body = ["email": email]
|
||||
let _: EmptyResponse = try await post("/auth/send-code", body: body, authenticated: false)
|
||||
}
|
||||
|
||||
func verifyCode(email: String, code: String) async throws -> AuthResponse {
|
||||
let body = ["email": email, "code": code]
|
||||
return try await post("/auth/verify-code", body: body, authenticated: false)
|
||||
}
|
||||
|
||||
// MARK: - Workspaces
|
||||
|
||||
func listWorkspaces() async throws -> [Workspace] {
|
||||
try await get("/api/workspaces")
|
||||
}
|
||||
|
||||
func getWorkspace(_ id: String) async throws -> Workspace {
|
||||
try await get("/api/workspaces/\(id)")
|
||||
}
|
||||
|
||||
// MARK: - Issues
|
||||
|
||||
func listIssues(
|
||||
status: String? = nil,
|
||||
priority: String? = nil,
|
||||
assigneeId: String? = nil,
|
||||
limit: Int = 200,
|
||||
offset: Int = 0
|
||||
) async throws -> [Issue] {
|
||||
var params: [(String, String)] = [
|
||||
("limit", "\(limit)"),
|
||||
("offset", "\(offset)"),
|
||||
]
|
||||
if let status { params.append(("status", status)) }
|
||||
if let priority { params.append(("priority", priority)) }
|
||||
if let assigneeId { params.append(("assignee_id", assigneeId)) }
|
||||
return try await get("/api/issues", queryItems: params)
|
||||
}
|
||||
|
||||
func getIssue(_ id: String) async throws -> Issue {
|
||||
try await get("/api/issues/\(id)")
|
||||
}
|
||||
|
||||
func createIssue(_ req: CreateIssueRequest) async throws -> Issue {
|
||||
try await post("/api/issues", body: req)
|
||||
}
|
||||
|
||||
func updateIssue(_ id: String, _ req: UpdateIssueRequest) async throws -> Issue {
|
||||
try await put("/api/issues/\(id)", body: req)
|
||||
}
|
||||
|
||||
func deleteIssue(_ id: String) async throws {
|
||||
let _: EmptyResponse = try await request("DELETE", path: "/api/issues/\(id)")
|
||||
}
|
||||
|
||||
// MARK: - Comments
|
||||
|
||||
func listComments(issueId: String) async throws -> [Comment] {
|
||||
try await get("/api/issues/\(issueId)/comments")
|
||||
}
|
||||
|
||||
func createComment(issueId: String, content: String, parentId: String? = nil) async throws -> Comment {
|
||||
var body: [String: String] = ["content": content]
|
||||
if let parentId { body["parent_id"] = parentId }
|
||||
return try await post("/api/issues/\(issueId)/comments", body: body)
|
||||
}
|
||||
|
||||
// MARK: - Members & Agents
|
||||
|
||||
func listMembers(workspaceId: String) async throws -> [Member] {
|
||||
try await get("/api/workspaces/\(workspaceId)/members")
|
||||
}
|
||||
|
||||
func listAgents() async throws -> [Agent] {
|
||||
try await get("/api/agents")
|
||||
}
|
||||
|
||||
// MARK: - Tasks
|
||||
|
||||
func getActiveTask(issueId: String) async throws -> AgentTask? {
|
||||
do {
|
||||
return try await get("/api/issues/\(issueId)/active-task")
|
||||
} catch APIError.serverError {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func listTaskRuns(issueId: String) async throws -> [AgentTask] {
|
||||
try await get("/api/issues/\(issueId)/task-runs")
|
||||
}
|
||||
|
||||
func listTaskMessages(taskId: String) async throws -> [TaskMessage] {
|
||||
try await get("/api/tasks/\(taskId)/messages")
|
||||
}
|
||||
|
||||
func cancelTask(issueId: String, taskId: String) async throws {
|
||||
let _: EmptyResponse = try await post("/api/issues/\(issueId)/tasks/\(taskId)/cancel", body: EmptyBody())
|
||||
}
|
||||
|
||||
// MARK: - Timeline
|
||||
|
||||
func listTimeline(issueId: String) async throws -> [TimelineEntry] {
|
||||
try await get("/api/issues/\(issueId)/timeline")
|
||||
}
|
||||
|
||||
// MARK: - Networking Helpers
|
||||
|
||||
private func get<T: Decodable>(_ path: String, queryItems: [(String, String)] = []) async throws -> T {
|
||||
try await request("GET", path: path, queryItems: queryItems)
|
||||
}
|
||||
|
||||
private func post<T: Decodable, B: Encodable>(_ path: String, body: B, authenticated: Bool = true) async throws -> T {
|
||||
try await request("POST", path: path, body: body, authenticated: authenticated)
|
||||
}
|
||||
|
||||
private func put<T: Decodable, B: Encodable>(_ path: String, body: B) async throws -> T {
|
||||
try await request("PUT", path: path, body: body)
|
||||
}
|
||||
|
||||
private func request<T: Decodable>(
|
||||
_ method: String,
|
||||
path: String,
|
||||
queryItems: [(String, String)] = [],
|
||||
body: (any Encodable)? = nil,
|
||||
authenticated: Bool = true
|
||||
) async throws -> T {
|
||||
guard var components = URLComponents(string: baseURL + path) else {
|
||||
throw APIError.invalidURL
|
||||
}
|
||||
if !queryItems.isEmpty {
|
||||
components.queryItems = queryItems.map { URLQueryItem(name: $0.0, value: $0.1) }
|
||||
}
|
||||
guard let url = components.url else {
|
||||
throw APIError.invalidURL
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = method
|
||||
|
||||
if authenticated {
|
||||
guard let token else { throw APIError.unauthorized }
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
|
||||
if let workspaceId, authenticated {
|
||||
request.setValue(workspaceId, forHTTPHeaderField: "X-Workspace-ID")
|
||||
}
|
||||
|
||||
if let body {
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = try JSONEncoder().encode(body)
|
||||
}
|
||||
|
||||
let (data, response) : (Data, URLResponse)
|
||||
do {
|
||||
(data, response) = try await session.data(for: request)
|
||||
} catch {
|
||||
throw APIError.networkError(error)
|
||||
}
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw APIError.serverError("Invalid response")
|
||||
}
|
||||
|
||||
if httpResponse.statusCode == 401 {
|
||||
throw APIError.unauthorized
|
||||
}
|
||||
|
||||
if httpResponse.statusCode >= 400 {
|
||||
if let errorBody = try? JSONDecoder().decode(ErrorResponse.self, from: data) {
|
||||
throw APIError.serverError(errorBody.error)
|
||||
}
|
||||
throw APIError.serverError("Server error (\(httpResponse.statusCode))")
|
||||
}
|
||||
|
||||
// Handle empty responses
|
||||
if T.self == EmptyResponse.self {
|
||||
return EmptyResponse() as! T
|
||||
}
|
||||
|
||||
do {
|
||||
return try decoder.decode(T.self, from: data)
|
||||
} catch {
|
||||
throw APIError.decodingError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct EmptyResponse: Decodable {}
|
||||
struct EmptyBody: Encodable {}
|
||||
struct ErrorResponse: Decodable {
|
||||
let error: String
|
||||
}
|
||||
|
||||
struct TimelineEntry: Codable, Identifiable, Sendable {
|
||||
let id: String
|
||||
let issueId: String?
|
||||
let actorType: String?
|
||||
let actorId: String?
|
||||
let action: String?
|
||||
let field: String?
|
||||
let oldValue: String?
|
||||
let newValue: String?
|
||||
let createdAt: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, action, field
|
||||
case issueId = "issue_id"
|
||||
case actorType = "actor_type"
|
||||
case actorId = "actor_id"
|
||||
case oldValue = "old_value"
|
||||
case newValue = "new_value"
|
||||
case createdAt = "created_at"
|
||||
}
|
||||
}
|
||||
42
apps/ios/Multica/Services/KeychainHelper.swift
Normal file
42
apps/ios/Multica/Services/KeychainHelper.swift
Normal file
@@ -0,0 +1,42 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
enum KeychainHelper {
|
||||
private static let service = "ai.multica.app"
|
||||
|
||||
static func save(key: String, value: String) {
|
||||
guard let data = value.data(using: .utf8) else { return }
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: key,
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
var newItem = query
|
||||
newItem[kSecValueData as String] = data
|
||||
SecItemAdd(newItem as CFDictionary, nil)
|
||||
}
|
||||
|
||||
static func read(key: String) -> String? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
]
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
guard status == errSecSuccess, let data = result as? Data else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
static func delete(key: String) {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: key,
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
}
|
||||
122
apps/ios/Multica/Services/WebSocketClient.swift
Normal file
122
apps/ios/Multica/Services/WebSocketClient.swift
Normal file
@@ -0,0 +1,122 @@
|
||||
import Foundation
|
||||
|
||||
struct WSEvent: @unchecked Sendable {
|
||||
let type: String
|
||||
let payload: [String: Any]
|
||||
let actorId: String?
|
||||
|
||||
var prefix: String {
|
||||
String(type.prefix(while: { $0 != ":" }))
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class WebSocketClient: ObservableObject {
|
||||
static let shared = WebSocketClient()
|
||||
|
||||
@Published var isConnected = false
|
||||
|
||||
private var webSocketTask: URLSessionWebSocketTask?
|
||||
private var handlers: [(String, @Sendable (WSEvent) -> Void)] = []
|
||||
private var reconnectTask: Task<Void, Never>?
|
||||
private let session = URLSession(configuration: .default)
|
||||
|
||||
private init() {}
|
||||
|
||||
func connect() {
|
||||
guard let token = APIClient.shared.token,
|
||||
let workspaceId = APIClient.shared.workspaceId else { return }
|
||||
|
||||
let wsScheme: String
|
||||
#if DEBUG
|
||||
wsScheme = "ws"
|
||||
#else
|
||||
wsScheme = "wss"
|
||||
#endif
|
||||
|
||||
let baseHost = APIClient.shared.baseURL
|
||||
.replacingOccurrences(of: "http://", with: "")
|
||||
.replacingOccurrences(of: "https://", with: "")
|
||||
|
||||
guard let url = URL(string: "\(wsScheme)://\(baseHost)/ws?token=\(token)&workspace_id=\(workspaceId)") else {
|
||||
return
|
||||
}
|
||||
|
||||
webSocketTask = session.webSocketTask(with: url)
|
||||
webSocketTask?.resume()
|
||||
isConnected = true
|
||||
receiveMessage()
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
reconnectTask?.cancel()
|
||||
reconnectTask = nil
|
||||
webSocketTask?.cancel(with: .goingAway, reason: nil)
|
||||
webSocketTask = nil
|
||||
isConnected = false
|
||||
handlers.removeAll()
|
||||
}
|
||||
|
||||
func on(_ eventType: String, handler: @escaping @Sendable (WSEvent) -> Void) {
|
||||
handlers.append((eventType, handler))
|
||||
}
|
||||
|
||||
func onPrefix(_ prefix: String, handler: @escaping @Sendable (WSEvent) -> Void) {
|
||||
handlers.append(("prefix:\(prefix)", handler))
|
||||
}
|
||||
|
||||
private func receiveMessage() {
|
||||
webSocketTask?.receive { [weak self] result in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
switch result {
|
||||
case .success(let message):
|
||||
switch message {
|
||||
case .string(let text):
|
||||
self.handleMessage(text)
|
||||
case .data(let data):
|
||||
if let text = String(data: data, encoding: .utf8) {
|
||||
self.handleMessage(text)
|
||||
}
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
self.receiveMessage()
|
||||
case .failure:
|
||||
self.isConnected = false
|
||||
self.scheduleReconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleMessage(_ text: String) {
|
||||
guard let data = text.data(using: .utf8),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let type = json["type"] as? String else { return }
|
||||
|
||||
let payload = json["payload"] as? [String: Any] ?? [:]
|
||||
let actorId = json["actor_id"] as? String
|
||||
let event = WSEvent(type: type, payload: payload, actorId: actorId)
|
||||
|
||||
for (pattern, handler) in handlers {
|
||||
if pattern == type {
|
||||
handler(event)
|
||||
} else if pattern.hasPrefix("prefix:") {
|
||||
let prefix = String(pattern.dropFirst(7))
|
||||
if event.prefix == prefix {
|
||||
handler(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleReconnect() {
|
||||
reconnectTask?.cancel()
|
||||
reconnectTask = Task {
|
||||
try? await Task.sleep(for: .seconds(3))
|
||||
guard !Task.isCancelled else { return }
|
||||
self.connect()
|
||||
}
|
||||
}
|
||||
}
|
||||
65
apps/ios/Multica/ViewModels/AuthViewModel.swift
Normal file
65
apps/ios/Multica/ViewModels/AuthViewModel.swift
Normal file
@@ -0,0 +1,65 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class AuthViewModel {
|
||||
var email = ""
|
||||
var code = ""
|
||||
var isLoading = false
|
||||
var error: String?
|
||||
var codeSent = false
|
||||
var isAuthenticated = false
|
||||
var user: User?
|
||||
|
||||
init() {
|
||||
// Check for existing token
|
||||
if APIClient.shared.token != nil {
|
||||
isAuthenticated = true
|
||||
}
|
||||
}
|
||||
|
||||
func sendCode() async {
|
||||
guard !email.isEmpty else {
|
||||
error = "Please enter your email"
|
||||
return
|
||||
}
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
try await APIClient.shared.sendCode(email: email)
|
||||
codeSent = true
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func verifyCode() async {
|
||||
guard !code.isEmpty else {
|
||||
error = "Please enter the verification code"
|
||||
return
|
||||
}
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
let response = try await APIClient.shared.verifyCode(email: email, code: code)
|
||||
APIClient.shared.token = response.token
|
||||
user = response.user
|
||||
isAuthenticated = true
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func logout() {
|
||||
APIClient.shared.token = nil
|
||||
APIClient.shared.workspaceId = nil
|
||||
WebSocketClient.shared.disconnect()
|
||||
isAuthenticated = false
|
||||
user = nil
|
||||
email = ""
|
||||
code = ""
|
||||
codeSent = false
|
||||
}
|
||||
}
|
||||
132
apps/ios/Multica/ViewModels/IssueDetailViewModel.swift
Normal file
132
apps/ios/Multica/ViewModels/IssueDetailViewModel.swift
Normal file
@@ -0,0 +1,132 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class IssueDetailViewModel {
|
||||
var issue: Issue
|
||||
var comments: [Comment] = []
|
||||
var taskRuns: [AgentTask] = []
|
||||
var activeTask: AgentTask?
|
||||
var taskMessages: [TaskMessage] = []
|
||||
var isLoading = false
|
||||
var error: String?
|
||||
|
||||
init(issue: Issue) {
|
||||
self.issue = issue
|
||||
}
|
||||
|
||||
func loadAll() async {
|
||||
isLoading = true
|
||||
do {
|
||||
async let commentsResult = APIClient.shared.listComments(issueId: issue.id)
|
||||
async let taskRunsResult = APIClient.shared.listTaskRuns(issueId: issue.id)
|
||||
async let activeTaskResult = APIClient.shared.getActiveTask(issueId: issue.id)
|
||||
|
||||
comments = try await commentsResult
|
||||
taskRuns = try await taskRunsResult
|
||||
activeTask = try await activeTaskResult
|
||||
|
||||
// Load messages for active task
|
||||
if let task = activeTask {
|
||||
taskMessages = try await APIClient.shared.listTaskMessages(taskId: task.id)
|
||||
}
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func updateStatus(_ status: IssueStatus) async {
|
||||
do {
|
||||
let updated = try await APIClient.shared.updateIssue(issue.id, UpdateIssueRequest(status: status.rawValue))
|
||||
issue = updated
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func updatePriority(_ priority: IssuePriority) async {
|
||||
do {
|
||||
let updated = try await APIClient.shared.updateIssue(issue.id, UpdateIssueRequest(priority: priority.rawValue))
|
||||
issue = updated
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func updateAssignee(type: String?, id: String?) async {
|
||||
do {
|
||||
let updated = try await APIClient.shared.updateIssue(issue.id, UpdateIssueRequest(
|
||||
assigneeType: type ?? "",
|
||||
assigneeId: id ?? ""
|
||||
))
|
||||
issue = updated
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func updateTitle(_ title: String) async {
|
||||
do {
|
||||
let updated = try await APIClient.shared.updateIssue(issue.id, UpdateIssueRequest(title: title))
|
||||
issue = updated
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func addComment(_ content: String) async {
|
||||
do {
|
||||
let comment = try await APIClient.shared.createComment(issueId: issue.id, content: content)
|
||||
comments.append(comment)
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func loadTaskMessages(taskId: String) async {
|
||||
do {
|
||||
taskMessages = try await APIClient.shared.listTaskMessages(taskId: taskId)
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func setupRealtimeUpdates() {
|
||||
let issueId = issue.id
|
||||
WebSocketClient.shared.on("task:message") { [weak self] event in
|
||||
Task { @MainActor in
|
||||
guard let self,
|
||||
let payload = event.payload["issue_id"] as? String,
|
||||
payload == issueId else { return }
|
||||
// Reload messages for active task
|
||||
if let task = self.activeTask {
|
||||
await self.loadTaskMessages(taskId: task.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
WebSocketClient.shared.onPrefix("task") { [weak self] event in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
await self.loadAll()
|
||||
}
|
||||
}
|
||||
|
||||
WebSocketClient.shared.onPrefix("comment") { [weak self] event in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
self.comments = (try? await APIClient.shared.listComments(issueId: issueId)) ?? self.comments
|
||||
}
|
||||
}
|
||||
|
||||
WebSocketClient.shared.on("issue:updated") { [weak self] event in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
if let updated = try? await APIClient.shared.getIssue(issueId) {
|
||||
self.issue = updated
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
63
apps/ios/Multica/ViewModels/IssueListViewModel.swift
Normal file
63
apps/ios/Multica/ViewModels/IssueListViewModel.swift
Normal file
@@ -0,0 +1,63 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class IssueListViewModel {
|
||||
var issues: [Issue] = []
|
||||
var isLoading = false
|
||||
var error: String?
|
||||
var statusFilter: IssueStatus?
|
||||
var searchText = ""
|
||||
|
||||
var filteredIssues: [Issue] {
|
||||
var result = issues
|
||||
if let statusFilter {
|
||||
result = result.filter { $0.status == statusFilter }
|
||||
}
|
||||
if !searchText.isEmpty {
|
||||
result = result.filter {
|
||||
$0.title.localizedCaseInsensitiveContains(searchText) ||
|
||||
$0.identifier.localizedCaseInsensitiveContains(searchText)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Group issues by status for sectioned display
|
||||
var issuesByStatus: [(IssueStatus, [Issue])] {
|
||||
let grouped = Dictionary(grouping: filteredIssues, by: \.status)
|
||||
let order: [IssueStatus] = [.inProgress, .todo, .inReview, .blocked, .backlog, .done, .cancelled]
|
||||
return order.compactMap { status in
|
||||
guard let issues = grouped[status], !issues.isEmpty else { return nil }
|
||||
return (status, issues)
|
||||
}
|
||||
}
|
||||
|
||||
func loadIssues() async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
issues = try await APIClient.shared.listIssues()
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func refresh() async {
|
||||
do {
|
||||
issues = try await APIClient.shared.listIssues()
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func deleteIssue(_ issue: Issue) async {
|
||||
do {
|
||||
try await APIClient.shared.deleteIssue(issue.id)
|
||||
issues.removeAll { $0.id == issue.id }
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
70
apps/ios/Multica/ViewModels/WorkspaceViewModel.swift
Normal file
70
apps/ios/Multica/ViewModels/WorkspaceViewModel.swift
Normal file
@@ -0,0 +1,70 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class WorkspaceViewModel {
|
||||
var workspaces: [Workspace] = []
|
||||
var selectedWorkspace: Workspace?
|
||||
var members: [Member] = []
|
||||
var agents: [Agent] = []
|
||||
var isLoading = false
|
||||
var error: String?
|
||||
|
||||
var hasSelectedWorkspace: Bool {
|
||||
selectedWorkspace != nil
|
||||
}
|
||||
|
||||
func loadWorkspaces() async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
workspaces = try await APIClient.shared.listWorkspaces()
|
||||
// Auto-select if there's a saved workspace or only one
|
||||
if let savedId = APIClient.shared.workspaceId,
|
||||
let saved = workspaces.first(where: { $0.id == savedId }) {
|
||||
await selectWorkspace(saved)
|
||||
} else if workspaces.count == 1 {
|
||||
await selectWorkspace(workspaces[0])
|
||||
}
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func selectWorkspace(_ workspace: Workspace) async {
|
||||
selectedWorkspace = workspace
|
||||
APIClient.shared.workspaceId = workspace.id
|
||||
WebSocketClient.shared.disconnect()
|
||||
WebSocketClient.shared.connect()
|
||||
await loadWorkspaceData()
|
||||
}
|
||||
|
||||
func loadWorkspaceData() async {
|
||||
guard let workspace = selectedWorkspace else { return }
|
||||
do {
|
||||
async let membersResult = APIClient.shared.listMembers(workspaceId: workspace.id)
|
||||
async let agentsResult = APIClient.shared.listAgents()
|
||||
members = try await membersResult
|
||||
agents = try await agentsResult
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// All possible assignees (members + agents)
|
||||
var assignees: [Assignee] {
|
||||
let memberAssignees = members.map { Assignee.member($0) }
|
||||
let agentAssignees = agents.map { Assignee.agent($0) }
|
||||
return memberAssignees + agentAssignees
|
||||
}
|
||||
|
||||
func assigneeName(type: String?, id: String?) -> String {
|
||||
guard let type, let id else { return "Unassigned" }
|
||||
if type == "agent" {
|
||||
return agents.first(where: { $0.id == id })?.name ?? "Agent"
|
||||
} else {
|
||||
return members.first(where: { $0.userId == id })?.displayName ?? "Member"
|
||||
}
|
||||
}
|
||||
}
|
||||
105
apps/ios/Multica/Views/Auth/LoginView.swift
Normal file
105
apps/ios/Multica/Views/Auth/LoginView.swift
Normal file
@@ -0,0 +1,105 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LoginView: View {
|
||||
@Bindable var viewModel: AuthViewModel
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 32) {
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "bolt.circle.fill")
|
||||
.font(.system(size: 64))
|
||||
.foregroundStyle(Color.accentColor)
|
||||
Text("Multica")
|
||||
.font(.largeTitle.bold())
|
||||
Text("AI-native project management")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if !viewModel.codeSent {
|
||||
emailForm
|
||||
} else {
|
||||
codeForm
|
||||
}
|
||||
|
||||
if let error = viewModel.error {
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 32)
|
||||
.navigationBarHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
private var emailForm: some View {
|
||||
VStack(spacing: 16) {
|
||||
TextField("Email address", text: $viewModel.email)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.textContentType(.emailAddress)
|
||||
.keyboardType(.emailAddress)
|
||||
.autocapitalization(.none)
|
||||
.disableAutocorrection(true)
|
||||
|
||||
Button {
|
||||
Task { await viewModel.sendCode() }
|
||||
} label: {
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
Text("Send Code")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
.disabled(viewModel.email.isEmpty || viewModel.isLoading)
|
||||
}
|
||||
}
|
||||
|
||||
private var codeForm: some View {
|
||||
VStack(spacing: 16) {
|
||||
Text("Enter the 6-digit code sent to \(viewModel.email)")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
TextField("Verification code", text: $viewModel.code)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.keyboardType(.numberPad)
|
||||
.multilineTextAlignment(.center)
|
||||
.font(.title2.monospaced())
|
||||
|
||||
Button {
|
||||
Task { await viewModel.verifyCode() }
|
||||
} label: {
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
Text("Verify")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
.disabled(viewModel.code.isEmpty || viewModel.isLoading)
|
||||
|
||||
Button("Use a different email") {
|
||||
viewModel.codeSent = false
|
||||
viewModel.code = ""
|
||||
viewModel.error = nil
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
111
apps/ios/Multica/Views/Comments/CommentListView.swift
Normal file
111
apps/ios/Multica/Views/Comments/CommentListView.swift
Normal file
@@ -0,0 +1,111 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CommentListView: View {
|
||||
@Bindable var viewModel: IssueDetailViewModel
|
||||
@State private var newComment = ""
|
||||
@State private var isSending = false
|
||||
|
||||
var body: some View {
|
||||
LazyVStack(alignment: .leading, spacing: 0) {
|
||||
if viewModel.comments.isEmpty && !viewModel.isLoading {
|
||||
Text("No comments yet")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 32)
|
||||
}
|
||||
|
||||
ForEach(viewModel.comments) { comment in
|
||||
CommentRowView(comment: comment)
|
||||
if comment.id != viewModel.comments.last?.id {
|
||||
Divider().padding(.leading, 48)
|
||||
}
|
||||
}
|
||||
|
||||
// New comment input
|
||||
VStack(spacing: 8) {
|
||||
Divider()
|
||||
HStack(alignment: .bottom, spacing: 8) {
|
||||
TextField("Add a comment...", text: $newComment, axis: .vertical)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.lineLimit(1...5)
|
||||
|
||||
Button {
|
||||
Task { await sendComment() }
|
||||
} label: {
|
||||
if isSending {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
} else {
|
||||
Image(systemName: "arrow.up.circle.fill")
|
||||
.font(.title2)
|
||||
}
|
||||
}
|
||||
.disabled(newComment.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSending)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sendComment() async {
|
||||
let text = newComment.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return }
|
||||
isSending = true
|
||||
await viewModel.addComment(text)
|
||||
newComment = ""
|
||||
isSending = false
|
||||
}
|
||||
}
|
||||
|
||||
struct CommentRowView: View {
|
||||
let comment: Comment
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
AssigneeAvatar(
|
||||
type: comment.authorType,
|
||||
name: comment.authorName ?? "?",
|
||||
size: 28
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(comment.authorName ?? (comment.isFromAgent ? "Agent" : "User"))
|
||||
.font(.subheadline.bold())
|
||||
if comment.isFromAgent {
|
||||
Text("BOT")
|
||||
.font(.caption2.bold())
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 1)
|
||||
.background(.purple.opacity(0.15))
|
||||
.foregroundStyle(.purple)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 3))
|
||||
}
|
||||
Spacer()
|
||||
Text(relativeDate(comment.createdAt))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
|
||||
Text(comment.content)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
private func relativeDate(_ isoString: String) -> String {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
guard let date = formatter.date(from: isoString) ?? ISO8601DateFormatter().date(from: isoString) else {
|
||||
return ""
|
||||
}
|
||||
let relative = RelativeDateTimeFormatter()
|
||||
relative.unitsStyle = .abbreviated
|
||||
return relative.localizedString(for: date, relativeTo: Date())
|
||||
}
|
||||
}
|
||||
33
apps/ios/Multica/Views/Components/AssigneeAvatar.swift
Normal file
33
apps/ios/Multica/Views/Components/AssigneeAvatar.swift
Normal file
@@ -0,0 +1,33 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AssigneeAvatar: View {
|
||||
let type: String?
|
||||
let name: String
|
||||
var size: CGFloat = 24
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(type == "agent" ? Color.purple.opacity(0.2) : Color.gray.opacity(0.2))
|
||||
.frame(width: size, height: size)
|
||||
|
||||
if type == "agent" {
|
||||
Image(systemName: "cpu")
|
||||
.font(.system(size: size * 0.5))
|
||||
.foregroundStyle(.purple)
|
||||
} else {
|
||||
Text(initials)
|
||||
.font(.system(size: size * 0.4, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var initials: String {
|
||||
let parts = name.split(separator: " ")
|
||||
if parts.count >= 2 {
|
||||
return "\(parts[0].prefix(1))\(parts[1].prefix(1))".uppercased()
|
||||
}
|
||||
return String(name.prefix(2)).uppercased()
|
||||
}
|
||||
}
|
||||
29
apps/ios/Multica/Views/Components/PriorityIcon.swift
Normal file
29
apps/ios/Multica/Views/Components/PriorityIcon.swift
Normal file
@@ -0,0 +1,29 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PriorityIcon: View {
|
||||
let priority: IssuePriority
|
||||
var size: CGFloat = 14
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch priority {
|
||||
case .urgent:
|
||||
Image(systemName: "exclamationmark.3")
|
||||
.foregroundStyle(.red)
|
||||
case .high:
|
||||
Image(systemName: "exclamationmark.2")
|
||||
.foregroundStyle(.orange)
|
||||
case .medium:
|
||||
Image(systemName: "exclamationmark")
|
||||
.foregroundStyle(.yellow)
|
||||
case .low:
|
||||
Image(systemName: "arrow.down")
|
||||
.foregroundStyle(.blue)
|
||||
case .none:
|
||||
Image(systemName: "minus")
|
||||
.foregroundStyle(.gray)
|
||||
}
|
||||
}
|
||||
.font(.system(size: size, weight: .medium))
|
||||
}
|
||||
}
|
||||
46
apps/ios/Multica/Views/Components/StatusBadge.swift
Normal file
46
apps/ios/Multica/Views/Components/StatusBadge.swift
Normal file
@@ -0,0 +1,46 @@
|
||||
import SwiftUI
|
||||
|
||||
struct StatusBadge: View {
|
||||
let status: IssueStatus
|
||||
|
||||
var body: some View {
|
||||
Label(status.label, systemImage: status.iconName)
|
||||
.font(.caption)
|
||||
.foregroundStyle(statusColor)
|
||||
}
|
||||
|
||||
private var statusColor: Color {
|
||||
switch status {
|
||||
case .backlog: .gray
|
||||
case .todo: .primary
|
||||
case .inProgress: .yellow
|
||||
case .inReview: .blue
|
||||
case .done: .green
|
||||
case .blocked: .red
|
||||
case .cancelled: .gray
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct StatusIcon: View {
|
||||
let status: IssueStatus
|
||||
var size: CGFloat = 16
|
||||
|
||||
var body: some View {
|
||||
Image(systemName: status.iconName)
|
||||
.font(.system(size: size))
|
||||
.foregroundStyle(statusColor)
|
||||
}
|
||||
|
||||
private var statusColor: Color {
|
||||
switch status {
|
||||
case .backlog: .gray
|
||||
case .todo: .primary
|
||||
case .inProgress: .yellow
|
||||
case .inReview: .blue
|
||||
case .done: .green
|
||||
case .blocked: .red
|
||||
case .cancelled: .gray
|
||||
}
|
||||
}
|
||||
}
|
||||
38
apps/ios/Multica/Views/ContentView.swift
Normal file
38
apps/ios/Multica/Views/ContentView.swift
Normal file
@@ -0,0 +1,38 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@State private var authVM = AuthViewModel()
|
||||
@State private var workspaceVM = WorkspaceViewModel()
|
||||
@State private var issueListVM = IssueListViewModel()
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if !authVM.isAuthenticated {
|
||||
LoginView(viewModel: authVM)
|
||||
} else if !workspaceVM.hasSelectedWorkspace {
|
||||
WorkspacePickerView(viewModel: workspaceVM)
|
||||
} else {
|
||||
IssueListView(viewModel: issueListVM, workspaceVM: workspaceVM)
|
||||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .logout)) { _ in
|
||||
authVM.logout()
|
||||
workspaceVM.selectedWorkspace = nil
|
||||
issueListVM.issues = []
|
||||
}
|
||||
.onChange(of: workspaceVM.hasSelectedWorkspace) { _, hasWorkspace in
|
||||
if hasWorkspace {
|
||||
Task { await issueListVM.loadIssues() }
|
||||
setupRealtimeSync()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setupRealtimeSync() {
|
||||
WebSocketClient.shared.onPrefix("issue") { _ in
|
||||
Task { @MainActor in
|
||||
await issueListVM.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
96
apps/ios/Multica/Views/Issues/CreateIssueView.swift
Normal file
96
apps/ios/Multica/Views/Issues/CreateIssueView.swift
Normal file
@@ -0,0 +1,96 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CreateIssueView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Bindable var workspaceVM: WorkspaceViewModel
|
||||
var onCreate: (Issue) -> Void
|
||||
|
||||
@State private var title = ""
|
||||
@State private var description = ""
|
||||
@State private var status: IssueStatus = .todo
|
||||
@State private var priority: IssuePriority = .none
|
||||
@State private var selectedAssignee: Assignee?
|
||||
@State private var isSubmitting = false
|
||||
@State private var error: String?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
TextField("Title", text: $title)
|
||||
TextField("Description (optional)", text: $description, axis: .vertical)
|
||||
.lineLimit(3...8)
|
||||
}
|
||||
|
||||
Section("Properties") {
|
||||
// Status picker
|
||||
Picker("Status", selection: $status) {
|
||||
ForEach(IssueStatus.allCases, id: \.self) { s in
|
||||
Label(s.label, systemImage: s.iconName).tag(s)
|
||||
}
|
||||
}
|
||||
|
||||
// Priority picker
|
||||
Picker("Priority", selection: $priority) {
|
||||
ForEach(IssuePriority.allCases, id: \.self) { p in
|
||||
Label(p.label, systemImage: p.iconName).tag(p)
|
||||
}
|
||||
}
|
||||
|
||||
// Assignee picker
|
||||
Picker("Assignee", selection: $selectedAssignee) {
|
||||
Text("Unassigned").tag(nil as Assignee?)
|
||||
ForEach(workspaceVM.assignees) { assignee in
|
||||
Label(
|
||||
assignee.name,
|
||||
systemImage: assignee.typeName == "agent" ? "cpu" : "person"
|
||||
).tag(assignee as Assignee?)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let error {
|
||||
Section {
|
||||
Text(error)
|
||||
.foregroundStyle(.red)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("New Issue")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Create") {
|
||||
Task { await createIssue() }
|
||||
}
|
||||
.disabled(title.isEmpty || isSubmitting)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func createIssue() async {
|
||||
isSubmitting = true
|
||||
error = nil
|
||||
do {
|
||||
let request = CreateIssueRequest(
|
||||
title: title,
|
||||
description: description.isEmpty ? nil : description,
|
||||
status: status.rawValue,
|
||||
priority: priority.rawValue,
|
||||
assigneeType: selectedAssignee?.typeName,
|
||||
assigneeId: selectedAssignee?.entityId
|
||||
)
|
||||
let issue = try await APIClient.shared.createIssue(request)
|
||||
onCreate(issue)
|
||||
dismiss()
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isSubmitting = false
|
||||
}
|
||||
}
|
||||
182
apps/ios/Multica/Views/Issues/IssueDetailView.swift
Normal file
182
apps/ios/Multica/Views/Issues/IssueDetailView.swift
Normal file
@@ -0,0 +1,182 @@
|
||||
import SwiftUI
|
||||
|
||||
struct IssueDetailView: View {
|
||||
@Bindable var viewModel: IssueDetailViewModel
|
||||
@Bindable var workspaceVM: WorkspaceViewModel
|
||||
@State private var selectedTab = 0
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
issueHeader
|
||||
Divider()
|
||||
propertiesSection
|
||||
Divider()
|
||||
|
||||
// Tab bar
|
||||
Picker("", selection: $selectedTab) {
|
||||
Text("Comments").tag(0)
|
||||
Text("Activity").tag(1)
|
||||
if viewModel.issue.isAssignedToAgent {
|
||||
Text("Agent Runs").tag(2)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.padding()
|
||||
|
||||
switch selectedTab {
|
||||
case 0:
|
||||
CommentListView(viewModel: viewModel)
|
||||
case 1:
|
||||
activitySection
|
||||
case 2:
|
||||
TaskRunsView(viewModel: viewModel)
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(viewModel.issue.identifier)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task {
|
||||
await viewModel.loadAll()
|
||||
viewModel.setupRealtimeUpdates()
|
||||
}
|
||||
.alert("Error", isPresented: .init(
|
||||
get: { viewModel.error != nil },
|
||||
set: { if !$0 { viewModel.error = nil } }
|
||||
)) {
|
||||
Button("OK") { viewModel.error = nil }
|
||||
} message: {
|
||||
Text(viewModel.error ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
private var issueHeader: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(viewModel.issue.title)
|
||||
.font(.title2.bold())
|
||||
|
||||
if let desc = viewModel.issue.description, !desc.isEmpty {
|
||||
Text(desc)
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
// Active task banner
|
||||
if let task = viewModel.activeTask, task.status.isActive {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
Text("Agent is working on this issue...")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button("View Logs") {
|
||||
selectedTab = 2
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
.padding(10)
|
||||
.background(.purple.opacity(0.1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
private var propertiesSection: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Status
|
||||
propertyRow(label: "Status") {
|
||||
Menu {
|
||||
ForEach(IssueStatus.allCases, id: \.self) { status in
|
||||
Button {
|
||||
Task { await viewModel.updateStatus(status) }
|
||||
} label: {
|
||||
Label(status.label, systemImage: status.iconName)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
StatusBadge(status: viewModel.issue.status)
|
||||
}
|
||||
}
|
||||
Divider().padding(.leading)
|
||||
|
||||
// Priority
|
||||
propertyRow(label: "Priority") {
|
||||
Menu {
|
||||
ForEach(IssuePriority.allCases, id: \.self) { priority in
|
||||
Button {
|
||||
Task { await viewModel.updatePriority(priority) }
|
||||
} label: {
|
||||
Label(priority.label, systemImage: priority.iconName)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
PriorityIcon(priority: viewModel.issue.priority)
|
||||
Text(viewModel.issue.priority.label)
|
||||
.font(.subheadline)
|
||||
}
|
||||
}
|
||||
}
|
||||
Divider().padding(.leading)
|
||||
|
||||
// Assignee
|
||||
propertyRow(label: "Assignee") {
|
||||
Menu {
|
||||
Button("Unassigned") {
|
||||
Task { await viewModel.updateAssignee(type: nil, id: nil) }
|
||||
}
|
||||
Divider()
|
||||
ForEach(workspaceVM.assignees) { assignee in
|
||||
Button {
|
||||
Task { await viewModel.updateAssignee(type: assignee.typeName, id: assignee.entityId) }
|
||||
} label: {
|
||||
Label(
|
||||
assignee.name,
|
||||
systemImage: assignee.typeName == "agent" ? "cpu" : "person"
|
||||
)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
let name = workspaceVM.assigneeName(type: viewModel.issue.assigneeType, id: viewModel.issue.assigneeId)
|
||||
HStack(spacing: 6) {
|
||||
AssigneeAvatar(type: viewModel.issue.assigneeType, name: name, size: 20)
|
||||
Text(name)
|
||||
.font(.subheadline)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func propertyRow<Content: View>(label: String, @ViewBuilder content: () -> Content) -> some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 80, alignment: .leading)
|
||||
content()
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
private var activitySection: some View {
|
||||
LazyVStack(alignment: .leading, spacing: 0) {
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
} else {
|
||||
Text("Activity timeline")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
113
apps/ios/Multica/Views/Issues/IssueListView.swift
Normal file
113
apps/ios/Multica/Views/Issues/IssueListView.swift
Normal file
@@ -0,0 +1,113 @@
|
||||
import SwiftUI
|
||||
|
||||
struct IssueListView: View {
|
||||
@Bindable var viewModel: IssueListViewModel
|
||||
@Bindable var workspaceVM: WorkspaceViewModel
|
||||
@State private var showCreateIssue = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if viewModel.isLoading && viewModel.issues.isEmpty {
|
||||
ProgressView("Loading issues...")
|
||||
} else if viewModel.filteredIssues.isEmpty {
|
||||
ContentUnavailableView.search(text: viewModel.searchText)
|
||||
} else {
|
||||
issueList
|
||||
}
|
||||
}
|
||||
.navigationTitle(workspaceVM.selectedWorkspace?.name ?? "Issues")
|
||||
.searchable(text: $viewModel.searchText, prompt: "Search issues...")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
showCreateIssue = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Menu {
|
||||
Button("All") { viewModel.statusFilter = nil }
|
||||
Divider()
|
||||
ForEach(IssueStatus.allCases, id: \.self) { status in
|
||||
Button {
|
||||
viewModel.statusFilter = status
|
||||
} label: {
|
||||
Label(status.label, systemImage: status.iconName)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: viewModel.statusFilter != nil ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle")
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Menu {
|
||||
Button("Switch Workspace") {
|
||||
workspaceVM.selectedWorkspace = nil
|
||||
}
|
||||
Button("Logout", role: .destructive) {
|
||||
NotificationCenter.default.post(name: .logout, object: nil)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "person.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
.refreshable {
|
||||
await viewModel.refresh()
|
||||
}
|
||||
.sheet(isPresented: $showCreateIssue) {
|
||||
CreateIssueView(workspaceVM: workspaceVM) { newIssue in
|
||||
viewModel.issues.insert(newIssue, at: 0)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await viewModel.loadIssues()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var issueList: some View {
|
||||
List {
|
||||
ForEach(viewModel.issuesByStatus, id: \.0) { status, issues in
|
||||
Section {
|
||||
ForEach(issues) { issue in
|
||||
NavigationLink(value: issue) {
|
||||
IssueRowView(
|
||||
issue: issue,
|
||||
assigneeName: workspaceVM.assigneeName(type: issue.assigneeType, id: issue.assigneeId)
|
||||
)
|
||||
}
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
for index in indexSet {
|
||||
let issue = issues[index]
|
||||
Task { await viewModel.deleteIssue(issue) }
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
HStack(spacing: 6) {
|
||||
StatusIcon(status: status, size: 14)
|
||||
Text(status.label)
|
||||
.font(.caption.weight(.semibold))
|
||||
Text("\(issues.count)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.navigationDestination(for: Issue.self) { issue in
|
||||
IssueDetailView(
|
||||
viewModel: IssueDetailViewModel(issue: issue),
|
||||
workspaceVM: workspaceVM
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
static let logout = Notification.Name("logout")
|
||||
}
|
||||
36
apps/ios/Multica/Views/Issues/IssueRowView.swift
Normal file
36
apps/ios/Multica/Views/Issues/IssueRowView.swift
Normal file
@@ -0,0 +1,36 @@
|
||||
import SwiftUI
|
||||
|
||||
struct IssueRowView: View {
|
||||
let issue: Issue
|
||||
let assigneeName: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
StatusIcon(status: issue.status)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 6) {
|
||||
Text(issue.identifier)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
PriorityIcon(priority: issue.priority, size: 10)
|
||||
}
|
||||
|
||||
Text(issue.title)
|
||||
.font(.subheadline)
|
||||
.lineLimit(2)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if issue.assigneeId != nil {
|
||||
AssigneeAvatar(
|
||||
type: issue.assigneeType,
|
||||
name: assigneeName,
|
||||
size: 24
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
138
apps/ios/Multica/Views/Tasks/TaskMessagesView.swift
Normal file
138
apps/ios/Multica/Views/Tasks/TaskMessagesView.swift
Normal file
@@ -0,0 +1,138 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TaskMessagesView: View {
|
||||
let messages: [TaskMessage]
|
||||
let isLive: Bool
|
||||
|
||||
var body: some View {
|
||||
LazyVStack(alignment: .leading, spacing: 2) {
|
||||
if messages.isEmpty {
|
||||
Text("No log messages")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
}
|
||||
|
||||
ForEach(messages) { message in
|
||||
TaskMessageRow(message: message)
|
||||
}
|
||||
|
||||
if isLive && !messages.isEmpty {
|
||||
HStack(spacing: 6) {
|
||||
ProgressView()
|
||||
.controlSize(.mini)
|
||||
Text("Streaming...")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TaskMessageRow: View {
|
||||
let message: TaskMessage
|
||||
@State private var isExpanded = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
switch message.type {
|
||||
case .text:
|
||||
textMessage
|
||||
|
||||
case .thinking:
|
||||
thinkingMessage
|
||||
|
||||
case .toolUse:
|
||||
toolUseMessage
|
||||
|
||||
case .toolResult:
|
||||
toolResultMessage
|
||||
|
||||
case .error:
|
||||
errorMessage
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 3)
|
||||
}
|
||||
|
||||
private var textMessage: some View {
|
||||
Text(message.content ?? "")
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
|
||||
private var thinkingMessage: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "brain")
|
||||
.font(.caption2)
|
||||
Text("Thinking")
|
||||
.font(.caption2.weight(.medium))
|
||||
}
|
||||
.foregroundStyle(.purple.opacity(0.7))
|
||||
|
||||
if let content = message.content, !content.isEmpty {
|
||||
Text(content)
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(isExpanded ? nil : 3)
|
||||
.onTapGesture { isExpanded.toggle() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var toolUseMessage: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "wrench")
|
||||
.font(.caption2)
|
||||
Text(message.tool ?? "Tool")
|
||||
.font(.caption2.weight(.semibold).monospaced())
|
||||
}
|
||||
.foregroundStyle(.blue)
|
||||
|
||||
if let content = message.content, !content.isEmpty {
|
||||
Text(content)
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(isExpanded ? nil : 2)
|
||||
.onTapGesture { isExpanded.toggle() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var toolResultMessage: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "arrow.turn.down.left")
|
||||
.font(.caption2)
|
||||
Text(message.tool ?? "Result")
|
||||
.font(.caption2.weight(.medium).monospaced())
|
||||
}
|
||||
.foregroundStyle(.green)
|
||||
|
||||
if let output = message.output, !output.isEmpty {
|
||||
Text(output)
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(isExpanded ? nil : 3)
|
||||
.onTapGesture { isExpanded.toggle() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var errorMessage: some View {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.caption2)
|
||||
Text(message.content ?? "Error")
|
||||
.font(.caption2.monospaced())
|
||||
}
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
206
apps/ios/Multica/Views/Tasks/TaskRunsView.swift
Normal file
206
apps/ios/Multica/Views/Tasks/TaskRunsView.swift
Normal file
@@ -0,0 +1,206 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TaskRunsView: View {
|
||||
@Bindable var viewModel: IssueDetailViewModel
|
||||
|
||||
var body: some View {
|
||||
LazyVStack(alignment: .leading, spacing: 0) {
|
||||
if viewModel.taskRuns.isEmpty && !viewModel.isLoading {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "cpu")
|
||||
.font(.title)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("No agent runs yet")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Assign this issue to an agent to trigger execution.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 32)
|
||||
}
|
||||
|
||||
// Active task with live logs
|
||||
if let activeTask = viewModel.activeTask, activeTask.status.isActive {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
Text("Running")
|
||||
.font(.subheadline.bold())
|
||||
.foregroundStyle(.purple)
|
||||
Spacer()
|
||||
Text("Started \(relativeDate(activeTask.startedAt ?? activeTask.createdAt))")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 12)
|
||||
|
||||
TaskMessagesView(messages: viewModel.taskMessages, isLive: true)
|
||||
}
|
||||
.background(.purple.opacity(0.03))
|
||||
|
||||
Divider()
|
||||
}
|
||||
|
||||
// Historical runs
|
||||
ForEach(viewModel.taskRuns.filter { t in
|
||||
viewModel.activeTask.map { $0.id != t.id } ?? true
|
||||
}) { task in
|
||||
NavigationLink {
|
||||
TaskRunDetailView(task: task)
|
||||
} label: {
|
||||
TaskRunRow(task: task)
|
||||
}
|
||||
Divider().padding(.leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func relativeDate(_ isoString: String) -> String {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
guard let date = formatter.date(from: isoString) ?? ISO8601DateFormatter().date(from: isoString) else {
|
||||
return ""
|
||||
}
|
||||
let relative = RelativeDateTimeFormatter()
|
||||
relative.unitsStyle = .abbreviated
|
||||
return relative.localizedString(for: date, relativeTo: Date())
|
||||
}
|
||||
}
|
||||
|
||||
struct TaskRunRow: View {
|
||||
let task: AgentTask
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: task.status.iconName)
|
||||
.foregroundStyle(taskColor)
|
||||
.font(.body)
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
HStack {
|
||||
Text(task.status.label)
|
||||
.font(.subheadline.weight(.medium))
|
||||
if let error = task.error {
|
||||
Text(error)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.red)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
HStack(spacing: 12) {
|
||||
Text(formatDate(task.createdAt))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
if let started = task.startedAt, let completed = task.completedAt {
|
||||
Text(duration(from: started, to: completed))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
private var taskColor: Color {
|
||||
switch task.status {
|
||||
case .completed: .green
|
||||
case .failed: .red
|
||||
case .running: .purple
|
||||
case .cancelled: .gray
|
||||
default: .secondary
|
||||
}
|
||||
}
|
||||
|
||||
private func formatDate(_ iso: String) -> String {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
guard let date = formatter.date(from: iso) ?? ISO8601DateFormatter().date(from: iso) else { return iso }
|
||||
let df = DateFormatter()
|
||||
df.dateStyle = .short
|
||||
df.timeStyle = .short
|
||||
return df.string(from: date)
|
||||
}
|
||||
|
||||
private func duration(from start: String, to end: String) -> String {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
guard let s = formatter.date(from: start) ?? ISO8601DateFormatter().date(from: start),
|
||||
let e = formatter.date(from: end) ?? ISO8601DateFormatter().date(from: end) else { return "" }
|
||||
let interval = e.timeIntervalSince(s)
|
||||
if interval < 60 { return "\(Int(interval))s" }
|
||||
if interval < 3600 { return "\(Int(interval / 60))m \(Int(interval.truncatingRemainder(dividingBy: 60)))s" }
|
||||
return "\(Int(interval / 3600))h \(Int((interval.truncatingRemainder(dividingBy: 3600)) / 60))m"
|
||||
}
|
||||
}
|
||||
|
||||
struct TaskRunDetailView: View {
|
||||
let task: AgentTask
|
||||
@State private var messages: [TaskMessage] = []
|
||||
@State private var isLoading = true
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
// Task info header
|
||||
HStack {
|
||||
Image(systemName: task.status.iconName)
|
||||
.foregroundStyle(taskColor)
|
||||
Text(task.status.label)
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
if let error = task.error {
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
if isLoading {
|
||||
ProgressView("Loading execution log...")
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
} else {
|
||||
TaskMessagesView(messages: messages, isLive: false)
|
||||
}
|
||||
}
|
||||
.padding(.top)
|
||||
}
|
||||
.navigationTitle("Run Details")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task {
|
||||
do {
|
||||
messages = try await APIClient.shared.listTaskMessages(taskId: task.id)
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
private var taskColor: Color {
|
||||
switch task.status {
|
||||
case .completed: .green
|
||||
case .failed: .red
|
||||
case .running: .purple
|
||||
case .cancelled: .gray
|
||||
default: .secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
47
apps/ios/Multica/Views/Workspace/WorkspacePickerView.swift
Normal file
47
apps/ios/Multica/Views/Workspace/WorkspacePickerView.swift
Normal file
@@ -0,0 +1,47 @@
|
||||
import SwiftUI
|
||||
|
||||
struct WorkspacePickerView: View {
|
||||
@Bindable var viewModel: WorkspaceViewModel
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if viewModel.isLoading {
|
||||
ProgressView("Loading workspaces...")
|
||||
} else if viewModel.workspaces.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Workspaces",
|
||||
systemImage: "folder",
|
||||
description: Text("You don't belong to any workspaces yet.")
|
||||
)
|
||||
} else {
|
||||
List(viewModel.workspaces) { workspace in
|
||||
Button {
|
||||
Task { await viewModel.selectWorkspace(workspace) }
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(workspace.name)
|
||||
.font(.headline)
|
||||
if let desc = workspace.description, !desc.isEmpty {
|
||||
Text(desc)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Workspaces")
|
||||
.task {
|
||||
await viewModel.loadWorkspaces()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
64
apps/ios/README.md
Normal file
64
apps/ios/README.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Multica iOS App
|
||||
|
||||
MVP iOS client for the Multica platform — issue management with AI agent execution log viewing.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Xcode 16+
|
||||
- iOS 17.0+
|
||||
- [XcodeGen](https://github.com/yonaskolb/XcodeGen) (for generating the Xcode project)
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
# Install XcodeGen if you don't have it
|
||||
brew install xcodegen
|
||||
|
||||
# Generate Xcode project
|
||||
cd apps/ios
|
||||
xcodegen generate
|
||||
|
||||
# Open in Xcode
|
||||
open Multica.xcodeproj
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
By default, the app connects to `http://localhost:8080` in debug builds. To change the API URL, edit `Multica/Services/APIClient.swift`.
|
||||
|
||||
## Features
|
||||
|
||||
- **Authentication** — Passwordless email login (send code → verify)
|
||||
- **Workspace selection** — Pick from your workspaces
|
||||
- **Issue list** — Grouped by status, searchable, with status filtering
|
||||
- **Issue detail** — View/edit title, status, priority, and assignee
|
||||
- **Comments** — View and add comments with threaded display
|
||||
- **Agent task runs** — View all historical agent executions for an issue
|
||||
- **Execution logs** — Real-time streaming of agent tool use, thinking, and output
|
||||
- **Real-time sync** — WebSocket connection for live updates
|
||||
|
||||
## Architecture
|
||||
|
||||
- **SwiftUI** with `@Observable` (iOS 17+)
|
||||
- **MVVM** — ViewModels use `@Observable` macro
|
||||
- **URLSession** for HTTP networking
|
||||
- **URLSessionWebSocketTask** for real-time
|
||||
- **Keychain** for secure token storage
|
||||
- No third-party dependencies
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
Multica/
|
||||
├── MulticaApp.swift # App entry point
|
||||
├── Models/ # Codable data models
|
||||
├── Services/ # API client, WebSocket, Keychain
|
||||
├── ViewModels/ # @Observable view models
|
||||
└── Views/
|
||||
├── Auth/ # Login + code verification
|
||||
├── Workspace/ # Workspace picker
|
||||
├── Issues/ # List, detail, create
|
||||
├── Comments/ # Comment list + input
|
||||
├── Tasks/ # Agent runs + execution logs
|
||||
└── Components/ # Shared UI (badges, icons, avatars)
|
||||
```
|
||||
24
apps/ios/project.yml
Normal file
24
apps/ios/project.yml
Normal file
@@ -0,0 +1,24 @@
|
||||
name: Multica
|
||||
options:
|
||||
bundleIdPrefix: ai.multica
|
||||
deploymentTarget:
|
||||
iOS: "17.0"
|
||||
xcodeVersion: "16.0"
|
||||
generateEmptyDirectories: true
|
||||
settings:
|
||||
base:
|
||||
SWIFT_VERSION: "5.9"
|
||||
MARKETING_VERSION: "1.0.0"
|
||||
CURRENT_PROJECT_VERSION: 1
|
||||
DEVELOPMENT_TEAM: ""
|
||||
targets:
|
||||
Multica:
|
||||
type: application
|
||||
platform: iOS
|
||||
sources:
|
||||
- Multica
|
||||
settings:
|
||||
base:
|
||||
INFOPLIST_FILE: Multica/Info.plist
|
||||
PRODUCT_BUNDLE_IDENTIFIER: ai.multica.app
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
Reference in New Issue
Block a user