Align iOS Assistant with macOS

This commit is contained in:
Kaede Akino committed 2026-09-12 11:34:57 +08:00
1 parent 352be589c8
commit e98e038998
15 files changed
+807 -395

No files matched your search

@@ -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)
@@ -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<UUID> {
var result: Set<UUID> = [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
}
}
}
+2 -2
View File
@@ -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
+263 -115
View File
@@ -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<Void, Never>?
@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) {
@@ -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)
+32 -40
View File
@@ -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)
}
}
+226 -93
View File
@@ -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)) }
}
+12
View File
@@ -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<Item>, 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() {
+20 -1
View File
@@ -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)
}
@@ -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])
@@ -2,6 +2,7 @@ import AgentKit
import SwiftUI
import TermindApplication
import TermindDomain
import TermindPresentation
import struct TermindDomain.Host
+1
View File
@@ -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.
@@ -1,5 +1,6 @@
import SwiftUI
import TermindApplication
import TermindPresentation
/// The palette that opens when a draft starts with `/`.
///
+2 -95
View File
@@ -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<UUID> {
var result: Set<UUID> = [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
@@ -5,6 +5,7 @@ import MarkdownView
import SwiftUI
import TermindApplication
import TermindDomain
import TermindPresentation
@MainActor
final class TranscriptMarkdownCache {