import MusicAssistantCore import SwiftUI struct ChatWorkspaceView: View { @State private var project: SongProject let saveProject: (SongProject) async -> Bool @State private var messages: [ChatWorkspaceMessage] @State private var draftMessage = "" @FocusState private var isComposerFocused: Bool init(project: SongProject, saveProject: @escaping (SongProject) async -> Bool = { _ in true }) { _project = State(initialValue: project) self.saveProject = saveProject _messages = State(initialValue: Self.initialMessages(for: project)) } var body: some View { HSplitView { VStack(spacing: 0) { ChatWorkspaceHeader(project: project) Divider() ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 16) { ForEach(messages) { message in ChatMessageBubble(message: message) .id(message.id) } } .padding(24) .frame(maxWidth: .infinity, alignment: .leading) } .background(Color(nsColor: .textBackgroundColor)) .onChange(of: messages.count) { _, _ in guard let lastMessageID = messages.last?.id else { return } withAnimation(.snappy) { proxy.scrollTo(lastMessageID, anchor: .bottom) } } } Divider() ChatComposer( text: $draftMessage, isFocused: $isComposerFocused, sendAction: sendMessage ) .padding(.horizontal, 20) .padding(.vertical, 14) } .frame(minWidth: 520, maxWidth: .infinity, maxHeight: .infinity) ProjectInspectorView(project: $project, saveProject: saveProject) .frame(minWidth: 300, idealWidth: 340, maxWidth: 420, maxHeight: .infinity) } .frame(maxWidth: .infinity, maxHeight: .infinity) .onAppear { isComposerFocused = true } } private func sendMessage() { let trimmedMessage = draftMessage.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedMessage.isEmpty else { return } messages.append(ChatWorkspaceMessage(role: .user, text: trimmedMessage)) draftMessage = "" } private static func initialMessages(for project: SongProject) -> [ChatWorkspaceMessage] { let prompt: String if project.idea.isEmpty { prompt = "What kind of song are we making?" } else { prompt = project.idea } return [ ChatWorkspaceMessage(role: .assistant, text: prompt) ] } } private struct ChatWorkspaceHeader: View { let project: SongProject var body: some View { VStack(alignment: .leading, spacing: 16) { HStack(alignment: .top, spacing: 12) { Image(systemName: "music.note") .font(.title3.weight(.semibold)) .foregroundStyle(.tint) .frame(width: 42, height: 42) .background(.tint.opacity(0.12), in: RoundedRectangle(cornerRadius: 8)) VStack(alignment: .leading, spacing: 4) { Text(project.title) .font(.title2.weight(.semibold)) .lineLimit(1) Text(project.idea.isEmpty ? "Song Project" : project.idea) .font(.subheadline) .foregroundStyle(.secondary) .lineLimit(2) } Spacer(minLength: 12) Text(project.updatedAt.formatted(date: .abbreviated, time: .shortened)) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) } ViewThatFits(in: .horizontal) { HStack(spacing: 12) { projectMetrics } LazyVGrid( columns: [GridItem(.adaptive(minimum: 104), spacing: 10)], alignment: .leading, spacing: 8 ) { projectMetrics } } } .padding(.horizontal, 22) .padding(.vertical, 20) .frame(maxWidth: .infinity, alignment: .leading) .background(.bar) } @ViewBuilder private var projectMetrics: some View { ProjectMetricLabel(title: "Versions", value: project.versions.count, systemImage: "clock.arrow.circlepath") ProjectMetricLabel(title: "Genres", value: project.genres.count, systemImage: "music.quarternote.3") ProjectMetricLabel(title: "Instruments", value: project.selectedInstrumentTracks.count, systemImage: "pianokeys") ProjectMetricLabel(title: "Sections", value: project.sections.count, systemImage: "text.line.first.and.arrowtriangle.forward") ProjectMetricLabel(title: "Vocalists", value: project.vocalists.count, systemImage: "person.wave.2") } } private struct ProjectMetricLabel: View { let title: String let value: Int let systemImage: String var body: some View { Label { Text("\(value) \(title)") } icon: { Image(systemName: systemImage) } .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) .labelStyle(.titleAndIcon) .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) } } private struct ChatMessageBubble: View { let message: ChatWorkspaceMessage var body: some View { HStack(alignment: .bottom, spacing: 8) { if message.role == .user { Spacer(minLength: 64) } if message.role == .assistant { ChatRoleMark(role: message.role) } Text(message.text) .font(.body) .foregroundStyle(message.role == .user ? .white : .primary) .padding(.horizontal, 14) .padding(.vertical, 10) .background(message.role == .user ? Color.accentColor : Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 8)) .frame(maxWidth: 620, alignment: message.role == .user ? .trailing : .leading) if message.role == .user { ChatRoleMark(role: message.role) } if message.role == .assistant { Spacer(minLength: 64) } } .frame(maxWidth: .infinity, alignment: message.role == .user ? .trailing : .leading) } } private struct ChatRoleMark: View { let role: ChatWorkspaceMessage.Role var body: some View { Image(systemName: role == .assistant ? "sparkles" : "person.fill") .font(.caption.weight(.semibold)) .foregroundStyle(role == .assistant ? Color.teal : Color.accentColor) .frame(width: 28, height: 28) .background(.quaternary, in: RoundedRectangle(cornerRadius: 7)) .accessibilityHidden(true) } } private struct ChatComposer: View { @Binding var text: String var isFocused: FocusState.Binding let sendAction: () -> Void var body: some View { HStack(alignment: .bottom, spacing: 10) { TextField("Describe the song idea, lyrics, mood, or arrangement", text: $text, axis: .vertical) .textFieldStyle(.plain) .lineLimit(1...5) .focused(isFocused) .onSubmit(sendAction) Button(action: sendAction) { Label("Send", systemImage: "paperplane.fill") .labelStyle(.iconOnly) } .keyboardShortcut(.return, modifiers: .command) .buttonStyle(.borderedProminent) .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) .help("Send") } .padding(10) .background(.quaternary, in: RoundedRectangle(cornerRadius: 8)) } } private struct ChatWorkspaceMessage: Identifiable, Equatable { enum Role: Equatable { case assistant case user } let id: UUID let role: Role let text: String init(id: UUID = UUID(), role: Role, text: String) { self.id = id self.role = role self.text = text } } #Preview { ChatWorkspaceView( project: SongProject( title: "Untitled Song", idea: "A cinematic Arabic pop song with oud, violin, and a hopeful chorus." ) ) }