Compare commits

...

1 Commits

Author SHA1 Message Date
Jiayuan
31c2476a7b feat(ios): add MVP iOS app with issue management and agent log viewing
SwiftUI app (iOS 17+) with zero third-party dependencies:
- Passwordless email authentication with Keychain token storage
- Workspace selection and multi-workspace support
- Issue list grouped by status with search and filtering
- Issue detail with inline status/priority/assignee editing
- Comments with threaded display and compose
- Agent task runs history and real-time execution log streaming
- WebSocket integration for live updates
2026-04-02 23:50:28 +08:00
35 changed files with 2581 additions and 0 deletions

View File

@@ -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
}
}

View File

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

View File

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

View 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>

View 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
}

View 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
}
}

View 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"
}
}

View 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"
}
}

View 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
}
}
}

View 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()
}
}
}

View 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
}

View 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?
}

View File

@@ -0,0 +1,10 @@
import SwiftUI
@main
struct MulticaApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}

View 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"
}
}

View 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)
}
}

View 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()
}
}
}

View 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
}
}

View 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
}
}
}
}
}

View 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
}
}
}

View 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"
}
}
}

View 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)
}
}
}

View 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())
}
}

View 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()
}
}

View 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))
}
}

View 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
}
}
}

View 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()
}
}
}
}

View 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
}
}

View 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()
}
}
}
}

View 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")
}

View 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)
}
}

View 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)
}
}

View 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
}
}
}

View 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
View 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
View 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