Skip to content
Merged
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
187 changes: 153 additions & 34 deletions packaging/ios-companion/Sources/ChatView.swift
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -21,6 +28,9 @@ final class ChatModel: ObservableObject {
private var page = 0
private var task: Task<Void, Never>?
private var moodTask: Task<Void, Never>?
/// 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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
}
Expand All @@ -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) }
}
Expand All @@ -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) }
Expand All @@ -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) }
}
Expand All @@ -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.
Expand Down
28 changes: 25 additions & 3 deletions src/web/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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,
Expand All @@ -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",
Expand Down Expand Up @@ -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 });
Expand Down