From 3e8f1d4ad495f8b328593a9a88d681149c400766 Mon Sep 17 00:00:00 2001 From: Chris Izatt Date: Thu, 18 Jun 2026 11:58:02 +0100 Subject: [PATCH] fix(ios): surface new-chat create failures instead of hanging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Starting a new GLOBAL or ENSEMBLE chat could spin on "Creating…" forever. The Mac legitimately declines some creates (ensemble mode disabled; global not shared while the workspace allowlist is empty), but `send` only fires the create callback when the action is ACCEPTED — so a denial never reached NewChatBootstrapView, which never stopped spinning and never showed why. - createEmptyThread now propagates failure via send's onAck (onCreated(nil) on a denied/failed ack); the generic send is unchanged. - NewChatBootstrapView shows a failure state with the Mac's reason (model.lastActionMessage) + a Try Again button, and clears the latched requestedKey so retry actually re-requests. Verified: swift build + 69 Kit tests + iOS app build (iPhone 17 sim). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Sources/TaskWraithUI/NewChatCanvas.swift | 50 ++++++++++++++++++- .../TaskWraithUI/RemoteSessionModel.swift | 28 +++++++---- 2 files changed, 68 insertions(+), 10 deletions(-) diff --git a/ios/TaskWraithKit/Sources/TaskWraithUI/NewChatCanvas.swift b/ios/TaskWraithKit/Sources/TaskWraithUI/NewChatCanvas.swift index 4a2a0715..bdec4164 100644 --- a/ios/TaskWraithKit/Sources/TaskWraithUI/NewChatCanvas.swift +++ b/ios/TaskWraithKit/Sources/TaskWraithUI/NewChatCanvas.swift @@ -12,6 +12,7 @@ struct NewChatBootstrapView: View { @State private var createdThreadId: String? @State private var requestedKey: String? + @State private var createFailed = false private var targetWorkspaceId: String? { switch mode { @@ -37,6 +38,8 @@ struct NewChatBootstrapView: View { Group { if let threadId = createdThreadId { detailHost(threadId: threadId) + } else if createFailed { + failureView } else { VStack(spacing: 12) { TaskWraithMonolineBrandView(markSize: 44, titleSize: 20) @@ -86,17 +89,62 @@ struct NewChatBootstrapView: View { } } + private var failureView: some View { + VStack(spacing: 14) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 32)) + .foregroundStyle(TWTheme.textMuted) + Text(failureTitle) + .font(.headline) + .foregroundStyle(TWTheme.textPrimary) + .multilineTextAlignment(.center) + Text(model.lastActionMessage ?? "Your Mac declined the request.") + .font(.callout) + .foregroundStyle(TWTheme.textSecondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + Button { + createFailed = false + createIfReady() + } label: { + Text("Try Again").font(.callout.weight(.semibold)) + } + .buttonStyle(.borderedProminent) + .tint(TWTheme.chroma1) + .padding(.top, 2) + } + .padding(28) + .frame(maxWidth: 420) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(TWTheme.appBg) + } + + private var failureTitle: String { + switch mode { + case .workspace: return "Couldn't start this chat" + case .ensemble: return "Couldn't start this ensemble" + case .global: return "Couldn't start a global chat" + } + } + private func createIfReady() { guard createdThreadId == nil, let workspaceId = targetWorkspaceId else { return } let key = "\(variant):\(workspaceId)" guard requestedKey != key else { return } requestedKey = key + createFailed = false model.createEmptyThread( workspaceId: workspaceId, variant: variant, title: "New Chat" ) { threadId in - guard let threadId else { return } + guard let threadId else { + // Mac declined (or the request failed) — stop spinning, surface + // the reason, and allow Retry (clear the latched key). + requestedKey = nil + createFailed = true + return + } createdThreadId = threadId } } diff --git a/ios/TaskWraithKit/Sources/TaskWraithUI/RemoteSessionModel.swift b/ios/TaskWraithKit/Sources/TaskWraithUI/RemoteSessionModel.swift index a6158b13..c967e82d 100644 --- a/ios/TaskWraithKit/Sources/TaskWraithUI/RemoteSessionModel.swift +++ b/ios/TaskWraithKit/Sources/TaskWraithUI/RemoteSessionModel.swift @@ -2441,16 +2441,26 @@ public final class RemoteSessionModel: ObservableObject { title: title), timeoutMs: 12_000, successLabel: "Chat created.", - navigateOnAck: true - ) { [weak self] threadId in - guard let self, let threadId else { - onCreated?(nil) - return + navigateOnAck: true, + onThreadCreated: { [weak self] threadId in + guard let self, let threadId else { + onCreated?(nil) + return + } + self.rememberThreadWorkspace(threadId, workspaceId: workspaceId) + self.scheduleThreadRefresh(threadId) + onCreated?(threadId) + }, + // A denied/failed create never reaches onThreadCreated (send only + // fires that when accepted), which left the new-chat canvas spinning + // on "Creating…" forever — exactly what "can't start a global / + // ensemble chat" looks like. Surface the failure so the canvas can + // show the Mac's reason (e.g. ensemble mode disabled, global not + // shared while the workspace allowlist is empty) and offer Retry. + onAck: { accepted in + if !accepted { onCreated?(nil) } } - self.rememberThreadWorkspace(threadId, workspaceId: workspaceId) - self.scheduleThreadRefresh(threadId) - onCreated?(threadId) - } + ) } /// Create an empty ensemble chat, optionally queue the first prompt.