From 6a874de5647f9bb88f79d9402718cc7be0f13388 Mon Sep 17 00:00:00 2001 From: Callum McIntyre Date: Fri, 1 May 2026 21:12:03 +0000 Subject: [PATCH] Improve chat navigation UX: last-used subtitle, resume action, clock indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SpriteRowView: show most recently used chat name + relative time as subtitle ("Chat #3 · 8m ago") via @Query, falling back to sprite creation date when no chats exist yet. - DashboardView: add "Resume Chat" leading swipe action and "Resume Last Chat" context menu item on both iPhone and iPad. On iPhone the action fetches the most recent open chat and pushes SpriteDetailView via NavigationPath carrying the chat ID, bypassing the overview. On iPad the flag pendingChatOpen skips the onChange reset to .overview and jumps straight to the chat tab. Switched iPhone NavigationStack to use an explicit NavigationPath so swipe actions can push programmatically with a typed SpriteNavTarget value. - SpriteDetailView: accept initialChatId parameter; when present, switch to that chat on first appear and — on iPhone — set showingChat = true to push ChatView directly without landing on the overview first. - SpriteNavigationPanel: show a subtle clock SF Symbol next to the most recently used open chat in the iPad sidebar, giving a visual anchor for "where you left off". Also fix two pre-existing CLAUDE.md violations in DashboardView: - Move confirmationDialog off ForEach rows onto stable ancestor views to prevent immediate dismiss on list re-render. - Use .alert (not .confirmationDialog) for the iPad/Mac delete confirmation since regular size class turns confirmationDialog into an anchored popover. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- Wisp/Views/Dashboard/DashboardView.swift | 136 ++++++++++++++---- Wisp/Views/Dashboard/SpriteRowView.swift | 23 ++- .../Views/SpriteDetail/SpriteDetailView.swift | 16 ++- .../SpriteDetail/SpriteNavigationPanel.swift | 5 + 4 files changed, 149 insertions(+), 31 deletions(-) diff --git a/Wisp/Views/Dashboard/DashboardView.swift b/Wisp/Views/Dashboard/DashboardView.swift index a367247a..29aac5de 100644 --- a/Wisp/Views/Dashboard/DashboardView.swift +++ b/Wisp/Views/Dashboard/DashboardView.swift @@ -6,6 +6,19 @@ enum SpriteSortOrder: String, CaseIterable { case newest = "Newest" } +/// Navigation target for the iPhone NavigationStack. +/// Carries an optional resumeChatId so "Resume Chat" swipe actions can jump +/// directly into a specific chat instead of landing on the overview. +struct SpriteNavTarget: Hashable { + let spriteId: String + let resumeChatId: UUID? + + init(_ spriteId: String, resumeChatId: UUID? = nil) { + self.spriteId = spriteId + self.resumeChatId = resumeChatId + } +} + struct DashboardView: View { @Environment(SpritesAPIClient.self) private var apiClient @Environment(ChatSessionManager.self) private var chatSessionManager @@ -17,6 +30,10 @@ struct DashboardView: View { @State private var selectedTab: SpriteTab = .chat @State private var sortOrder: SpriteSortOrder = .newest @State private var showSettings = false + // iPhone: programmatic navigation path (lets swipe actions push directly to chat) + @State private var navPath = NavigationPath() + // iPad: flag set by "Resume Chat" action so onChange skips defaulting to overview + @State private var pendingChatOpen = false private var sortedSprites: [Sprite] { switch sortOrder { @@ -77,6 +94,19 @@ struct DashboardView: View { .tint(.red) } .swipeActions(edge: .leading) { + Button { + if selectedSpriteID == sprite.id { + // Already selected in the split view — just switch to the chat tab directly. + selectedTab = .chat + } else { + pendingChatOpen = true + selectedSpriteID = sprite.id + } + } label: { + Label("Resume Chat", systemImage: "arrow.uturn.right") + } + .tint(.indigo) + if (sprite.status == .warm || sprite.status == .cold) && !viewModel.wakingSprites.contains(sprite.name) { Button { Task { await viewModel.wakeSprite(sprite, apiClient: apiClient) } @@ -87,6 +117,17 @@ struct DashboardView: View { } } .contextMenu { + Button { + if selectedSpriteID == sprite.id { + selectedTab = .chat + } else { + pendingChatOpen = true + selectedSpriteID = sprite.id + } + } label: { + Label("Resume Last Chat", systemImage: "arrow.uturn.right") + } + if (sprite.status == .warm || sprite.status == .cold) && !viewModel.wakingSprites.contains(sprite.name) { Button { Task { await viewModel.wakeSprite(sprite, apiClient: apiClient) } @@ -100,16 +141,6 @@ struct DashboardView: View { Label("Delete", systemImage: "trash") } } - .confirmationDialog("Delete Sprite?", isPresented: .init( - get: { viewModel.spriteToDelete?.id == sprite.id }, - set: { if !$0 { viewModel.spriteToDelete = nil } } - )) { - Button("Delete", role: .destructive) { - Task { await viewModel.deleteSprite(sprite, apiClient: apiClient) } - } - } message: { - Text("This will permanently delete \"\(sprite.name)\". This action cannot be undone.") - } .id(sprite.id) } } @@ -118,7 +149,7 @@ struct DashboardView: View { @ViewBuilder private var iPhoneSpriteListRows: some View { ForEach(sortedSprites) { sprite in - NavigationLink(value: sprite.id) { + NavigationLink(value: SpriteNavTarget(sprite.id)) { SpriteRowView( sprite: sprite, isPlain: false, @@ -133,6 +164,19 @@ struct DashboardView: View { .tint(.red) } .swipeActions(edge: .leading) { + Button { + let name = sprite.name + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.spriteName == name && !$0.isClosed }, + sortBy: [SortDescriptor(\.lastUsed, order: .reverse)] + ) + let resumeChatId = (try? modelContext.fetch(descriptor))?.first?.id + navPath.append(SpriteNavTarget(sprite.id, resumeChatId: resumeChatId)) + } label: { + Label("Resume Chat", systemImage: "arrow.uturn.right") + } + .tint(.indigo) + if (sprite.status == .warm || sprite.status == .cold) && !viewModel.wakingSprites.contains(sprite.name) { Button { Task { await viewModel.wakeSprite(sprite, apiClient: apiClient) } @@ -143,6 +187,18 @@ struct DashboardView: View { } } .contextMenu { + Button { + let name = sprite.name + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.spriteName == name && !$0.isClosed }, + sortBy: [SortDescriptor(\.lastUsed, order: .reverse)] + ) + let resumeChatId = (try? modelContext.fetch(descriptor))?.first?.id + navPath.append(SpriteNavTarget(sprite.id, resumeChatId: resumeChatId)) + } label: { + Label("Resume Last Chat", systemImage: "arrow.uturn.right") + } + if (sprite.status == .warm || sprite.status == .cold) && !viewModel.wakingSprites.contains(sprite.name) { Button { Task { await viewModel.wakeSprite(sprite, apiClient: apiClient) } @@ -156,16 +212,6 @@ struct DashboardView: View { Label("Delete", systemImage: "trash") } } - .confirmationDialog("Delete Sprite?", isPresented: .init( - get: { viewModel.spriteToDelete?.id == sprite.id }, - set: { if !$0 { viewModel.spriteToDelete = nil } } - )) { - Button("Delete", role: .destructive) { - Task { await viewModel.deleteSprite(sprite, apiClient: apiClient) } - } - } message: { - Text("This will permanently delete \"\(sprite.name)\". This action cannot be undone.") - } .listRowSeparator(.hidden) .listRowBackground(Color.clear) .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) @@ -175,7 +221,7 @@ struct DashboardView: View { // iPhone: explicit NavigationStack so SpriteDetailView can push further views private var iPhoneContent: some View { - NavigationStack { + NavigationStack(path: $navPath) { Group { if viewModel.sprites.isEmpty && !viewModel.isLoading { ContentUnavailableView( @@ -196,10 +242,24 @@ struct DashboardView: View { } .navigationTitle("Sprites") .toolbar { dashboardToolbar } - .navigationDestination(for: String.self) { id in - if let sprite = sortedSprites.first(where: { $0.id == id }) { - SpriteDetailView(sprite: sprite, selectedTab: $selectedTab) - .id(id) + .confirmationDialog("Delete Sprite?", isPresented: .init( + get: { viewModel.spriteToDelete != nil }, + set: { if !$0 { viewModel.spriteToDelete = nil } } + )) { + Button("Delete", role: .destructive) { + if let sprite = viewModel.spriteToDelete { + Task { await viewModel.deleteSprite(sprite, apiClient: apiClient) } + } + } + } message: { + if let sprite = viewModel.spriteToDelete { + Text("This will permanently delete \"\(sprite.name)\". This action cannot be undone.") + } + } + .navigationDestination(for: SpriteNavTarget.self) { target in + if let sprite = sortedSprites.first(where: { $0.id == target.spriteId }) { + SpriteDetailView(sprite: sprite, selectedTab: $selectedTab, initialChatId: target.resumeChatId) + .id(target.spriteId) } } } @@ -228,6 +288,23 @@ struct DashboardView: View { } .navigationTitle("Sprites") .toolbar { dashboardToolbar } + // .alert (not .confirmationDialog) — regular size class becomes a popover + // when anchored, but .alert is a centred modal regardless of attachment point. + .alert("Delete Sprite?", isPresented: .init( + get: { viewModel.spriteToDelete != nil }, + set: { if !$0 { viewModel.spriteToDelete = nil } } + )) { + Button("Delete", role: .destructive) { + if let sprite = viewModel.spriteToDelete { + Task { await viewModel.deleteSprite(sprite, apiClient: apiClient) } + } + } + Button("Cancel", role: .cancel) {} + } message: { + if let sprite = viewModel.spriteToDelete { + Text("This will permanently delete \"\(sprite.name)\". This action cannot be undone.") + } + } } detail: { if let id = selectedSpriteID, let selectedSprite = sortedSprites.first(where: { $0.id == id }) { SpriteDetailView(sprite: selectedSprite, selectedTab: $selectedTab) @@ -250,7 +327,12 @@ struct DashboardView: View { } } .onChange(of: selectedSpriteID) { _, _ in - selectedTab = .overview + if pendingChatOpen { + selectedTab = .chat + pendingChatOpen = false + } else { + selectedTab = .overview + } } .task { await viewModel.loadSprites(apiClient: apiClient) diff --git a/Wisp/Views/Dashboard/SpriteRowView.swift b/Wisp/Views/Dashboard/SpriteRowView.swift index bbdbd2a4..6c97692e 100644 --- a/Wisp/Views/Dashboard/SpriteRowView.swift +++ b/Wisp/Views/Dashboard/SpriteRowView.swift @@ -1,3 +1,4 @@ +import SwiftData import SwiftUI struct SpriteRowView: View { @@ -6,8 +7,22 @@ struct SpriteRowView: View { var isSelected: Bool = false var hasUnreadChats: Bool = false + @Query private var recentChats: [SpriteChat] @State private var isPulsing = false + init(sprite: Sprite, isPlain: Bool = false, isSelected: Bool = false, hasUnreadChats: Bool = false) { + self.sprite = sprite + self.isPlain = isPlain + self.isSelected = isSelected + self.hasUnreadChats = hasUnreadChats + let name = sprite.name + _recentChats = Query( + filter: #Predicate { $0.spriteName == name && !$0.isClosed }, + sort: \.lastUsed, + order: .reverse + ) + } + var body: some View { if isPlain { rowContent.onAppear { startPulsingIfNeeded() } @@ -43,7 +58,11 @@ struct SpriteRowView: View { .fontWeight(.medium) .foregroundStyle(isPlain ? AnyShapeStyle(.primary) : AnyShapeStyle(Color.primary)) - if let createdAt = sprite.createdAt { + if let chat = recentChats.first { + Text("\(chat.displayName) · \(chat.lastUsed.relativeFormatted)") + .font(.caption) + .foregroundStyle(isPlain ? AnyShapeStyle(.secondary) : AnyShapeStyle(Color.secondary)) + } else if let createdAt = sprite.createdAt { Text(createdAt.relativeFormatted) .font(.caption) .foregroundStyle(isPlain ? AnyShapeStyle(.secondary) : AnyShapeStyle(Color.secondary)) @@ -100,6 +119,7 @@ private func mockSprite(id: String = "s1", name: String, status: String) -> Spri SpriteRowView(sprite: mockSprite(id: "s4", name: "busy-sprite", status: "running"), hasUnreadChats: true) } .padding() + .modelContainer(for: [SpriteChat.self, SpriteSession.self], inMemory: true) } #Preview("Plain style (iPad sidebar)") { @@ -109,4 +129,5 @@ private func mockSprite(id: String = "s1", name: String, status: String) -> Spri SpriteRowView(sprite: mockSprite(id: "s3", name: "old-project", status: "cold"), isPlain: true) SpriteRowView(sprite: mockSprite(id: "s4", name: "busy-sprite", status: "running"), isPlain: true, hasUnreadChats: true) } + .modelContainer(for: [SpriteChat.self, SpriteSession.self], inMemory: true) } diff --git a/Wisp/Views/SpriteDetail/SpriteDetailView.swift b/Wisp/Views/SpriteDetail/SpriteDetailView.swift index c7b8ce7f..2c5fe85b 100644 --- a/Wisp/Views/SpriteDetail/SpriteDetailView.swift +++ b/Wisp/Views/SpriteDetail/SpriteDetailView.swift @@ -3,6 +3,7 @@ import SwiftData struct SpriteDetailView: View { let sprite: Sprite + let initialChatId: UUID? @Binding var selectedTab: SpriteTab @State private var chatListViewModel: SpriteChatListViewModel @State private var chatViewModel: ChatViewModel? @@ -20,8 +21,9 @@ struct SpriteDetailView: View { @Environment(\.scenePhase) private var scenePhase @Environment(\.horizontalSizeClass) private var sizeClass - init(sprite: Sprite, selectedTab: Binding) { + init(sprite: Sprite, selectedTab: Binding, initialChatId: UUID? = nil) { self.sprite = sprite + self.initialChatId = initialChatId _selectedTab = selectedTab _chatListViewModel = State(initialValue: SpriteChatListViewModel(spriteName: sprite.name)) _checkpointsViewModel = State(initialValue: CheckpointsViewModel(spriteName: sprite.name)) @@ -268,8 +270,16 @@ struct SpriteDetailView: View { chatListViewModel.createChat(modelContext: modelContext) } - // Initialize chat VM for active chat - if let active = chatListViewModel.activeChat { + // If a specific chat was requested (e.g. via "Resume Chat" swipe action), + // switch to it and — on iPhone — push straight to ChatView, bypassing overview. + if let chatId = initialChatId, + let chat = chatListViewModel.chats.first(where: { $0.id == chatId }) { + markChatRead(chat) + switchToChat(chat) + if sizeClass != .regular { + showingChat = true + } + } else if let active = chatListViewModel.activeChat { switchToChat(active) } } diff --git a/Wisp/Views/SpriteDetail/SpriteNavigationPanel.swift b/Wisp/Views/SpriteDetail/SpriteNavigationPanel.swift index 36998e1a..32e8e983 100644 --- a/Wisp/Views/SpriteDetail/SpriteNavigationPanel.swift +++ b/Wisp/Views/SpriteDetail/SpriteNavigationPanel.swift @@ -92,6 +92,11 @@ struct SpriteNavigationPanel: View { Image(systemName: "archivebox") .font(.caption2) .foregroundStyle(.tertiary) + } else if chat.id == openChats.first?.id { + Image(systemName: "clock") + .font(.caption2) + .foregroundStyle(.tertiary) + .accessibilityLabel("Most recently used") } } .contextMenu {