From f98f6d24da4a93272ae6c9f6f5ebc8d38ce89d80 Mon Sep 17 00:00:00 2001 From: Chris Izatt Date: Thu, 18 Jun 2026 10:58:20 +0100 Subject: [PATCH 1/2] fix(ios): keep active chat + sidebar state across settings changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Any settings change (theme, composer shell, transcript font, …) bumps TWThemeStore.revision, and RootView keys `.id(themes.revision)` — so the whole tree is torn down/rebuilt to re-read TWTheme's computed-static tokens. That reset the open chat, collapsed expanded workspaces, and re-expanded collapsed section headers every time. Hoist the transient UI state onto RemoteSessionModel (survives the teardown): selectedTaskId + expandedWorkspaces / collapsedSections / collapsedParents. HomeView reads the sidebar sets via computed properties (call sites unchanged). iPhone navigation becomes selection-driven (navigationDestination(item:) + a Button row instead of NavigationLink) so the open chat re-pushes from the surviving selectedTaskId after the rebuild; iPad already restores via its selection-bound detail column. Bonus: navigationTarget deep-links now also push correctly on iPhone. Verified: swift build + 69 Kit tests + iOS app build (iPhone 17 sim). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Sources/TaskWraithUI/AppShell.swift | 13 ++++---- .../Sources/TaskWraithUI/HomeListViews.swift | 33 +++++++++++++++---- .../TaskWraithUI/RemoteSessionModel.swift | 11 +++++++ 3 files changed, 44 insertions(+), 13 deletions(-) diff --git a/ios/TaskWraithKit/Sources/TaskWraithUI/AppShell.swift b/ios/TaskWraithKit/Sources/TaskWraithUI/AppShell.swift index 8969e5c9..8cfc4740 100644 --- a/ios/TaskWraithKit/Sources/TaskWraithUI/AppShell.swift +++ b/ios/TaskWraithKit/Sources/TaskWraithUI/AppShell.swift @@ -176,7 +176,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 +231,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 +246,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 +258,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 +280,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..75dca6dc 100644 --- a/ios/TaskWraithKit/Sources/TaskWraithUI/HomeListViews.swift +++ b/ios/TaskWraithKit/Sources/TaskWraithUI/HomeListViews.swift @@ -19,8 +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 = [] + /// 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 showSettings = false @State private var canvasMode: ComposeMode? = nil @@ -40,10 +44,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. @@ -488,9 +498,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..5f83b227 100644 --- a/ios/TaskWraithKit/Sources/TaskWraithUI/RemoteSessionModel.swift +++ b/ios/TaskWraithKit/Sources/TaskWraithUI/RemoteSessionModel.swift @@ -207,6 +207,17 @@ 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 = [] /// Deep-link target captured from a notification tap before the session is /// established (cold launch); applied to navigationTarget on `.established`. private var pendingDeepLinkThreadId: String? From 714bda73eb7545a638c396bf4f784977f4c8216a Mon Sep 17 00:00:00 2001 From: Chris Izatt Date: Thu, 18 Jun 2026 11:13:15 +0100 Subject: [PATCH 2/2] fix(ios): keep the Settings sheet open across settings changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the prior commit. The Settings sheet was presented from HomeView, inside the `.id(themes.revision)` subtree — so changing a setting tore it down and closed it (you had to reopen for each change). Present it from RootView instead (above the teardown) via model.settingsPresented; it survives the rebuild and re-themes live through AppSettingsSheet's own @ObservedObject themes — no flash, no internal-state loss. Verified: swift build + iOS app build (iPhone 17 sim). Co-Authored-By: Claude Opus 4.8 (1M context) --- ios/TaskWraithKit/Sources/TaskWraithUI/AppShell.swift | 10 ++++++++++ .../Sources/TaskWraithUI/HomeListViews.swift | 8 +------- .../Sources/TaskWraithUI/RemoteSessionModel.swift | 5 +++++ 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/ios/TaskWraithKit/Sources/TaskWraithUI/AppShell.swift b/ios/TaskWraithKit/Sources/TaskWraithUI/AppShell.swift index 8cfc4740..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. diff --git a/ios/TaskWraithKit/Sources/TaskWraithUI/HomeListViews.swift b/ios/TaskWraithKit/Sources/TaskWraithUI/HomeListViews.swift index 75dca6dc..169246b7 100644 --- a/ios/TaskWraithKit/Sources/TaskWraithUI/HomeListViews.swift +++ b/ios/TaskWraithKit/Sources/TaskWraithUI/HomeListViews.swift @@ -25,7 +25,6 @@ struct HomeView: View { get { model.collapsedParents } nonmutating set { model.collapsedParents = newValue } } - @State private var showSettings = false @State private var canvasMode: ComposeMode? = nil private func openCanvas(_ mode: ComposeMode) { @@ -122,7 +121,7 @@ struct HomeView: View { Image(systemName: "arrow.clockwise") } Button { - showSettings = true + model.settingsPresented = true } label: { Image(systemName: "gearshape") } @@ -138,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 diff --git a/ios/TaskWraithKit/Sources/TaskWraithUI/RemoteSessionModel.swift b/ios/TaskWraithKit/Sources/TaskWraithUI/RemoteSessionModel.swift index 5f83b227..7473fd28 100644 --- a/ios/TaskWraithKit/Sources/TaskWraithUI/RemoteSessionModel.swift +++ b/ios/TaskWraithKit/Sources/TaskWraithUI/RemoteSessionModel.swift @@ -218,6 +218,11 @@ public final class RemoteSessionModel: ObservableObject { @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?