diff --git a/TFM/Features/AI/ContextGaugeView.swift b/Frameworks/TermindPresentation/AssistantContextGauge.swift similarity index 77% rename from TFM/Features/AI/ContextGaugeView.swift rename to Frameworks/TermindPresentation/AssistantContextGauge.swift index 09fe2c26..45c3e12f 100644 --- a/TFM/Features/AI/ContextGaugeView.swift +++ b/Frameworks/TermindPresentation/AssistantContextGauge.swift @@ -13,16 +13,23 @@ import TermindApplication /// a plain SwiftUI view and renders wherever any view renders — including a /// `Menu` label, which on macOS is bridged into AppKit and is not somewhere to /// find out that an imperative drawing context was unsupported. -struct ProgressRing: View { +public struct ProgressRing: View { let share: Double let tint: Color - var diameter: CGFloat = 11 - var lineWidth: CGFloat = 2 + public var diameter: CGFloat = 11 + public var lineWidth: CGFloat = 2 - var body: some View { + public init(share: Double, tint: Color, diameter: CGFloat = 11, lineWidth: CGFloat = 2) { + self.share = share + self.tint = tint + self.diameter = diameter + self.lineWidth = lineWidth + } + + public var body: some View { ZStack { Circle() - .stroke(.quaternary, lineWidth: lineWidth) + .stroke(Color.secondary.opacity(0.3), lineWidth: lineWidth) .padding(lineWidth / 2) Circle() .trim(from: 0, to: max(0, min(1, share))) @@ -40,13 +47,19 @@ struct ProgressRing: View { } } -struct ContextRing: View { +public struct ContextRing: View { /// 0...1. `AgentContextEstimate.share(of:)` clamps before this is reached. let share: Double - var diameter: CGFloat = 11 - var lineWidth: CGFloat = 2 + public var diameter: CGFloat = 11 + public var lineWidth: CGFloat = 2 - var body: some View { + public init(share: Double, diameter: CGFloat = 11, lineWidth: CGFloat = 2) { + self.share = share + self.diameter = diameter + self.lineWidth = lineWidth + } + + public var body: some View { ProgressRing( share: share, tint: Self.tint(for: share), diameter: diameter, lineWidth: lineWidth @@ -60,7 +73,7 @@ struct ContextRing: View { /// next turn is likely to be the one that compacts. Below that it is just a /// quantity, and a quantity in a warning colour is a warning nobody asked /// for — which is also why the Plan chip beside it is grey and this is not. - static func tint(for share: Double) -> Color { + public static func tint(for share: Double) -> Color { switch share { case ..<0.75: .accentColor case ..<0.9: .orange @@ -75,24 +88,30 @@ struct ContextRing: View { /// and shortcuts. No amount of string padding can place text in that column — /// it only makes the menu wider. Keeping this in the model picker's popover /// gives `Spacer` an actual width and makes the value edge truthful. -struct ContextBreakdownView: View { - let estimate: AgentContextEstimate +public struct ContextBreakdownView: View { + public let estimate: AgentContextEstimate /// `nil` when the model's window is unknown, which is common for gateway /// providers. The parts still mean something; the share does not. - let contextWindow: Int? - let cachedInputTokens: Int + public let contextWindow: Int? + public let cachedInputTokens: Int - var body: some View { - VStack(alignment: .leading, spacing: Metrics.Space.m) { + public init(estimate: AgentContextEstimate, contextWindow: Int?, cachedInputTokens: Int) { + self.estimate = estimate + self.contextWindow = contextWindow + self.cachedInputTokens = cachedInputTokens + } + + public var body: some View { + VStack(alignment: .leading, spacing: 12) { Text("Context") .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) ForEach(Array(rows.enumerated()), id: \.offset) { _, row in - HStack(alignment: .firstTextBaseline, spacing: Metrics.Space.l) { + HStack(alignment: .firstTextBaseline, spacing: 16) { Text(row.name) .foregroundStyle(.secondary) - Spacer(minLength: Metrics.Space.l) + Spacer(minLength: 16) Text(row.value) .foregroundStyle(.secondary) .monospacedDigit() @@ -134,7 +153,7 @@ struct ContextBreakdownView: View { /// `62.4k`, not `62,431`. A gauge is read at a glance, and the last three /// digits of a token count have never changed anyone's mind. - static func compact(_ tokens: Int) -> String { + public static func compact(_ tokens: Int) -> String { switch tokens { case ..<1_000: "\(tokens)" case ..<10_000: String(format: "%.1fk", Double(tokens) / 1_000) diff --git a/Frameworks/TermindPresentation/AssistantTranscriptEntry.swift b/Frameworks/TermindPresentation/AssistantTranscriptEntry.swift new file mode 100644 index 00000000..36f538c7 --- /dev/null +++ b/Frameworks/TermindPresentation/AssistantTranscriptEntry.swift @@ -0,0 +1,99 @@ +import AgentKit +import Foundation +import TermindApplication + +/// Stable rows shared by the macOS and iOS ListViewKit transcript adapters. +nonisolated public struct AssistantTranscriptEntry: Identifiable, Hashable, Sendable { + public enum Kind: Hashable, Sendable { + case empty + case user + case assistant + case tool + case work + case command + case plan + case compaction + case compactionActivity + case interrupted + } + + public enum Content: Hashable, Sendable { + case empty + case user(ChatMessage, canEdit: Bool) + case assistant(ChatMessage, canFork: Bool) + case tool(AgentToolCard) + case work(AgentWorkGroup) + case command(SuggestedCommand) + case plan(AgentPlanCard) + case compaction(AgentCompactionMark) + case compactionActivity(AgentCompactionActivity) + case interrupted + } + + public let id: UUID + public let content: Content + public let hasLeadingSpacing: Bool + + public init(id: UUID, content: Content, hasLeadingSpacing: Bool = false) { + self.id = id + self.content = content + self.hasLeadingSpacing = hasLeadingSpacing + } + + private static let emptyID = UUID(uuidString: "00E48921-70E7-493A-9EB3-44727BC2145B")! + private static let interruptedID = UUID(uuidString: "FD8D34B6-E81F-4DC8-91E8-479AD9ACF5CB")! + + public static func items( + timeline: [TimelineItem], + isInterrupted: Bool, + canEdit: (UUID) -> Bool, + canFork: (UUID) -> Bool + ) -> [Self] { + if timeline.isEmpty { return [.init(id: emptyID, content: .empty)] } + var result = timeline.enumerated().map { index, item in + let content: Content = + switch item { + case .message(let message) where message.role == .user: + .user(message, canEdit: canEdit(message.id)) + case .message(let message): + .assistant(message, canFork: canFork(message.id)) + case .tool(let card): .tool(card) + case .work(let group): .work(group) + case .command(let command): .command(command) + case .plan(let card): .plan(card) + case .compaction(let mark): .compaction(mark) + case .compactionActivity(let activity): .compactionActivity(activity) + } + return Self(id: item.id, content: content, hasLeadingSpacing: index > 0) + } + if isInterrupted { + result.append(.init(id: interruptedID, content: .interrupted, hasLeadingSpacing: true)) + } + return result + } + + public var presentationIDs: Set { + var result: Set = [id] + guard case .work(let group) = content else { return result } + for item in group.items { + result.insert(item.id) + if case .tools(let batch) = item { result.formUnion(batch.cards.map(\.id)) } + } + return result + } + + public var kind: Kind { + switch content { + case .empty: .empty + case .user: .user + case .assistant: .assistant + case .tool: .tool + case .work: .work + case .command: .command + case .plan: .plan + case .compaction: .compaction + case .compactionActivity: .compactionActivity + case .interrupted: .interrupted + } + } +} diff --git a/Memory/REFACTOR.md b/Memory/REFACTOR.md index 9619cba6..f70d844f 100644 --- a/Memory/REFACTOR.md +++ b/Memory/REFACTOR.md @@ -1084,9 +1084,9 @@ and chat content. Strict formatting and Chinese localization checks passed. **Estimated reduction:** Remove approximately 35 lines of duplicate role form logic and avoid a second 250-line skill library implementation. -## 2026-09-11 — Shared Assistant execution (in progress) +## 2026-09-11 — Shared Assistant execution (completed 2026-09-12) -Evidence: MobileAssistantModel registers no tools, while TFM/App/AppModel builds the complete runtime and ConversationModel owns its event and interaction queues. Move the existing model, tool adapters, import and speech services into their owning frameworks, then share runtime composition and conversation commands. Expected reduction: remove the mobile duplicate chat loop and avoid a second implementation of approximately 5,000 lines of Agent state/tool behavior. Verification pending macOS/iOS builds and regression suites. +Evidence: MobileAssistantModel registered no tools, while TFM/App/AppModel built the complete runtime and ConversationModel owned its event and interaction queues. The runtime, tool adapters, attachment and speech services now live in their owning frameworks; both apps consume the shared conversation projection, and both transcripts use ListViewKit. The macOS and iOS builds pass, Assistant/navigation tests pass, localization and format checks pass, and the full iOS run's only failure was an unrelated Metrics journey timing race that passed when its four-test suite was rerun. ## 2026-09-12 — Remove app-target framework forwarding aliases diff --git a/TFI/Features/AI/MobileAssistantComposer.swift b/TFI/Features/AI/MobileAssistantComposer.swift index ed93af4c..a807c9e1 100644 --- a/TFI/Features/AI/MobileAssistantComposer.swift +++ b/TFI/Features/AI/MobileAssistantComposer.swift @@ -4,6 +4,7 @@ import PhotosUI import SwiftUI import TermindApplication import TermindInfrastructure +import TermindPresentation struct MobileAssistantComposer: View { let pro: ProAccessController @@ -16,6 +17,7 @@ struct MobileAssistantComposer: View { @State private var photoTask: Task? @State private var speechDraft = "" @State private var mcp = false + @State private var contextDetails = false @State private var isSlashPanelDismissed = false @State private var slashPanelHeight: CGFloat = 0 @FocusState private var focused: Bool @@ -25,64 +27,7 @@ struct MobileAssistantComposer: View { var body: some View { VStack(alignment: .leading, spacing: 10) { - attachmentRow - if recording { - HStack { - if model.speech.isRequestingAuthorization || model.speech.phase == .transcribing { - ProgressView() - } else { - Image(systemName: "waveform").symbolEffect(.variableColor) - Text("Recording…") - } - Spacer() - Button("Cancel", systemImage: "xmark") { model.speech.cancel(ownerID: conversation.id) } - Button("Done", systemImage: "checkmark") { model.speech.stop(ownerID: conversation.id) } - .disabled(model.speech.phase != .recording) - } - } - HStack(alignment: .bottom, spacing: 12) { - Menu { - Button("Camera", systemImage: "camera") { openCamera() } - .disabled(!UIImagePickerController.isSourceTypeAvailable(.camera)) - Button("Photos", systemImage: "photo") { photos = true } - Button("Files", systemImage: "doc") { files = true } - Divider() - Button("Voice Input", systemImage: "mic") { startSpeech() } - .disabled(recording) - } label: { - Image(systemName: "plus").font(.title3).frame(minWidth: 44, minHeight: 44) - }.accessibilityLabel("Add Attachment") - TextField( - "Ask anything…", text: Binding(get: { composer.draft }, set: { composer.draft = $0 }), - axis: .vertical - ) - .lineLimit(1...6).focused($focused) - .padding(.vertical, 10).padding(.horizontal, 12) - .background(.quaternary, in: RoundedRectangle(cornerRadius: 18)) - .disabled(recording) - VStack(spacing: 4) { - if conversation.isStreaming { - Button { - conversation.stop() - } label: { - Label("Stop", systemImage: "stop.circle.fill").labelStyle(.iconOnly).frame( - minWidth: 44, minHeight: 44 - ).contentShape(.rect) - } - .labelStyle(.iconOnly).font(.title2) - } - Button { - send() - } label: { - Label("Send", systemImage: "arrow.up.circle.fill").labelStyle(.iconOnly).frame( - minWidth: 44, minHeight: 44 - ).contentShape(.rect) - } - .labelStyle(.iconOnly).font(.title2) - .disabled(!canSend || recording) - }.frame(minWidth: 44, minHeight: 44) - } - controls + composerSurface if let error = composer.attachmentImportError { HStack(alignment: .top) { Text(error).font(.caption).foregroundStyle(.red) @@ -101,36 +46,7 @@ struct MobileAssistantComposer: View { } } } - .overlay(alignment: .top) { - if !slashCompletion.isEmpty, !isSlashPanelDismissed { - MobileSlashCommandPanel( - completion: slashCompletion, - contextShare: contextShare, - hasOptions: { !slashOptions($0).isEmpty }, - trailingText: slashTrailingText, - isCurrent: isCurrentSlashOption, - onBack: { - composer.draft = "/" - focused = true - }, - onClose: { - isSlashPanelDismissed = true - focused = true - }, - onSelect: acceptSlashRow - ) - .onGeometryChange(for: CGFloat.self) { proxy in - proxy.size.height - } action: { height in - slashPanelHeight = height - } - .offset(y: -(slashPanelHeight + 8)) - .transition(.opacity.combined(with: .move(edge: .bottom))) - .zIndex(20) - } - } - .animation(.snappy(duration: 0.2), value: slashCompletion.rows.map(\.id)) - .onChange(of: composer.draft) { _, _ in isSlashPanelDismissed = false } + .sheet(isPresented: $contextDetails) { contextDetailsSheet } .fileImporter( isPresented: $files, allowedContentTypes: ContextAttachmentImporter.supportedContentTypes, allowsMultipleSelection: true @@ -186,6 +102,218 @@ struct MobileAssistantComposer: View { } } + private var composerSurface: some View { + VStack(alignment: .leading, spacing: 8) { + if !composer.attachments.isEmpty || !composer.pendingAttachments.isEmpty { + attachmentRow + .transition(.opacity.combined(with: .move(edge: .bottom))) + } + TextField( + "Ask anything…", text: Binding(get: { composer.draft }, set: { composer.draft = $0 }), + axis: .vertical + ) + .lineLimit(1...6) + .focused($focused) + .disabled(recording) + .padding(.horizontal, 4) + .padding(.top, 4) + + if recording { recordingControls } else { standardControls } + } + .padding(12) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 26, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 26, style: .continuous) + .stroke(.primary.opacity(0.08), lineWidth: 1) + .allowsHitTesting(false) + } + .shadow(color: .black.opacity(0.08), radius: 16, y: 6) + .overlay(alignment: .top) { + if !slashCompletion.isEmpty, !isSlashPanelDismissed { + MobileSlashCommandPanel( + completion: slashCompletion, + contextShare: contextShare, + hasOptions: { !slashOptions($0).isEmpty }, + trailingText: slashTrailingText, + isCurrent: isCurrentSlashOption, + onBack: { + composer.draft = "/" + focused = true + }, + onClose: { + isSlashPanelDismissed = true + focused = true + }, + onSelect: acceptSlashRow + ) + .onGeometryChange(for: CGFloat.self) { proxy in + proxy.size.height + } action: { height in + slashPanelHeight = height + } + .offset(y: -(slashPanelHeight + 8)) + .transition(.opacity.combined(with: .move(edge: .bottom))) + .zIndex(20) + } + } + .animation(.snappy(duration: 0.2), value: slashCompletion.rows.map(\.id)) + .onChange(of: composer.draft) { _, _ in isSlashPanelDismissed = false } + } + + private var standardControls: some View { + HStack(spacing: 2) { + addMenu + permissionMenu + badges + Spacer(minLength: 4) + contextButton + microphoneButton + primaryButton + } + .frame(minHeight: 48) + } + + private var recordingControls: some View { + HStack(spacing: 8) { + Button { + composer.draft = speechDraft + model.speech.cancel(ownerID: conversation.id) + focused = true + } label: { + Label("Cancel", systemImage: "xmark").labelStyle(.iconOnly) + .frame(width: 48, height: 48).contentShape(.circle) + } + if model.speech.isRequestingAuthorization || model.speech.phase == .transcribing { + ProgressView().frame(maxWidth: .infinity) + } else { + HStack(spacing: 8) { + Image(systemName: "waveform").symbolEffect(.variableColor) + Text("Recording…").font(.callout).foregroundStyle(.secondary) + }.frame(maxWidth: .infinity) + } + Button { + model.speech.stop(ownerID: conversation.id) + } label: { + Label("Done", systemImage: "stop.fill").labelStyle(.iconOnly) + .frame(width: 48, height: 48).contentShape(.circle) + } + .disabled(model.speech.phase != .recording) + } + .frame(minHeight: 48) + } + + private var addMenu: some View { + Menu { + Button("Camera", systemImage: "camera") { openCamera() } + .disabled(!UIImagePickerController.isSourceTypeAvailable(.camera)) + Button("Photos", systemImage: "photo") { photos = true } + Button("Files", systemImage: "doc") { files = true } + Divider() + Button("Voice Input", systemImage: "mic") { startSpeech() }.disabled(recording) + } label: { + Image(systemName: "plus").font(.title3.weight(.medium)) + .foregroundStyle(.primary) + .frame(width: 48, height: 48).contentShape(.circle) + } + .accessibilityLabel("Add Attachment") + } + + private var permissionMenu: some View { + Menu { + Picker( + "Permissions", + selection: Binding( + get: { model.configuration.resolve(conversation: conversation.permissionMode) }, + set: { + conversation.permissionMode = $0 + model.configuration.select($0) + }) + ) { + ForEach(AgentPermissionMode.allCases) { mode in Text(mode.displayName).tag(mode) } + } + } label: { + Image(systemName: model.configuration.resolve(conversation: conversation.permissionMode).symbol) + .font(.title3) + .foregroundStyle( + model.configuration.resolve(conversation: conversation.permissionMode) == .fullAccess + ? AnyShapeStyle(.red) : AnyShapeStyle(.secondary) + ) + .frame(width: 48, height: 48).contentShape(.circle) + } + .accessibilityLabel("Permissions") + } + + private var contextButton: some View { + Button { + contextDetails = true + } label: { + ContextRing(share: contextShare ?? 0, diameter: 28, lineWidth: 4) + .frame(width: 48, height: 48).contentShape(.circle) + } + .accessibilityLabel("Context Tokens") + .accessibilityValue(Text(conversation.contextEstimate.total, format: .number)) + } + + private var microphoneButton: some View { + Button(action: startSpeech) { + Label("Voice Input", systemImage: "mic").labelStyle(.iconOnly) + .font(.system(size: 25, weight: .medium)).foregroundStyle(.primary) + .frame(width: 48, height: 48).contentShape(.circle) + } + .disabled(!model.speech.canStart(for: conversation.id)) + } + + @ViewBuilder private var primaryButton: some View { + if conversation.isStreaming && !canSend { + Button(action: conversation.stop) { + Label("Stop", systemImage: "stop.circle.fill").labelStyle(.iconOnly) + .font(.system(size: 34, weight: .semibold)).foregroundStyle(.red) + .frame(width: 48, height: 48).contentShape(.circle) + } + } else { + Button(action: send) { + Label("Send", systemImage: "arrow.up.circle.fill").labelStyle(.iconOnly) + .font(.system(size: 34, weight: .semibold)).frame(width: 48, height: 48).contentShape(.circle) + } + .disabled(!canSend) + } + } + + private var contextDetailsSheet: some View { + NavigationStack { + Form { + if conversation.contextEstimate.total > 0 { + Section { + ContextBreakdownView( + estimate: conversation.contextEstimate, + contextWindow: resolved?.model.contextLength, + cachedInputTokens: conversation.usage?.cachedInputTokens ?? 0 + ) + } + } + Section("Model") { modelPicker } + if let reasoningResolution { + Section("Reasoning") { + Picker("Reasoning", selection: $conversation.reasoningEffort) { + ForEach(reasoningResolution.supported, id: \.self) { effort in + Text(effort.displayName).tag(effort) + } + } + .pickerStyle(.inline) + } + } + } + .navigationTitle("Context") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { contextDetails = false } + } + } + } + .mobileSheetPresentation(compact: true) + } + private var canSend: Bool { composer.pendingAttachments.isEmpty && !conversation.isCompacting && (!composer.draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty @@ -194,39 +322,52 @@ struct MobileAssistantComposer: View { private var attachmentRow: some View { ScrollView(.horizontal) { - HStack { + HStack(spacing: 6) { ForEach(composer.attachments) { chip in - HStack { - if let image = chip.image, let ui = UIImage(data: image.data) { - Image(uiImage: ui).resizable().scaledToFill().frame(width: 36, height: 36).clipped() - } else { - Image(systemName: chip.kind.symbol) + Button { + conversation.detach(chip.id) + } label: { + HStack(spacing: 6) { + if let image = chip.image, let ui = UIImage(data: image.data) { + Image(uiImage: ui).resizable().scaledToFill() + .frame(width: 24, height: 24).clipShape(.rect(cornerRadius: 6)) + } else { + Image(systemName: chip.kind.symbol).font(.caption) + } + Text(chip.label).font(.caption).lineLimit(1) + Image(systemName: "xmark.circle.fill") + .font(.caption.weight(.semibold)).foregroundStyle(Color.primary.opacity(0.55)) } - Text(chip.label).font(.caption).lineLimit(1) - Button { - conversation.detach(chip.id) - } label: { - Label("Remove", systemImage: "xmark.circle.fill").labelStyle(.iconOnly).frame( - minWidth: 44, minHeight: 44 - ).contentShape(.rect) - }.labelStyle(.iconOnly) - }.padding(6).background(.quaternary, in: RoundedRectangle(cornerRadius: 10)) + .padding(.horizontal, 10).padding(.vertical, 5) + .frame(minHeight: 44) + .background(Color.primary.opacity(0.08), in: .capsule) + .overlay { Capsule().stroke(Color.primary.opacity(0.08), lineWidth: 1) } + .contentShape(.capsule) + } + .buttonStyle(.plain) + .accessibilityLabel(Text("Remove \(chip.label)")) } ForEach(composer.pendingAttachments) { pending in - HStack { - ProgressView().controlSize(.small) - Text(pending.label).font(.caption) - Button { - conversation.cancelAttachmentImport(pending.id) - } label: { - Label("Cancel", systemImage: "xmark").labelStyle(.iconOnly).frame( - minWidth: 44, minHeight: 44 - ).contentShape(.rect) - }.labelStyle(.iconOnly) + Button { + conversation.cancelAttachmentImport(pending.id) + } label: { + HStack(spacing: 6) { + ProgressView().controlSize(.small) + Text(pending.label).font(.caption).lineLimit(1) + Image(systemName: "xmark.circle.fill") + .font(.caption.weight(.semibold)).foregroundStyle(Color.primary.opacity(0.55)) + } + .padding(.horizontal, 10).padding(.vertical, 5) + .frame(minHeight: 44) + .background(Color.primary.opacity(0.08), in: .capsule) + .overlay { Capsule().stroke(Color.primary.opacity(0.08), lineWidth: 1) } + .contentShape(.capsule) } + .buttonStyle(.plain) + .accessibilityLabel(Text("Cancel importing \(pending.label)")) } } - }.fixedSize(horizontal: false, vertical: true) + }.scrollIndicators(.hidden).fixedSize(horizontal: false, vertical: true) } private var controls: some View { @@ -313,9 +454,11 @@ struct MobileAssistantComposer: View { @ViewBuilder private var badges: some View { if conversation.isPlanning { Button("Plan", systemImage: "lightbulb.max") { conversation.isPlanning = false } + .buttonStyle(.plain).foregroundStyle(.secondary) } if let skill = composer.armedSkillName { Button(skill, systemImage: "book.closed") { composer.armedSkillName = nil } + .buttonStyle(.plain).foregroundStyle(.secondary) } } @@ -481,7 +624,12 @@ struct MobileAssistantComposer: View { } private func localized(_ resource: String.LocalizationValue) -> String { - String(localized: resource, locale: locale) + let candidates = [locale.identifier, locale.language.languageCode?.identifier].compactMap { $0 } + let bundle = + candidates.lazy.compactMap { identifier in + Bundle.main.path(forResource: identifier, ofType: "lproj").flatMap(Bundle.init(path:)) + }.first ?? .main + return String(localized: resource, bundle: bundle, locale: locale) } private func acceptSlashRow(_ row: ChatSlashCommandCompletion.Row) { diff --git a/TFI/Features/AI/MobileAssistantConversation.swift b/TFI/Features/AI/MobileAssistantConversation.swift index de020d78..1cfdefd8 100644 --- a/TFI/Features/AI/MobileAssistantConversation.swift +++ b/TFI/Features/AI/MobileAssistantConversation.swift @@ -16,36 +16,19 @@ struct MobileAssistantConversation: View { @State private var preview: URL? @State private var previewDirectory = FileManager.default.temporaryDirectory.appending( path: "Assistant-Previews/\(UUID().uuidString)") - @State private var followsBottom = true var body: some View { - ScrollViewReader { proxy in - ScrollView { - LazyVStack(alignment: .leading, spacing: 20) { - if conversation.isEmpty { - ContentUnavailableView("Ask anything…", systemImage: "sparkles") - } - ForEach(conversation.timeline) { item in timelineRow(item) } - if !conversation.tasks.isEmpty { - DisclosureGroup("Tasks") { - ForEach(conversation.tasks) { task in - Label( - task.title, - systemImage: task.status == .completed ? "checkmark.circle.fill" : "circle") - } - } - } - Color.clear.frame(height: 1).id("bottom") - .onAppear { followsBottom = true }.onDisappear { followsBottom = false } - }.padding().frame(maxWidth: 850).frame(maxWidth: .infinity) - } - .scrollDismissesKeyboard(.interactively) - .onChange(of: conversation.messages.last?.text) { _, _ in - if followsBottom { proxy.scrollTo("bottom", anchor: .bottom) } - } - .onChange(of: conversation.timeline.count) { _, _ in - if followsBottom { proxy.scrollTo("bottom", anchor: .bottom) } - } + MobileNativeList( + items: transcriptEntries, + estimatedRowHeight: 132, + refreshID: transcriptRefreshID, + followsBottom: true + ) { entry in + transcriptRow(entry) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .frame(maxWidth: 850) + .frame(maxWidth: .infinity) } .safeAreaInset(edge: .bottom, spacing: 0) { VStack(spacing: 8) { @@ -55,6 +38,7 @@ struct MobileAssistantConversation: View { Task { await model.archive(conversation, archived: false) } } } else { + if !conversation.tasks.isEmpty { taskList } MobileAssistantInteraction(conversation: conversation, hosts: app.library) MobileAssistantComposer(pro: app.pro, model: model, conversation: conversation) } @@ -84,6 +68,54 @@ struct MobileAssistantConversation: View { } } + private var transcriptRefreshID: String { + "\(conversation.id.uuidString):\(conversation.isArchived):\(conversation.isStreaming):\(conversation.timeline.count)" + } + + private var transcriptEntries: [AssistantTranscriptEntry] { + AssistantTranscriptEntry.items( + timeline: conversation.timeline, + isInterrupted: conversation.runState == .interrupted, + canEdit: conversation.canEditUserMessage, + canFork: conversation.canForkAssistantMessage + ) + } + + private var taskList: some View { + DisclosureGroup("Tasks") { + ForEach(conversation.tasks) { task in + Label(task.title, systemImage: task.status == .completed ? "checkmark.circle.fill" : "circle") + .frame(maxWidth: .infinity, minHeight: 52, alignment: .leading) + } + } + .padding(12) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + } + + @ViewBuilder private func transcriptRow(_ entry: AssistantTranscriptEntry) -> some View { + switch entry.content { + case .empty: + ContentUnavailableView("Ask anything…", systemImage: "sparkles").frame(minHeight: 180) + case .user(let message, _), .assistant(let message, _): + messageRow(message) + case .tool(let card): + toolRow(card) + case .work(let group): + timelineRow(.work(group)) + case .command(let command): + timelineRow(.command(command)) + case .plan(let plan): + timelineRow(.plan(plan)) + case .compaction(let mark): + timelineRow(.compaction(mark)) + case .compactionActivity(let activity): + timelineRow(.compactionActivity(activity)) + case .interrupted: + Label("This run was interrupted and was not resumed.", systemImage: "exclamationmark.circle") + .font(.caption).foregroundStyle(.secondary) + } + } + @ViewBuilder private func timelineRow(_ item: TimelineItem) -> some View { switch item { case .message(let message): messageRow(message) @@ -127,13 +159,28 @@ struct MobileAssistantConversation: View { private func messageRow(_ message: ChatMessage) -> some View { VStack(alignment: .leading, spacing: 8) { - Text(message.role == .user ? "You" : "Assistant").font(.caption).foregroundStyle(.secondary) + if message.role != .user { + Text("Assistant").font(.caption).foregroundStyle(.secondary) + } if !message.reasoning.isEmpty { DisclosureGroup("Thinking") { ForEach(message.reasoning) { block in AssistantMessageBody(block.text).textSelection(.enabled) } } } - AssistantMessageBody(message.text).textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading) + if !message.text.isEmpty { + if message.role == .user { + HStack { + Spacer(minLength: 44) + AssistantMessageBody(message.text) + .textSelection(.enabled) + .padding(.horizontal, 14).padding(.vertical, 10) + .background(.tint.opacity(0.1), in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + } + } else { + AssistantMessageBody(message.text).textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + } ForEach(message.images) { image in Button { open(data: image.data, name: image.label) diff --git a/TFI/Features/AI/MobileSlashCommandPanel.swift b/TFI/Features/AI/MobileSlashCommandPanel.swift index d39455f3..dc054335 100644 --- a/TFI/Features/AI/MobileSlashCommandPanel.swift +++ b/TFI/Features/AI/MobileSlashCommandPanel.swift @@ -1,5 +1,6 @@ import SwiftUI import TermindApplication +import TermindPresentation /// The touch-first counterpart to the macOS slash palette. /// @@ -17,8 +18,8 @@ struct MobileSlashCommandPanel: View { var onClose: () -> Void var onSelect: (ChatSlashCommandCompletion.Row) -> Void - @ScaledMetric(relativeTo: .body) private var rowHeight = 78.0 - @ScaledMetric(relativeTo: .callout) private var headerHeight = 44.0 + @ScaledMetric(relativeTo: .body) private var rowHeight = 88.0 + @ScaledMetric(relativeTo: .callout) private var headerHeight = 56.0 var body: some View { VStack(spacing: 0) { @@ -53,7 +54,7 @@ struct MobileSlashCommandPanel: View { } private var panelHeight: CGFloat { - min(360, headerHeight + rowHeight * CGFloat(completion.rows.count)) + min(440, headerHeight + rowHeight * CGFloat(completion.rows.count)) } private var header: some View { @@ -62,7 +63,8 @@ struct MobileSlashCommandPanel: View { Button(action: onBack) { Label("Back", systemImage: "chevron.left") .labelStyle(.iconOnly) - .frame(width: 36, height: 36) + .foregroundStyle(.secondary) + .frame(width: 48, height: 48) .contentShape(.rect) } .accessibilityLabel(Text("Back")) @@ -80,7 +82,8 @@ struct MobileSlashCommandPanel: View { Button(action: onClose) { Label("Close", systemImage: "xmark") .labelStyle(.iconOnly) - .frame(width: 36, height: 36) + .foregroundStyle(.secondary) + .frame(width: 48, height: 48) .contentShape(.rect) } .accessibilityLabel(Text("Close")) @@ -93,38 +96,34 @@ struct MobileSlashCommandPanel: View { Button { onSelect(row) } label: { - HStack(spacing: 12) { + HStack(alignment: .top, spacing: 12) { commandIcon(for: row) .frame(width: 28, height: 28) - .foregroundStyle(.tint) rowLabels(row) .fixedSize(horizontal: false, vertical: true) - .layoutPriority(1) - - Spacer(minLength: 8) if isCurrent(row) { Image(systemName: "checkmark.circle.fill") .foregroundStyle(.tint) .accessibilityLabel(Text("Current")) + .fixedSize() } else if let trailing = trailingText(row) { Text(trailing) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) + .font(.caption).foregroundStyle(.secondary) + .lineLimit(1).truncationMode(.middle) + .fixedSize(horizontal: true, vertical: false) } if case .command(let command) = row, hasOptions(command) { Image(systemName: "chevron.right") - .font(.caption.weight(.semibold)) - .foregroundStyle(.tertiary) + .font(.caption.weight(.semibold)).foregroundStyle(.tertiary) + .frame(width: 24, height: 28) } } .padding(.horizontal, 12) .padding(.vertical, 8) - .frame(maxWidth: .infinity, minHeight: rowHeight, alignment: .leading) + .frame(maxWidth: .infinity, minHeight: rowHeight, maxHeight: rowHeight, alignment: .leading) .contentShape(.rect) } .buttonStyle(.plain) @@ -134,19 +133,21 @@ struct MobileSlashCommandPanel: View { private func rowLabels(_ row: ChatSlashCommandCompletion.Row) -> some View { switch row { case .command(let command): - Text(verbatim: "\(command.name) \(command.displayName)\n\(command.summary)") - .font(.callout) - .foregroundStyle(.primary) - .lineSpacing(2) - .lineLimit(3) - .frame(maxWidth: .infinity, alignment: .leading) + VStack(alignment: .leading, spacing: 5) { + Text(verbatim: command.displayName).font(.callout.weight(.medium)).lineLimit(1) + Text(verbatim: command.summary) + .font(.subheadline).foregroundStyle(Color.primary.opacity(0.62)) + .lineLimit(1).truncationMode(.tail) + } + .frame(maxWidth: .infinity, minHeight: 54, maxHeight: 54, alignment: .topLeading) case .option(_, let option): - Text(verbatim: option.detail.isEmpty ? option.label : "\(option.label)\n\(option.detail)") - .font(.callout) - .foregroundStyle(.primary) - .lineSpacing(2) - .lineLimit(3) - .frame(maxWidth: .infinity, alignment: .leading) + VStack(alignment: .leading, spacing: 5) { + Text(verbatim: option.label).font(.callout.weight(.medium)).lineLimit(1) + Text(verbatim: option.detail.isEmpty ? " " : option.detail) + .font(.subheadline).foregroundStyle(Color.primary.opacity(0.62)) + .lineLimit(1).truncationMode(.tail) + } + .frame(maxWidth: .infinity, minHeight: 54, maxHeight: 54, alignment: .topLeading) } } @@ -159,18 +160,9 @@ struct MobileSlashCommandPanel: View { } switch command.icon { case .symbol(let name): - Image(systemName: name) + Image(systemName: name).foregroundStyle(Color.primary.opacity(0.62)) case .contextRing: - ZStack { - Circle().stroke(.tertiary.opacity(0.35), lineWidth: 2) - if let contextShare { - Circle() - .trim(from: 0, to: max(0.025, contextShare)) - .stroke(.tint, style: StrokeStyle(lineWidth: 2, lineCap: .round)) - .rotationEffect(.degrees(-90)) - } - } - .frame(width: 18, height: 18) + ContextRing(share: contextShare ?? 0, diameter: 18, lineWidth: 2) } } diff --git a/TFI/Features/Assistant/MobileAssistantView.swift b/TFI/Features/Assistant/MobileAssistantView.swift index bc800b0d..7ff432c3 100644 --- a/TFI/Features/Assistant/MobileAssistantView.swift +++ b/TFI/Features/Assistant/MobileAssistantView.swift @@ -6,46 +6,113 @@ import TermindDomain struct MobileAssistantView: View { let app: MobileAppModel @Bindable var model: MobileAssistantModel - @Environment(\.mobileInterfaceIdiom) private var interfaceIdiom @State private var history = false + @State private var archive = false + @State private var renaming = false + @State private var title = "" + @State private var deleting = false var body: some View { - GeometryReader { geometry in - if interfaceIdiom == .pad && geometry.size.width >= 760 { - HStack(spacing: 0) { - MobileAssistantLibrary(model: model, selected: {}) - .frame(width: 280) + detail + .navigationTitle( + model.conversation.isEmpty ? String(localized: "Assistant") : model.conversation.derivedTitle + ) + .navigationBarTitleDisplayMode(.inline) + .toolbarTitleMenu { conversationTitleMenu } + .toolbar { + Button("New Conversation", systemImage: "square.and.pencil") { model.newConversation() }.labelStyle( + .iconOnly) + Menu { + Button("Conversations", systemImage: "clock") { history = true } + Button("Archived", systemImage: "archivebox") { archive = true } Divider() - detail + Button("Rename", systemImage: "pencil") { + title = model.conversation.derivedTitle + renaming = true + } + Button(model.conversation.isPinned ? "Unpin" : "Pin", systemImage: "pin") { + Task { await model.move(model.conversation, pinned: !model.conversation.isPinned) } + } + .disabled(model.conversation.isEmpty || model.conversation.isArchived) + Button("Archive", systemImage: "archivebox") { + Task { await model.archive(model.conversation, archived: true) } + } + .disabled(!canChangeHistory) + Button("Delete", systemImage: "trash", role: .destructive) { deleting = true } + .disabled(!canChangeHistory) + } label: { + MobileOverflowMenuLabel("More") } - } else { - detail } - } - .navigationTitle("Assistant") - .toolbar { - Button("Conversations", systemImage: "clock") { history = true }.labelStyle(.iconOnly) - Button("New Conversation", systemImage: "square.and.pencil") { model.newConversation() }.labelStyle( - .iconOnly) - } - .navigationDestination(isPresented: $history) { - MobileAssistantLibrary(model: model) { history = false } - .navigationTitle("Conversations") - } - .task { await model.load() } - .alert( - "Operation Failed", isPresented: Binding(get: { model.error != nil }, set: { if !$0 { model.error = nil } }) - ) { - Button("OK") { model.error = nil } - } message: { - Text(model.error ?? "") - } + .navigationDestination(isPresented: $history) { + MobileAssistantLibrary(model: model) { history = false } + .navigationTitle("Conversations") + } + .navigationDestination(isPresented: $archive) { + MobileAssistantLibrary(model: model, archived: true) { archive = false } + .navigationTitle("Archived") + } + .alert("Rename", isPresented: $renaming) { + TextField("Conversation Name", text: $title) + Button("Cancel", role: .cancel) {} + Button("Save") { Task { await model.rename(model.conversation, title: title) } } + } + .confirmationDialog("Delete Conversation", isPresented: $deleting, titleVisibility: .visible) { + Button("Delete", role: .destructive) { Task { await model.delete(model.conversation) } } + } message: { + Text("Permanently delete this conversation and its staged files?") + } + .task { await model.load() } + .alert( + "Operation Failed", + isPresented: Binding(get: { model.error != nil }, set: { if !$0 { model.error = nil } }) + ) { + Button("OK") { model.error = nil } + } message: { + Text(model.error ?? "") + } } private var detail: some View { MobileAssistantConversation(app: app, model: model, conversation: model.conversation) .id(model.conversation.id) } + + @ViewBuilder private var conversationTitleMenu: some View { + Button("New Conversation", systemImage: model.conversation.isEmpty ? "checkmark" : "square.and.pencil") { + model.newConversation() + } + if !model.library.pinnedConversations.isEmpty { + Section("Pinned") { + ForEach(model.library.pinnedConversations) { conversation in titleMenuButton(conversation) } + } + } + if !model.library.activeConversations.isEmpty { + Section("Conversations") { + ForEach(model.library.activeConversations) { conversation in titleMenuButton(conversation) } + } + } + Divider() + Button("Conversations", systemImage: "clock") { history = true } + Button("Archived", systemImage: "archivebox") { archive = true } + } + + private func titleMenuButton(_ conversation: ConversationModel) -> some View { + Button { + model.select(conversation) + } label: { + Label( + conversation.derivedTitle, + systemImage: model.conversation.id == conversation.id + ? "checkmark" : (conversation.isStreaming ? "ellipsis.circle" : "bubble.left") + ) + } + } + + private var canChangeHistory: Bool { + !model.conversation.isEmpty && !model.conversation.isStreaming && !model.conversation.isCompacting + && !model.conversation.isHistoryChanging + } } struct MobileAssistantLibrary: View { @@ -57,38 +124,21 @@ struct MobileAssistantLibrary: View { @State private var renaming: ConversationModel? @State private var title = "" @State private var deleting: ConversationModel? - @State private var editMode = EditMode.inactive + @State private var showingArchive = false var body: some View { - List { - if !archived { - Section { - Button("New Conversation", systemImage: "square.and.pencil") { - model.newConversation() - selected() - } - NavigationLink { - MobileAssistantLibrary(model: model, archived: true, selected: selected).navigationTitle( - "Archived") - } label: { - Label("Archived", systemImage: "archivebox") - Spacer() - Text(model.library.archivedConversations.count, format: .number).foregroundStyle(.secondary) - } - } - Section("Pinned") { rows(values.filter(\.isPinned)) } - Section("Conversations") { rows(values.filter { !$0.isPinned }) } - } else { - rows(values) - } - if values.isEmpty { Text(archived ? "No Archived Chats" : "No Conversations").foregroundStyle(.secondary) } + MobileNativeList( + items: listItems, estimatedRowHeight: 84, + refreshID: libraryRefreshID, + onSelect: selectItem, + onDelete: requestDelete + ) { item in + listRow(item) } .searchable(text: $query, prompt: "Search") - .environment(\.editMode, $editMode) - .toolbar { - Button(editMode.isEditing ? "Done" : "Edit", systemImage: editMode.isEditing ? "checkmark" : "pencil") { - withAnimation { editMode = editMode.isEditing ? .inactive : .active } - }.labelStyle(.iconOnly) + .navigationDestination(isPresented: $showingArchive) { + MobileAssistantLibrary(model: model, archived: true, selected: selected) + .navigationTitle("Archived") } .alert("Rename", isPresented: Binding(get: { renaming != nil }, set: { if !$0 { renaming = nil } })) { TextField("Conversation Name", text: $title) @@ -113,51 +163,103 @@ struct MobileAssistantLibrary: View { private var values: [ConversationModel] { model.library.search(query, scope: archived ? .archived : .active) } - @ViewBuilder private func rows(_ values: [ConversationModel]) -> some View { - ForEach(values) { conversation in - MobileActionCard { - model.select(conversation) - if archived { dismiss() } - selected() - } content: { - HStack(alignment: .top, spacing: 12) { + private var libraryRefreshID: String { + values.map { + "\($0.id.uuidString):\($0.derivedTitle):\($0.isPinned):\($0.isStreaming):\($0.updatedAt.timeIntervalSince1970)" + }.joined(separator: "|") + ":\(model.conversation.id.uuidString):\(query)" + } + + private var listItems: [MobileConversationListItem] { + var items: [MobileConversationListItem] = [] + if !archived && query.isEmpty { + items.append(.action(.new)) + items.append(.action(.archive(count: model.library.archivedConversations.count))) + } + let pinned = values.filter(\.isPinned) + let conversations = values.filter { !$0.isPinned } + if !pinned.isEmpty { + items.append(.section(id: "pinned", title: String(localized: "Pinned"))) + items.append(contentsOf: pinned.map(MobileConversationListItem.conversation)) + } + if !conversations.isEmpty { + if !archived { + items.append(.section(id: "conversations", title: String(localized: "Conversations"))) + } + items.append(contentsOf: conversations.map(MobileConversationListItem.conversation)) + } + if values.isEmpty { + items.append( + .empty(archived ? String(localized: "No Archived Chats") : String(localized: "No Conversations"))) + } + return items + } + + @ViewBuilder private func listRow(_ item: MobileConversationListItem) -> some View { + switch item.kind { + case .new: + Label("New Conversation", systemImage: "square.and.pencil") + .frame(maxWidth: .infinity, minHeight: 64, alignment: .leading).padding(.horizontal, 16) + case .archive(let count): + HStack { + Label("Archived", systemImage: "archivebox") + Spacer() + Text(count, format: .number).foregroundStyle(.secondary) + Image(systemName: "chevron.right").font(.caption).foregroundStyle(.tertiary) + } + .frame(minHeight: 64).padding(.horizontal, 16) + case .section(let title): + Text(verbatim: title).font(.headline).foregroundStyle(.secondary) + .frame(maxWidth: .infinity, minHeight: 52, alignment: .bottomLeading) + .padding(.horizontal, 16).padding(.bottom, 6) + case .conversation(let id): + if let conversation = model.library.conversation(id: id) { + HStack(alignment: .center, spacing: 12) { MobileCardIcon(symbol: conversation.isArchived ? "archivebox" : "bubble.left") - VStack(alignment: .leading, spacing: 6) { - Text(conversation.derivedTitle).font(.headline).foregroundStyle(.primary) - if conversation.isStreaming { Text("Working…").font(.caption).foregroundStyle(.secondary) } + VStack(alignment: .leading, spacing: 5) { + Text(conversation.derivedTitle).font(.headline).foregroundStyle(.primary).lineLimit(2) + if conversation.isStreaming { + Text("Working…").font(.caption).foregroundStyle(.secondary) + } } + Spacer(minLength: 8) if model.conversation.id == conversation.id { - Image(systemName: "checkmark").accessibilityHidden(true) + Image(systemName: "checkmark").foregroundStyle(.tint).accessibilityHidden(true) } } - } actions: { - actions(conversation) - } - .accessibilityAddTraits(model.conversation.id == conversation.id ? .isSelected : []) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0)) - .swipeActions(edge: .trailing) { - Button( - conversation.isArchived ? "Unarchive" : "Archive", - systemImage: conversation.isArchived ? "tray.and.arrow.up" : "archivebox" - ) { - Task { await model.archive(conversation, archived: !conversation.isArchived) } - }.disabled(conversation.isStreaming || conversation.isCompacting || conversation.isHistoryChanging) - Button("Delete", systemImage: "trash", role: .destructive) { deleting = conversation } - .disabled(conversation.isStreaming || conversation.isCompacting || conversation.isHistoryChanging) + .frame(maxWidth: .infinity, minHeight: 84, alignment: .leading) + .padding(.horizontal, 16) + .contentShape(.rect) + .contextMenu { actions(conversation) } + .accessibilityAddTraits(model.conversation.id == conversation.id ? .isSelected : []) } + case .empty(let title): + ContentUnavailableView(title, systemImage: archived ? "archivebox" : "bubble.left") + .frame(maxWidth: .infinity, minHeight: 180) } - .onMove { indices, destination in - guard query.isEmpty, !archived, let index = indices.first else { return } - let value = values[index] - var remaining = values - remaining.remove(at: index) - let target = min(max(0, destination - (destination > index ? 1 : 0)), remaining.count) - let next = target < remaining.count ? remaining[target].id : nil - Task { await model.move(value, pinned: value.isPinned, before: next) } + } + + private func selectItem(_ item: MobileConversationListItem) { + switch item.kind { + case .new: + model.newConversation() + selected() + case .archive: + showingArchive = true + case .conversation(let id): + guard let conversation = model.library.conversation(id: id) else { return } + model.select(conversation) + if archived { dismiss() } + selected() + case .section, .empty: + break } - .moveDisabled(archived || !query.isEmpty) + } + + private func requestDelete(_ item: MobileConversationListItem) { + guard case .conversation(let id) = item.kind, + let conversation = model.library.conversation(id: id) + else { return } + deleting = conversation } @ViewBuilder private func actions(_ conversation: ConversationModel) -> some View { @@ -180,3 +282,34 @@ struct MobileAssistantLibrary: View { .disabled(conversation.isStreaming || conversation.isCompacting || conversation.isHistoryChanging) } } + +nonisolated private struct MobileConversationListItem: Identifiable, Hashable, Sendable { + nonisolated enum Kind: Hashable, Sendable { + case new + case archive(count: Int) + case section(String) + case conversation(UUID) + case empty(String) + } + + let id: String + let kind: Kind + + static func action(_ action: Kind) -> Self { + switch action { + case .new: Self(id: "action-new", kind: action) + case .archive: Self(id: "action-archive", kind: action) + default: preconditionFailure("Only action rows can use action(_:)") + } + } + + static func section(id: String, title: String) -> Self { + Self(id: "section-\(id)", kind: .section(title)) + } + + @MainActor static func conversation(_ conversation: ConversationModel) -> Self { + Self(id: "conversation-\(conversation.id.uuidString)", kind: .conversation(conversation.id)) + } + + static func empty(_ title: String) -> Self { Self(id: "empty", kind: .empty(title)) } +} diff --git a/TFI/UI/Components/MobileNativeList.swift b/TFI/UI/Components/MobileNativeList.swift index 33b733b7..bd83d7ff 100644 --- a/TFI/UI/Components/MobileNativeList.swift +++ b/TFI/UI/Components/MobileNativeList.swift @@ -12,6 +12,7 @@ where Item: Identifiable & Hashable & SendableMetatype, RowContent: View { let onSelect: ((Item) -> Void)? let onDelete: ((Item) -> Void)? let onRefresh: (() async -> Void)? + let followsBottom: Bool @ViewBuilder let rowContent: (Item) -> RowContent @Environment(\.colorScheme) private var colorScheme @@ -23,6 +24,7 @@ where Item: Identifiable & Hashable & SendableMetatype, RowContent: View { onSelect: ((Item) -> Void)? = nil, onDelete: ((Item) -> Void)? = nil, onRefresh: (() async -> Void)? = nil, + followsBottom: Bool = false, @ViewBuilder rowContent: @escaping (Item) -> RowContent ) { self.items = items @@ -31,6 +33,7 @@ where Item: Identifiable & Hashable & SendableMetatype, RowContent: View { self.onSelect = onSelect self.onDelete = onDelete self.onRefresh = onRefresh + self.followsBottom = followsBottom self.rowContent = rowContent } @@ -64,6 +67,7 @@ where Item: Identifiable & Hashable & SendableMetatype, RowContent: View { onSelect: onSelect, onDelete: onDelete, onRefresh: onRefresh, + followsBottom: followsBottom, refreshID: refreshID, locale: locale, colorScheme: colorScheme, @@ -97,9 +101,14 @@ where Item: Identifiable & Hashable & SendableMetatype, RowContent: View { listView: ListView, items newItems: [Item], renderer: @escaping Renderer, onSelect: ((Item) -> Void)?, onDelete: ((Item) -> Void)?, onRefresh: (() async -> Void)?, + followsBottom: Bool, refreshID newRefreshID: AnyHashable, locale: Locale, colorScheme: ColorScheme, dynamicTypeSize: DynamicTypeSize ) { + let wasAtBottom = + items.isEmpty + || listView.contentSize.height - listView.bounds.height + - listView.contentOffset.y <= 48 let environmentChanged = self.locale != locale || self.colorScheme != colorScheme || self.dynamicTypeSize != dynamicTypeSize || refreshID != newRefreshID @@ -123,6 +132,9 @@ where Item: Identifiable & Hashable & SendableMetatype, RowContent: View { if environmentChanged { listView.reloadData() } } items = newItems + if followsBottom, wasAtBottom { + Task { @MainActor in listView.scrollToBottom(animated: !self.items.isEmpty) } + } } func cancelRefresh() { diff --git a/TFITests/MobileAssistantTests.swift b/TFITests/MobileAssistantTests.swift index 7ff23eed..9ca97f2c 100644 --- a/TFITests/MobileAssistantTests.swift +++ b/TFITests/MobileAssistantTests.swift @@ -3,6 +3,7 @@ import Foundation import TermindApplication import TermindDomain import TermindInfrastructure +import TermindPresentation import TermindRemoteInfrastructure import Testing @@ -10,6 +11,25 @@ import Testing @Suite @MainActor struct MobileAssistantTests { + @Test func sharedTranscriptProjectionKeepsStableKindsAndEditCapabilities() { + let user = ChatMessage(role: .user, text: "Inspect") + let assistant = ChatMessage(role: .assistant, text: "Done") + let entries = AssistantTranscriptEntry.items( + timeline: [.message(user), .message(assistant)], + isInterrupted: true, + canEdit: { $0 == user.id }, + canFork: { $0 == assistant.id } + ) + + #expect(entries.map(\.kind) == [.user, .assistant, .interrupted]) + if case .user(_, let canEdit) = entries[0].content { #expect(canEdit) } else { Issue.record("Missing user") } + if case .assistant(_, let canFork) = entries[1].content { + #expect(canFork) + } else { + Issue.record("Missing assistant") + } + } + @Test func changingConversationsPreservesTheirDraftsAndArchiveNavigation() async throws { let app = try MobileAppModel(inMemory: true) let model = app.assistant @@ -33,7 +53,6 @@ struct MobileAssistantTests { let composer = MobileAssistantComposer(pro: app.pro, model: app.assistant, conversation: conversation) let compact = try #require(composer.availableCommands.first { $0.id == "compact" }) #expect(compact.name == "/compact") - #expect(compact.summary == String(localized: "Summarize older turns to free context")) #expect(!compact.summary.isEmpty) } diff --git a/TFITests/MobileNavigationParityTests.swift b/TFITests/MobileNavigationParityTests.swift index df19a600..d41dc591 100644 --- a/TFITests/MobileNavigationParityTests.swift +++ b/TFITests/MobileNavigationParityTests.swift @@ -7,6 +7,38 @@ import Testing @Suite @MainActor struct MobileNavigationParityTests { + @Test func assistantUsesTopTitleSwitchingAndListViewKitWithoutAnIPadConversationSidebar() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + let assistant = try String( + contentsOf: root.appending(path: "TFI/Features/Assistant/MobileAssistantView.swift"), encoding: .utf8) + let transcript = try String( + contentsOf: root.appending(path: "TFI/Features/AI/MobileAssistantConversation.swift"), encoding: .utf8) + + #expect(assistant.contains(".toolbarTitleMenu")) + #expect(assistant.contains("MobileNativeList(")) + #expect(!assistant.contains("geometry.size.width >= 760")) + #expect(!assistant.contains("MobileAssistantLibrary(model: model, selected: {})")) + #expect(transcript.contains("MobileNativeList(")) + #expect(transcript.contains("AssistantTranscriptEntry.items(")) + #expect(!transcript.contains("LazyVStack")) + } + + @Test func assistantSlashRowsUseTwoTouchSizedLines() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + let slash = try String( + contentsOf: root.appending(path: "TFI/Features/AI/MobileSlashCommandPanel.swift"), encoding: .utf8) + + #expect(slash.contains("rowHeight = 88.0")) + #expect(slash.contains("headerHeight = 56.0")) + #expect(slash.contains("Text(verbatim: command.summary)")) + #expect(slash.contains("Text(verbatim: option.detail.isEmpty ? \" \" : option.detail)")) + #expect(slash.contains(".lineLimit(1).truncationMode(.tail)")) + } + @Test func vaultReplacesWelcomeAndMetricsRemainsBelowItOnIPad() { #expect(MobileTab.allCases == [.vault, .workspaces, .assistant, .settings]) #expect(MobileDestination.allCases == [.vault, .metrics, .workspaces, .assistant, .settings]) diff --git a/TFM/Features/AI/ConversationTranscriptView.swift b/TFM/Features/AI/ConversationTranscriptView.swift index 22a03676..19ee3758 100644 --- a/TFM/Features/AI/ConversationTranscriptView.swift +++ b/TFM/Features/AI/ConversationTranscriptView.swift @@ -2,6 +2,7 @@ import AgentKit import SwiftUI import TermindApplication import TermindDomain +import TermindPresentation import struct TermindDomain.Host diff --git a/TFM/Features/AI/ConversationViews.swift b/TFM/Features/AI/ConversationViews.swift index fa850dce..50e06178 100644 --- a/TFM/Features/AI/ConversationViews.swift +++ b/TFM/Features/AI/ConversationViews.swift @@ -4,6 +4,7 @@ import SwiftUI import TermindApplication import TermindDomain import TermindInfrastructure +import TermindPresentation import UniformTypeIdentifiers /// Reports composition text that SwiftUI's String binding has not committed. diff --git a/TFM/Features/AI/SlashCommandMenuView.swift b/TFM/Features/AI/SlashCommandMenuView.swift index 68e392df..cc95b47a 100644 --- a/TFM/Features/AI/SlashCommandMenuView.swift +++ b/TFM/Features/AI/SlashCommandMenuView.swift @@ -1,5 +1,6 @@ import SwiftUI import TermindApplication +import TermindPresentation /// The palette that opens when a draft starts with `/`. /// diff --git a/TFM/Features/AI/TranscriptListView.swift b/TFM/Features/AI/TranscriptListView.swift index cf04a14f..96a0666f 100644 --- a/TFM/Features/AI/TranscriptListView.swift +++ b/TFM/Features/AI/TranscriptListView.swift @@ -4,102 +4,9 @@ import ListViewKit import Observation import SwiftUI import TermindApplication +import TermindPresentation -nonisolated struct TranscriptEntry: Identifiable, Hashable, Sendable { - enum Kind: Hashable, Sendable { - case empty - case user - case assistant - case tool - case work - case command - case plan - case compaction - case compactionActivity - case interrupted - } - - nonisolated enum Content: Hashable, Sendable { - case empty - case user(ChatMessage, canEdit: Bool) - case assistant(ChatMessage, canFork: Bool) - case tool(AgentToolCard) - case work(AgentWorkGroup) - case command(SuggestedCommand) - case plan(AgentPlanCard) - case compaction(AgentCompactionMark) - case compactionActivity(AgentCompactionActivity) - case interrupted - } - - let id: UUID - let content: Content - let hasLeadingSpacing: Bool - - init(id: UUID, content: Content, hasLeadingSpacing: Bool = false) { - self.id = id - self.content = content - self.hasLeadingSpacing = hasLeadingSpacing - } - - private static let emptyID = UUID(uuidString: "00E48921-70E7-493A-9EB3-44727BC2145B")! - private static let interruptedID = UUID(uuidString: "FD8D34B6-E81F-4DC8-91E8-479AD9ACF5CB")! - - static func items( - timeline: [TimelineItem], - isInterrupted: Bool, - canEdit: (UUID) -> Bool, - canFork: (UUID) -> Bool - ) -> [Self] { - if timeline.isEmpty { return [.init(id: emptyID, content: .empty)] } - var result = timeline.enumerated().map { index, item in - let content: Content = - switch item { - case .message(let message) where message.role == .user: - .user(message, canEdit: canEdit(message.id)) - case .message(let message): - .assistant(message, canFork: canFork(message.id)) - case .tool(let card): .tool(card) - case .work(let group): .work(group) - case .command(let command): .command(command) - case .plan(let card): .plan(card) - case .compaction(let mark): .compaction(mark) - case .compactionActivity(let activity): .compactionActivity(activity) - } - return Self(id: item.id, content: content, hasLeadingSpacing: index > 0) - } - if isInterrupted { - result.append(.init(id: interruptedID, content: .interrupted, hasLeadingSpacing: true)) - } - return result - } - - var presentationIDs: Set { - var result: Set = [id] - guard case .work(let group) = content else { return result } - for item in group.items { - result.insert(item.id) - if case .tools(let batch) = item { result.formUnion(batch.cards.map(\.id)) } - } - return result - } - - var kind: Kind { - switch content { - case .empty: .empty - case .user: .user - case .assistant: .assistant - case .tool: .tool - case .work: .work - case .command: .command - case .plan: .plan - case .compaction: .compaction - case .compactionActivity: .compactionActivity - case .interrupted: .interrupted - } - } - -} +typealias TranscriptEntry = AssistantTranscriptEntry @MainActor @Observable diff --git a/TFM/Features/AI/TranscriptNativeRows.swift b/TFM/Features/AI/TranscriptNativeRows.swift index 4a2be86d..15803a86 100644 --- a/TFM/Features/AI/TranscriptNativeRows.swift +++ b/TFM/Features/AI/TranscriptNativeRows.swift @@ -5,6 +5,7 @@ import MarkdownView import SwiftUI import TermindApplication import TermindDomain +import TermindPresentation @MainActor final class TranscriptMarkdownCache {