diff --git a/ios/TaskWraithKit/Sources/TaskWraithUI/AppShell.swift b/ios/TaskWraithKit/Sources/TaskWraithUI/AppShell.swift index 8969e5c9..aba36288 100644 --- a/ios/TaskWraithKit/Sources/TaskWraithUI/AppShell.swift +++ b/ios/TaskWraithKit/Sources/TaskWraithUI/AppShell.swift @@ -77,6 +77,16 @@ public struct RootView: View { // Theme tokens are computed statics — a revision bump rebuilds the // tree so every TWTheme read picks up the new selection. .id(themes.revision) + // Settings sheet lives ABOVE the revision teardown (attached after + // `.id`) so a theme/composer/font change made inside it keeps the sheet + // open — AppSettingsSheet re-themes live via its own @ObservedObject + // themes. Presented here, not from HomeView, which is inside the + // `.id(revision)` subtree that rebuilds on every settings change. + .sheet(isPresented: $model.settingsPresented) { + AppSettingsSheet() + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } .animation(.easeInOut(duration: 0.25), value: showShellDuringDrop) // Privacy shield: iOS snapshots the UI for the app switcher — // transcripts and file contents must not be readable there. @@ -176,7 +186,6 @@ struct MastheadRow: View { struct ConnectedShell: View { @ObservedObject var model: RemoteSessionModel @Environment(\.horizontalSizeClass) private var sizeClass - @State private var selectedTaskId: String? @StateObject private var fileState = MobileFileEditorState() @StateObject private var diffState = MobileDiffStudioState() @State private var shellMode: ShellMode = .app @@ -232,11 +241,11 @@ struct ConnectedShell: View { } } else { NavigationSplitView { - HomeView(model: model, selection: $selectedTaskId, explicitSelection: true) + HomeView(model: model, selection: $model.selectedTaskId, explicitSelection: true) .navigationSplitViewColumnWidth(min: 300, ideal: 340) .iPadSidebarInnerRim(edge: .trailing) } detail: { - if let taskId = selectedTaskId, taskId.hasPrefix("new-") { + if let taskId = model.selectedTaskId, taskId.hasPrefix("new-") { NavigationStack { NewChatBootstrapView( model: model, @@ -247,7 +256,7 @@ struct ConnectedShell: View { ? String(taskId.split(separator: ":")[1]) : nil) } .id(taskId) - } else if let taskId = selectedTaskId { + } else if let taskId = model.selectedTaskId { // Hand-rolled third column: SwiftUI's `.inspector` // presents as an overlay here regardless of attach // level (tried both); an HStack pane DETERMINISTICALLY @@ -259,7 +268,7 @@ struct ConnectedShell: View { if model.inspectorPresented { ThreadInspector(model: model, threadId: taskId) { childId in model.inspectorPresented = false - selectedTaskId = childId + model.selectedTaskId = childId } .frame(width: 390) .background(TWTheme.appBg) @@ -281,8 +290,8 @@ struct ConnectedShell: View { } } else { NavigationStack { - HomeView(model: model, selection: $selectedTaskId) - .navigationDestination(for: String.self) { taskId in + HomeView(model: model, selection: $model.selectedTaskId) + .navigationDestination(item: $model.selectedTaskId) { taskId in ThreadDetailView(model: model, taskId: taskId) // Compact: the same binding presents as a sheet. .inspector(isPresented: $model.inspectorPresented) { diff --git a/ios/TaskWraithKit/Sources/TaskWraithUI/HomeListViews.swift b/ios/TaskWraithKit/Sources/TaskWraithUI/HomeListViews.swift index 1b4d7cbc..169246b7 100644 --- a/ios/TaskWraithKit/Sources/TaskWraithUI/HomeListViews.swift +++ b/ios/TaskWraithKit/Sources/TaskWraithUI/HomeListViews.swift @@ -19,9 +19,12 @@ import TaskWraithKit struct HomeView: View { @ObservedObject var model: RemoteSessionModel @Binding var selection: String? - /// Parent chats whose sub-thread/side-chat children are collapsed. - @State private var collapsedParents: Set = [] - @State private var showSettings = false + /// Parent chats whose sub-thread/side-chat children are collapsed. Backed by + /// the model so it survives the theme-revision teardown (see selectedTaskId). + private var collapsedParents: Set { + get { model.collapsedParents } + nonmutating set { model.collapsedParents = newValue } + } @State private var canvasMode: ComposeMode? = nil private func openCanvas(_ mode: ComposeMode) { @@ -40,10 +43,16 @@ struct HomeView: View { /// Workspace folders the user has EXPANDED — inverted from the old /// collapsed-set so folders start collapsed (a tidy first open; expand /// state then sticks for the session). - @State private var expandedWorkspaces: Set = [] + private var expandedWorkspaces: Set { + get { model.expandedWorkspaces } + nonmutating set { model.expandedWorkspaces = newValue } + } /// Top-level sections (activeRuns / pinned / recents / workspaces / /// globalChats) the user has collapsed — sections start expanded. - @State private var collapsedSections: Set = [] + private var collapsedSections: Set { + get { model.collapsedSections } + nonmutating set { model.collapsedSections = newValue } + } /// Top-level threads per workspace; sub-threads/side chats nest under /// their parent like the desktop sidebar. @@ -112,7 +121,7 @@ struct HomeView: View { Image(systemName: "arrow.clockwise") } Button { - showSettings = true + model.settingsPresented = true } label: { Image(systemName: "gearshape") } @@ -128,11 +137,6 @@ struct HomeView: View { .navigationDestination(item: $canvasMode) { mode in NewChatBootstrapView(model: model, mode: mode, initialWorkspaceId: nil) } - .sheet(isPresented: $showSettings) { - AppSettingsSheet() - .presentationDetents([.medium, .large]) - .presentationDragIndicator(.visible) - } } @ViewBuilder @@ -488,9 +492,20 @@ struct HomeView: View { .listRowSeparator(.hidden) .listRowBackground(selectedChrome) } else { - NavigationLink(value: card.id) { - TaskRow(model: model, card: card, nested: nested) + // Compact: selection-driven push via `navigationDestination(item:)` + // (not NavigationLink) so the open chat is restorable from the + // model after the theme-revision teardown. + Button { + selection = card.id + } label: { + HStack(spacing: 6) { + TaskRow(model: model, card: card, nested: nested) + Image(systemName: "chevron.right") + .font(.caption2) + .foregroundStyle(TWTheme.textMuted) + } } + .buttonStyle(.plain) .listRowInsets(rowInsets) .listRowSeparator(.hidden) .listRowBackground(rowChrome) diff --git a/ios/TaskWraithKit/Sources/TaskWraithUI/RemoteSessionModel.swift b/ios/TaskWraithKit/Sources/TaskWraithUI/RemoteSessionModel.swift index a6158b13..7473fd28 100644 --- a/ios/TaskWraithKit/Sources/TaskWraithUI/RemoteSessionModel.swift +++ b/ios/TaskWraithKit/Sources/TaskWraithUI/RemoteSessionModel.swift @@ -207,6 +207,22 @@ public final class RemoteSessionModel: ObservableObject { @Published public private(set) var lastActionMessage: String? /// Set after createThread succeeds — HomeView navigates to the new chat. @Published public var navigationTarget: String? + /// The chat the user has open (sidebar selection / pushed thread), plus the + /// sidebar's expand/collapse layout. Hoisted onto the model so they SURVIVE + /// the theme-revision view teardown: TWThemeStore bumps `revision` on any + /// settings change and RootView keys `.id(revision)` (TWTheme tokens are + /// computed statics, so the rebuild is how they re-read) — which would + /// otherwise drop the open chat + reset the sidebar. `selectedTaskId` drives + /// the iPad detail column and the iPhone `navigationDestination(item:)`. + @Published public var selectedTaskId: String? + @Published public var expandedWorkspaces: Set = [] + @Published public var collapsedSections: Set = [] + @Published public var collapsedParents: Set = [] + /// Settings sheet presentation — hoisted so a theme/composer/font change + /// made inside it doesn't tear the sheet down with the rest of the tree. + /// Presented from RootView (above the `.id(revision)` boundary); the sheet + /// re-themes live via its own `@ObservedObject themes`. + @Published public var settingsPresented = false /// Deep-link target captured from a notification tap before the session is /// established (cold launch); applied to navigationTarget on `.established`. private var pendingDeepLinkThreadId: String?