From dc7e5ca851607ee965a5df7052e73061c7e20d19 Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Sun, 13 Sep 2026 16:36:45 +0800 Subject: [PATCH] Refine mobile Assistant interaction layout --- .../AI/MobileAssistantConversation.swift | 37 +++++-------- .../Workspaces/MobileWorkspaceListView.swift | 12 ++-- TFITests/MobileNavigationParityTests.swift | 55 ++++++++++++++++--- .../AI/ConversationTranscriptView.swift | 25 +-------- 4 files changed, 67 insertions(+), 62 deletions(-) diff --git a/TFI/Features/AI/MobileAssistantConversation.swift b/TFI/Features/AI/MobileAssistantConversation.swift index 146476b6..3a63f227 100644 --- a/TFI/Features/AI/MobileAssistantConversation.swift +++ b/TFI/Features/AI/MobileAssistantConversation.swift @@ -381,25 +381,11 @@ private struct MobileAssistantSteeringQueue: View { @State private var rowsHeight: CGFloat = 0 - private var deliveryStatus: LocalizedStringKey { - switch conversation.runState { - case .waitingForApproval, .waitingForUser: "Waiting for response" - default: "Next step" - } - } - var body: some View { VStack(alignment: .leading, spacing: 12) { - HStack(spacing: 8) { - Label("Steering", systemImage: "arrow.turn.down.right") - .font(.caption.weight(.medium)) - .foregroundStyle(.secondary) - Spacer(minLength: 8) - Text(deliveryStatus) - .font(.caption2) - .foregroundStyle(.tertiary) - .lineLimit(1) - } + Label("Steering", systemImage: "arrow.turn.down.right") + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) ScrollView { VStack(alignment: .leading, spacing: 0) { @@ -442,8 +428,8 @@ private struct MobileAssistantSteeringQueue: View { } Image(systemName: "xmark") - .font(.system(size: 8, weight: .bold)) - .frame(width: 20, height: 20) + .font(.caption.weight(.bold)) + .frame(width: 28, height: 28) .foregroundStyle(.secondary) } .padding(.horizontal, 8) @@ -558,6 +544,14 @@ struct MobileAssistantConversation: View { } @ViewBuilder private var bottomSurface: some View { + bottomSurfaceContent + .padding(12) + .frame(maxWidth: 840) + .frame(maxWidth: .infinity) + .background(Color(uiColor: .systemBackground)) + } + + @ViewBuilder private var bottomSurfaceContent: some View { if conversation.isArchived { archivedNotice .padding(12) @@ -566,15 +560,10 @@ struct MobileAssistantConversation: View { RoundedRectangle(cornerRadius: 18, style: .continuous) .stroke(.primary.opacity(0.08), lineWidth: 1) } - .padding(12) } else if conversation.pendingApproval != nil || conversation.pendingUserInteraction != nil { MobileAssistantInteraction(conversation: conversation, hosts: app.library) - .padding(12) - .background(Color(uiColor: .systemBackground)) } else { regularInteractionSurface - .padding(12) - .background(Color(uiColor: .systemBackground)) } } diff --git a/TFI/Features/Workspaces/MobileWorkspaceListView.swift b/TFI/Features/Workspaces/MobileWorkspaceListView.swift index e4e4bdd8..60d6a82d 100644 --- a/TFI/Features/Workspaces/MobileWorkspaceListView.swift +++ b/TFI/Features/Workspaces/MobileWorkspaceListView.swift @@ -140,6 +140,7 @@ struct MobileWorkspaceListView: View { Image(systemName: row.entry.kind.symbol) .foregroundStyle(.tint) .frame(width: 28, height: 28) + .accessibilityLabel(Text(row.entry.kind.label)) VStack(alignment: .leading, spacing: 2) { Text(row.host.name).foregroundStyle(.primary).lineLimit(1) Text(app.library.loginAddress(for: row.host)) @@ -149,13 +150,10 @@ struct MobileWorkspaceListView: View { .truncationMode(.middle) } Spacer(minLength: 12) - VStack(alignment: .trailing, spacing: 2) { - Text(row.entry.kind.label).font(.caption.weight(.medium)) - Text(row.entry.connectedAt, format: .relative(presentation: .numeric).locale(locale)) - .font(.caption2) - } - .foregroundStyle(.secondary) - .lineLimit(1) + Text(row.entry.connectedAt, format: .relative(presentation: .numeric).locale(locale)) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) } .frame(maxWidth: .infinity, alignment: .leading) .contentShape(.rect) diff --git a/TFITests/MobileNavigationParityTests.swift b/TFITests/MobileNavigationParityTests.swift index 43136899..76948438 100644 --- a/TFITests/MobileNavigationParityTests.swift +++ b/TFITests/MobileNavigationParityTests.swift @@ -27,8 +27,11 @@ struct MobileNavigationParityTests { assistant.components(separatedBy: "@ViewBuilder private var conversationTitleMenu").last? .components(separatedBy: "private func titleMenuButton").first) let bottomSurface = try #require( - transcript.components(separatedBy: "@ViewBuilder private var bottomSurface").last? - .components(separatedBy: "private var archivedNotice").first) + transcript.components(separatedBy: "@ViewBuilder private var bottomSurface: some View {").last? + .components(separatedBy: "@ViewBuilder private var bottomSurfaceContent").first) + let transcriptRows = try #require( + transcript.components(separatedBy: "MobileNativeList(").last? + .components(separatedBy: ".safeAreaInset(edge: .bottom").first) let assistantRows = try #require( assistant.components(separatedBy: "private func conversationRow").last? .components(separatedBy: "private func select").first) @@ -93,6 +96,7 @@ struct MobileNavigationParityTests { #expect(transcript.contains("AssistantTranscriptPresentationState()")) #expect(transcript.contains("DisclosureGroup(isExpanded: disclosureBinding")) #expect(!transcript.contains("LazyVStack")) + #expect(transcriptRows.contains(".frame(maxWidth: 840)")) #expect(workRows.contains("if batch.cards.count == 1, let card = batch.cards.first")) #expect(workRows.contains("defaultExpanded: true")) #expect(workRows.contains("presentation.isExpanded(.toolBatch(batch.id))")) @@ -110,12 +114,15 @@ struct MobileNavigationParityTests { let interaction = try String( contentsOf: root.appending(path: "TFI/Features/AI/MobileAssistantInteraction.swift"), encoding: .utf8) let bottomSurface = try #require( - conversation.components(separatedBy: "@ViewBuilder private var bottomSurface").last? - .components(separatedBy: "private var archivedNotice").first) + conversation.components(separatedBy: "@ViewBuilder private var bottomSurface: some View {").last? + .components(separatedBy: "@ViewBuilder private var bottomSurfaceContent").first) + let bottomSurfaceContent = try #require( + conversation.components(separatedBy: "@ViewBuilder private var bottomSurfaceContent").last? + .components(separatedBy: "private var regularInteractionSurface").first) let blockingSurface = try #require( - bottomSurface.components(separatedBy: "conversation.pendingUserInteraction != nil {").last? + bottomSurfaceContent.components(separatedBy: "conversation.pendingUserInteraction != nil {").last? .components(separatedBy: "} else {").first) - let composerSurface = try #require(bottomSurface.components(separatedBy: "} else {").last) + let composerSurface = try #require(bottomSurfaceContent.components(separatedBy: "} else {").last) let regularSurface = try #require( conversation.components(separatedBy: "private var regularInteractionSurface").last? .components(separatedBy: "private var archivedNotice").first @@ -126,13 +133,22 @@ struct MobileNavigationParityTests { ) #expect( - bottomSurface.contains( + bottomSurfaceContent.contains( "conversation.pendingApproval != nil || conversation.pendingUserInteraction != nil")) + #expect(bottomSurface.contains("bottomSurfaceContent")) + let padding = try #require(bottomSurface.range(of: ".padding(12)")) + let widthLimit = try #require(bottomSurface.range(of: ".frame(maxWidth: 840)")) + let fullWidth = try #require(bottomSurface.range(of: ".frame(maxWidth: .infinity)")) + let background = try #require( + bottomSurface.range(of: ".background(Color(uiColor: .systemBackground))")) + #expect(padding.lowerBound < widthLimit.lowerBound) + #expect(widthLimit.lowerBound < fullWidth.lowerBound) + #expect(fullWidth.lowerBound < background.lowerBound) + #expect(bottomSurfaceContent.contains("if conversation.isArchived")) #expect(blockingSurface.contains("MobileAssistantInteraction(")) #expect(!blockingSurface.contains("MobileAssistantComposer(")) #expect(!blockingSurface.contains("MobileAssistantTaskCapsule(")) #expect(!blockingSurface.contains("MobileAssistantSteeringQueue(")) - #expect(blockingSurface.contains(".background(Color(uiColor: .systemBackground))")) #expect(composerSurface.contains("regularInteractionSurface")) #expect(regularSurface.contains("ZStack(alignment: .top)")) #expect(regularFlow.contains("MobileAssistantSteeringQueue(")) @@ -141,7 +157,7 @@ struct MobileNavigationParityTests { #expect(regularSurface.contains("MobileAssistantTaskCapsule(")) #expect(regularSurface.contains(".offset(y: -44)")) #expect(!composerSurface.contains("MobileAssistantInteraction(")) - #expect(composerSurface.contains(".background(Color(uiColor: .systemBackground))")) + #expect(!bottomSurfaceContent.contains(".background(Color(uiColor: .systemBackground))")) #expect(interaction.contains("conversation.answerUserInputOption(option.id, in: questionID)")) #expect(!interaction.contains("conversation.toggleUserInputOption(option.id, in: questionID)")) #expect(interaction.components(separatedBy: "MobileAssistantAdaptiveHeight {").count == 3) @@ -186,6 +202,10 @@ struct MobileNavigationParityTests { contentsOf: root.appending(path: "TFI/Features/AI/MobileAssistantTranscriptCards.swift"), encoding: .utf8 ) + let desktopConversation = try String( + contentsOf: root.appending(path: "TFM/Features/AI/ConversationTranscriptView.swift"), + encoding: .utf8 + ) #expect(!composer.contains("pendingSteering")) #expect(conversation.contains("struct MobileAssistantSteeringQueue")) @@ -197,7 +217,17 @@ struct MobileNavigationParityTests { .components(separatedBy: "struct MobileAssistantConversation").first ) #expect(steeringQueue.contains("Image(systemName: \"xmark\")")) + #expect(steeringQueue.contains(".font(.caption.weight(.bold))")) + #expect(steeringQueue.contains(".frame(width: 28, height: 28)")) #expect(!steeringQueue.contains(".onHover")) + let desktopSteeringQueue = try #require( + desktopConversation.components(separatedBy: "struct SteeringQueueView").last) + for source in [steeringQueue, desktopSteeringQueue] { + #expect(!source.contains("\"Next step\"")) + #expect(!source.contains("\"Waiting for response\"")) + #expect(!source.contains("deliveryStatus")) + #expect(!source.contains("isWaitingForResponse")) + } #expect(conversation.contains("struct MobileAssistantTaskCapsule")) #expect(conversation.contains("ProgressRing(")) #expect(conversation.contains(".offset(y: -overlayHeight)")) @@ -748,6 +778,13 @@ struct MobileNavigationParityTests { #expect(workspaceList.contains("description: Text(\"Use Connect to start a workspace.\")")) #expect(workspaceList.contains("Section(\"History\")")) #expect(workspaceList.contains("static let historyLimit = 5")) + let historyRows = try #require( + workspaceList.components(separatedBy: "private var historyRowsContent").last? + .components(separatedBy: "private func statusTint").first) + #expect(historyRows.contains("Image(systemName: row.entry.kind.symbol)")) + #expect(historyRows.contains(".accessibilityLabel(Text(row.entry.kind.label))")) + #expect(historyRows.contains("Text(row.entry.connectedAt, format: .relative")) + #expect(!historyRows.contains("Text(row.entry.kind.label).font")) #expect(!workspaceList.contains("Open a host from Vault to start a workspace.")) #expect(!workspaceList.contains(".buttonStyle(.borderedProminent)")) #expect(workspaceList.contains(".buttonStyle(.plain)")) diff --git a/TFM/Features/AI/ConversationTranscriptView.swift b/TFM/Features/AI/ConversationTranscriptView.swift index 19ee3758..b706f061 100644 --- a/TFM/Features/AI/ConversationTranscriptView.swift +++ b/TFM/Features/AI/ConversationTranscriptView.swift @@ -881,30 +881,11 @@ struct SteeringQueueView: View { @State private var rowsHeight: CGFloat = 0 @State private var hoveredMessageID: PendingSteeringMessage.ID? - /// The wait has two different causes, and only one of them resolves on its - /// own. While a card above is holding the run, saying "next step" would be - /// a promise nothing is working towards. - private var deliveryStatus: LocalizedStringKey { - switch conversation.runState { - case .waitingForApproval, .waitingForUser: "Waiting for response" - default: "Next step" - } - } - var body: some View { VStack(alignment: .leading, spacing: Metrics.Space.m) { - HStack(spacing: Metrics.Space.s) { - Label("Steering", systemImage: "arrow.turn.down.right") - .font(.caption.weight(.medium)) - .foregroundStyle(.secondary) - - Spacer(minLength: Metrics.Space.s) - - Text(deliveryStatus) - .font(.caption2) - .foregroundStyle(.tertiary) - .lineLimit(1) - } + Label("Steering", systemImage: "arrow.turn.down.right") + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) ScrollView { VStack(alignment: .leading, spacing: 0) {