Files
multica/apps/ios/Multica/Views/Issues/IssueDetailView.swift
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

183 lines
6.2 KiB
Swift

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