From 4e5749e56f05f5810e45194c3874f6debcea3cfb Mon Sep 17 00:00:00 2001 From: oratis Date: Wed, 1 Jul 2026 22:56:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(ios):=20chat=20never=20dead-ends=20?= =?UTF-8?q?=E2=80=94=20retry,=20stop,=20and=20empty-turn=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The phone showed a bare "(no response)" bubble with no way forward. Root cause wasn't the backend (verified: /chat streams cleanly over loopback AND the phone's LAN+token path) — it was an empty agent turn (e.g. a provider/proxy hiccup returns an empty completion) landing in a UI dead end. iOS (ChatView): - Model each Lisa turn with a status (streaming/ok/empty/error/cancelled) instead of a lone isError flag. Empty and cancelled are retryable, not failures. - Every non-ok terminal turn gets an inline **Retry** that replays the last message (drops the failed bubble so it doesn't stack). - **Stop** button while streaming (replaces send) — cancels the turn; the torn-down SSE connection lets the Mac abort the agent too. - Typing indicator only stands in for a still-empty streaming turn; composer grows to 5 lines and sends on return. Server (/chat): - Emit an explicit `{type:"empty"}` when a turn produces zero text, zero tools, and no error, so the client can distinguish "nothing came back" from a real reply. - Per-turn AbortController wired to client disconnect (`req` close) → Stop actually aborts the agent instead of leaving it running (and blocking the queued retry). Combined with the server-wide shutdown signal via AbortSignal.any. - Guard SSE writes against a closed socket (no "write after end" from the loop). Backward-compatible: `empty` is additive (old clients ignore it); the new client also self-detects an empty turn, so the fix works against any backend. Verified: tsc --noEmit clean; iOS BUILD SUCCEEDED; 27/27 LisaPocketTests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ios-companion/Sources/ChatView.swift | 187 ++++++++++++++---- src/web/server.ts | 28 ++- 2 files changed, 178 insertions(+), 37 deletions(-) diff --git a/packaging/ios-companion/Sources/ChatView.swift b/packaging/ios-companion/Sources/ChatView.swift index 1b85546..805ac78 100644 --- a/packaging/ios-companion/Sources/ChatView.swift +++ b/packaging/ios-companion/Sources/ChatView.swift @@ -1,14 +1,21 @@ import SwiftUI /// One chat turn. Lisa's text accumulates during streaming; `tools` collects the -/// tool names she invokes (rendered as chips); `isError` flags a failed turn. +/// tool names she invokes (rendered as chips); `status` tracks how the turn ended +/// so the UI can offer a retry instead of a dead end. struct ChatMessage: Identifiable, Equatable { enum Role { case user, lisa } + /// Lifecycle of a Lisa turn. `streaming` while tokens arrive; then one of the + /// terminal states. `empty`/`cancelled` are retryable but not failures. + enum Status: Equatable { case streaming, ok, empty, error, cancelled } let id = UUID() var role: Role var text: String = "" var tools: [String] = [] - var isError: Bool = false + var status: Status = .ok + var isError: Bool { status == .error } + /// Terminal states the user can retry from (nothing useful landed). + var isRetryable: Bool { status == .error || status == .empty || status == .cancelled } } @MainActor @@ -21,6 +28,9 @@ final class ChatModel: ObservableObject { private var page = 0 private var task: Task? private var moodTask: Task? + /// The last thing the user said — replayed by `resend()` when a turn comes + /// back empty / errored / cancelled. + private var lastUserText: String? // ── history ── func loadHistory(_ client: LisaClient) async { @@ -65,42 +75,90 @@ final class ChatModel: ObservableObject { } func stopMood() { moodTask?.cancel(); moodTask = nil } - // ── send ── + // ── send / retry / stop ── func send(_ text: String, client: LisaClient) { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } + guard !trimmed.isEmpty, !sending else { return } messages.append(ChatMessage(role: .user, text: trimmed)) - messages.append(ChatMessage(role: .lisa)) + lastUserText = trimmed + runTurn(trimmed, client: client) + } + + /// Replay the last user message. Drops a trailing failed/empty Lisa bubble so + /// the retry visually replaces it rather than stacking a second dead end. + func resend(client: LisaClient) { + guard let text = lastUserText, !sending else { return } + if let last = messages.last, last.role == .lisa, last.isRetryable { + messages.removeLast() + } + runTurn(text, client: client) + } + + /// Stop the in-flight turn. Cancelling the task tears down the SSE stream, + /// which also closes the connection so the Mac can abort the agent. + func cancel() { task?.cancel() } + + private func runTurn(_ userText: String, client: LisaClient) { + messages.append(ChatMessage(role: .lisa, status: .streaming)) let idx = messages.count - 1 sending = true task = Task { @MainActor in defer { sending = false } do { - for try await msg in client.chatStream(trimmed) { + for try await msg in client.chatStream(userText) { + guard messages.indices.contains(idx) else { break } switch msg.type { case "text": - if let t = msg.text { messages[idx].text += t } + if let t = msg.text, !t.isEmpty { messages[idx].text += t } case "tool_start": if let n = msg.object["name"] as? String, !messages[idx].tools.contains(n) { messages[idx].tools.append(n) } case "error": - messages[idx].isError = true + messages[idx].status = .error let m = msg.object["message"] as? String ?? "the turn failed" messages[idx].text += (messages[idx].text.isEmpty ? "" : "\n") + m + case "empty": + // Server signals the turn produced nothing (e.g. a provider + // hiccup). Resolved to `.empty` in resolveEnding below. + break default: break } } - if messages[idx].text.isEmpty && messages[idx].tools.isEmpty && !messages[idx].isError { - messages[idx].text = "(no response)" - } + resolveEnding(idx) } catch { - messages[idx].isError = true - messages[idx].text = (error as? LocalizedError)?.errorDescription ?? "Couldn't reach Lisa." + markFailed(idx, error) } } } + + /// A cleanly-finished stream: decide the terminal status. Text/tools ⇒ ok; + /// otherwise it's an empty turn (retryable, not an error). + private func resolveEnding(_ idx: Int) { + guard messages.indices.contains(idx), messages[idx].status == .streaming else { return } + if messages[idx].text.isEmpty && messages[idx].tools.isEmpty { + messages[idx].status = .empty + messages[idx].text = "Lisa didn't reply." + } else { + messages[idx].status = .ok + } + } + + /// A thrown stream: a user-initiated stop reads as `.cancelled`; anything else + /// is a real error. Either way it's retryable. + private func markFailed(_ idx: Int, _ error: Error) { + guard messages.indices.contains(idx) else { return } + let cancelled = error is CancellationError || (error as? URLError)?.code == .cancelled + if cancelled { + messages[idx].status = .cancelled + if messages[idx].text.isEmpty { messages[idx].text = "Stopped." } + } else { + messages[idx].status = .error + let msg = (error as? LocalizedError)?.errorDescription ?? "Couldn't reach Lisa." + messages[idx].text += (messages[idx].text.isEmpty ? "" : "\n") + msg + } + } } struct ChatView: View { @@ -204,18 +262,16 @@ struct ChatView: View { } LazyVStack(spacing: Theme.Space.m) { if model.messages.isEmpty { - VStack(spacing: 6) { - Image(systemName: "bubble.left.and.text.bubble.right") - .font(.largeTitle).foregroundStyle(Theme.tertiary) - Text("Ask Lisa anything — or tap a suggestion below.") - .font(.callout).foregroundStyle(Theme.secondary) - .multilineTextAlignment(.center) + emptyState.padding(.top, 60) + } + ForEach(model.messages) { msg in + // A blank streaming bubble is stood in for by the typing + // indicator below — don't render an empty bubble. + if !isWaitingBubble(msg) { + MessageBubble(message: msg, onRetry: retryAction(for: msg)) } - .frame(maxWidth: .infinity) - .padding(.top, 60) } - ForEach(model.messages) { MessageBubble(message: $0) } - if model.sending { + if showTyping { TypingIndicator().frame(maxWidth: .infinity, alignment: .leading) } } @@ -228,6 +284,35 @@ struct ChatView: View { } } + private var emptyState: some View { + VStack(spacing: 6) { + Image(systemName: "bubble.left.and.text.bubble.right") + .font(.largeTitle).foregroundStyle(Theme.tertiary) + Text("Ask Lisa anything — or tap a suggestion below.") + .font(.callout).foregroundStyle(Theme.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + } + + /// A Lisa turn that's mid-stream with nothing to show yet. + private func isWaitingBubble(_ msg: ChatMessage) -> Bool { + msg.role == .lisa && msg.status == .streaming && msg.text.isEmpty && msg.tools.isEmpty + } + + /// Show the typing indicator while the newest Lisa turn hasn't produced anything. + private var showTyping: Bool { + guard model.sending, let last = model.messages.last else { return false } + return isWaitingBubble(last) + } + + /// Offer Retry only on the newest turn, when it ended with nothing useful. + private func retryAction(for msg: ChatMessage) -> (() -> Void)? { + guard !model.sending, msg.role == .lisa, msg.isRetryable, + msg.id == model.messages.last?.id else { return nil } + return { model.resend(client: app.client) } + } + private func scrollToBottom(_ proxy: ScrollViewProxy) { withAnimation(.easeOut(duration: 0.15)) { proxy.scrollTo(Self.bottomID, anchor: .bottom) } } @@ -236,25 +321,41 @@ struct ChatView: View { HStack(spacing: Theme.Space.s) { TextField("Message…", text: $input, axis: .vertical) .textFieldStyle(.roundedBorder) - Button { - let text = input - input = "" - model.send(text, client: app.client) - } label: { - Image(systemName: "arrow.up.circle.fill").font(.title2) + .lineLimit(1...5) + .onSubmit(sendCurrent) + if model.sending { + Button { model.cancel() } label: { + Image(systemName: "stop.circle.fill").font(.title2) + } + .frame(width: 44, height: 44) + .foregroundStyle(Theme.danger) + .accessibilityLabel("Stop") + } else { + Button(action: sendCurrent) { + Image(systemName: "arrow.up.circle.fill").font(.title2) + } + .frame(width: 44, height: 44) + .accessibilityLabel("Send message") + .disabled(input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } - .frame(width: 44, height: 44) - .accessibilityLabel("Send message") - .disabled(input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || model.sending) } .padding() } + + private func sendCurrent() { + let text = input + input = "" + model.send(text, client: app.client) + } } /// A user/Lisa chat bubble: markdown text segments + fenced code as CodeBlocks + -/// tool chips for the tools Lisa ran this turn. +/// tool chips for the tools Lisa ran this turn. An optional Retry appears when the +/// turn came back empty / errored / stopped. struct MessageBubble: View { let message: ChatMessage + var onRetry: (() -> Void)? = nil + var body: some View { HStack { if message.role == .user { Spacer(minLength: 36) } @@ -274,10 +375,20 @@ struct MessageBubble: View { } } } + if let onRetry { + Button(action: onRetry) { + Label("Retry", systemImage: "arrow.clockwise") + .font(.caption.weight(.semibold)) + } + .buttonStyle(.plain) + .foregroundStyle(Theme.accent) + .padding(.top, 1) + .accessibilityLabel("Retry") + } } .padding(.horizontal, 12).padding(.vertical, 9) .background(bubbleBg, in: RoundedRectangle(cornerRadius: 14)) - .foregroundStyle(message.isError ? Theme.danger : Theme.text) + .foregroundStyle(textColor) .frame(maxWidth: 320, alignment: message.role == .user ? .trailing : .leading) if message.role == .lisa { Spacer(minLength: 36) } } @@ -288,6 +399,14 @@ struct MessageBubble: View { private var segments: [MessageSegment] { parseSegments(message.text) } private var bubbleBg: Color { message.role == .user ? Theme.accent.opacity(0.18) : Theme.card } + /// Errors read red; empty/stopped turns read muted; normal text is primary. + private var textColor: Color { + switch message.status { + case .error: return Theme.danger + case .empty, .cancelled: return Theme.secondary + default: return Theme.text + } + } } /// Animated "Lisa is typing" dots shown while a turn streams. diff --git a/src/web/server.ts b/src/web/server.ts index 6a8761e..1f1e46d 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -1959,8 +1959,17 @@ self.addEventListener('fetch', (event) => { "cache-control": "no-cache", connection: "keep-alive", }); - const send = (event: object) => + // Guard writes: once the client disconnects the socket is gone, and a bare + // res.write would throw "write after end" from inside the agent loop. + const send = (event: object) => { + if (res.writableEnded || res.destroyed) return; res.write(`data: ${JSON.stringify(event)}\n\n`); + }; + // Per-turn cancellation: if the client disconnects (taps Stop / closes the + // app), abort THIS turn's agent so it stops burning tokens and the next + // queued turn isn't stuck behind an abandoned run. + const turnAbort = new AbortController(); + req.on("close", () => turnAbort.abort()); const onMood = (slug: string) => send({ type: "mood", slug }); moodBus.on("mood", onMood); // Send the current mood immediately so a fresh tab knows where to start. @@ -1970,6 +1979,13 @@ self.addEventListener('fetch', (event) => { // this guard the catch below would send a second, identical error event // (the client used to render the same error twice). let errorSent = false; + // Track whether the turn produced anything visible. A model call can + // return a clean, successful turn with zero text and zero tools (e.g. a + // provider hiccup returns an empty completion). Without a signal the + // client can only guess ("(no response)"); we emit an explicit `empty` + // event so it can offer a retry instead of a dead end. + let anyText = false; + let anyTool = false; try { // Use the freshest cached prompt for this chat. If soul / skills / // memory changed since the previous chat, rebuildPrompt() picks it up. @@ -1980,7 +1996,8 @@ self.addEventListener('fetch', (event) => { tools: opts.tools, toolCtx: { cwd: process.cwd(), - signal: abort.signal, + // Abort on server shutdown OR this client disconnecting (Stop). + signal: AbortSignal.any([abort.signal, turnAbort.signal]), log: () => {}, }, history, @@ -1989,8 +2006,12 @@ self.addEventListener('fetch', (event) => { model: opts.model, thinking: opts.thinking, onEvent: (ev) => { - if (ev.type === "text_delta" && ev.text) + if (ev.type === "text_delta" && ev.text) { + anyText = true; send({ type: "text", text: ev.text }); + } + if (ev.type === "tool_call_start") + anyTool = true; if (ev.type === "tool_call_start") send({ type: "tool_start", @@ -2051,6 +2072,7 @@ self.addEventListener('fetch', (event) => { }); history.length = 0; history.push(...result.history); + if (!anyText && !anyTool && !errorSent) send({ type: "empty" }); send({ type: "done" }); } catch (err) { if (!errorSent) send({ type: "error", message: (err as Error).message });