Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 109 additions & 27 deletions Wisp/Views/Dashboard/DashboardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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) }
Expand All @@ -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) }
Expand All @@ -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)
}
}
Expand All @@ -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,
Expand All @@ -133,6 +164,19 @@ struct DashboardView: View {
.tint(.red)
}
.swipeActions(edge: .leading) {
Button {
let name = sprite.name
let descriptor = FetchDescriptor<SpriteChat>(
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) }
Expand All @@ -143,6 +187,18 @@ struct DashboardView: View {
}
}
.contextMenu {
Button {
let name = sprite.name
let descriptor = FetchDescriptor<SpriteChat>(
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) }
Expand All @@ -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))
Expand All @@ -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(
Expand All @@ -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)
}
}
}
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
23 changes: 22 additions & 1 deletion Wisp/Views/Dashboard/SpriteRowView.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import SwiftData
import SwiftUI

struct SpriteRowView: View {
Expand All @@ -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<SpriteChat> { $0.spriteName == name && !$0.isClosed },
sort: \.lastUsed,
order: .reverse
)
}

var body: some View {
if isPlain {
rowContent.onAppear { startPulsingIfNeeded() }
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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)") {
Expand All @@ -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)
}
16 changes: 13 additions & 3 deletions Wisp/Views/SpriteDetail/SpriteDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -20,8 +21,9 @@ struct SpriteDetailView: View {
@Environment(\.scenePhase) private var scenePhase
@Environment(\.horizontalSizeClass) private var sizeClass

init(sprite: Sprite, selectedTab: Binding<SpriteTab>) {
init(sprite: Sprite, selectedTab: Binding<SpriteTab>, 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))
Expand Down Expand Up @@ -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)
}
}
Expand Down
5 changes: 5 additions & 0 deletions Wisp/Views/SpriteDetail/SpriteNavigationPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading