Skip to content
Closed
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
39 changes: 25 additions & 14 deletions packaging/ios-companion/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,22 +14,32 @@ uses all live in `src/web/server.ts`.
**Compile-verified MVP.** The app builds clean for the iOS Simulator (Xcode 26,
iOS 17+ target). It covers:

- **Dispatch** — roster from `/api/agents/sessions` + live `/events` SSE; rows keyed
off `controllable` / `resumable`; per-session control: managed **approve/deny** ·
send · cancel, PTY send · **output** · cancel, and **adopt (resume)** for idle
claude sessions (handles the 409/403 the server returns).
- **Chat** — streams `POST /chat`.
- **Settings** — pairing (**scan** the Mac's QR code via the camera, or paste a
`lisa-pair://…` / `?token=` string → Keychain), ntfy push registration, and a
read-only view of the remote-control policy.
- **Glance** — a **Live Activity / Dynamic Island** for a pinned agent (Lock Screen +
compact / expanded / minimal Dynamic Island) and a **home-screen Widget** showing
active / stuck agent counts, both in a WidgetKit extension target. The Widget renders
a counts-only snapshot the app shares through an App Group — the auth token stays in
the Keychain and no session content ever reaches the extension.
- **Dispatch** — roster from `/api/agents/sessions` + live `/events` SSE (auto-reconnect
with backoff + a full resync on foreground); rows keyed off `controllable` /
`resumable`; per-session control: managed **approve/deny** · send · cancel, PTY send ·
**output** · cancel, and **adopt (resume)** for idle claude sessions (handles the
409/403 the server returns). The toolbar opens the **dispatch ledger** (Lisa's own
fire-and-forget runs, with a per-entry log tail).
- **Chat** — streams `POST /chat`, with a live **mood** indicator (mood SSE).
- **Reve** — "while you were away" note + current desire, an agent-activity **recap**
(2h/8h/24h), and dismissable advisor suggestions.
- **Sense** — ambient-signal **consent** (revoke-only from the phone; granting stays a
Mac action) + recent sense events.
- **Settings** — pairing (**scan** the Mac's QR code, or paste a `lisa-pair://…` /
`?token=` string → Keychain), ntfy push, read-only remote-control policy, **paired
devices**, an optional **Face ID / passcode** lock, and read-only **Inspect Lisa**
(Soul / Memory / Skills / Tools).
- **Glance** — a **Live Activity / Dynamic Island** for a pinned agent and a
**home-screen / lock-screen Widget** (systemSmall/Medium + accessory families) showing
active / stuck counts, in a WidgetKit extension. The Widget renders a counts-only
snapshot the app shares through an App Group — the token stays in the Keychain and no
session content reaches the extension — and tapping it deep-links into the app.
- **Deep-links** — `lisapocket://` opens the app from a Widget tap or an ntfy push
(the push carries a `Click` to the relevant session).

**Not yet** (follow-ups): live Live-Activity updates via APNs, so a pinned agent stays
fresh while backgrounded — needs an Apple push key; ntfy push works today.
fresh while backgrounded — needs an Apple push key; ntfy push works today. A real mood
**portrait** (the chip is a stand-in; the iOS app has no asset catalog yet).

> Like the Live Activity, the home-screen Widget is **compile-verified on the
> Simulator**. Its data only flows on a **signed** build: App Group capabilities aren't
Expand All @@ -41,6 +51,7 @@ fresh while backgrounded — needs an Apple push key; ntfy push works today.
```sh
brew install xcodegen # one-time
./build.sh # xcodegen generate + xcodebuild for the simulator
./build.sh test # run the LisaPocketTests logic tests on a simulator
```

The Xcode project is generated from `project.yml` (not committed). Simulator builds
Expand Down
19 changes: 15 additions & 4 deletions packaging/ios-companion/Sources/App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,25 @@ struct LisaPocketApp: App {
}

struct RootView: View {
@EnvironmentObject var app: AppState
@Environment(\.scenePhase) private var scenePhase
var body: some View {
TabView {
TabView(selection: $app.selectedTab) {
RosterView()
.tabItem { Label("Dispatch", systemImage: "cpu") }
.tabItem { Label("Dispatch", systemImage: "cpu") }.tag(0)
ChatView()
.tabItem { Label("Chat", systemImage: "bubble.left.and.bubble.right") }
.tabItem { Label("Chat", systemImage: "bubble.left.and.bubble.right") }.tag(1)
ReveView()
.tabItem { Label("Reve", systemImage: "moon.stars") }.tag(2)
SenseView()
.tabItem { Label("Sense", systemImage: "sensor.tag.radiowaves.forward") }.tag(3)
SettingsView()
.tabItem { Label("Settings", systemImage: "gearshape") }
.tabItem { Label("Settings", systemImage: "gearshape") }.tag(4)
}
.onOpenURL { app.handleDeepLink($0) }
.overlay { if app.locked { LockView() } }
.onChange(of: scenePhase) { _, phase in
if phase == .background { app.lockIfEnabled() } // re-arm when leaving foreground
}
}
}
71 changes: 71 additions & 0 deletions packaging/ios-companion/Sources/AppState.swift
Original file line number Diff line number Diff line change
@@ -1,10 +1,28 @@
import Foundation
import SwiftUI
import LocalAuthentication

/// A roster session a deep-link wants to open (agent + sessionId).
struct PendingNav: Equatable { var agent: String; var id: String }

/// Where a `lisapocket://` deep-link points.
enum DeepLinkRoute: Equatable {
case ignore // not our scheme
case roster // lisapocket://roster (or unknown host)
case session(agent: String, id: String) // lisapocket://session?agent=&id=
}

@MainActor
final class AppState: ObservableObject {
@Published var config: ServerConfig
@Published private(set) var client: LisaClient
/// Drives the TabView selection (0=Dispatch … 4=Settings) — deep-links set it.
@Published var selectedTab = 0
/// Set by a `lisapocket://session?…` deep-link; RosterView consumes + clears it.
@Published var pendingSession: PendingNav?
/// Optional Face ID / passcode gate over the app (token grants full control).
@Published var biometricLockEnabled: Bool
@Published var locked: Bool

init() {
let d = UserDefaults.standard
Expand All @@ -13,6 +31,9 @@ final class AppState: ObservableObject {
let cfg = ServerConfig(host: host, port: storedPort == 0 ? 5757 : storedPort, token: TokenStore.load())
self.config = cfg
self.client = LisaClient(config: cfg)
let lockOn = d.bool(forKey: "lisa.biometricLock")
self.biometricLockEnabled = lockOn
self.locked = lockOn && cfg.token != nil // require unlock at launch when armed
}

func update(host: String, port: Int, token: String?) {
Expand All @@ -38,4 +59,54 @@ final class AppState: ObservableObject {
update(host: host, port: port, token: token)
return true
}

/// Route a `lisapocket://` deep-link (from a push Click or the home Widget).
/// `lisapocket://roster` → Dispatch tab; `lisapocket://session?agent=&id=` →
/// Dispatch tab + ask RosterView to open that session.
func handleDeepLink(_ url: URL) {
switch AppState.parseDeepLink(url) {
case .ignore: return
case .roster: selectedTab = 0
case .session(let agent, let id):
selectedTab = 0
pendingSession = PendingNav(agent: agent, id: id)
}
}

/// Pure parse of a deep-link into a route. `nonisolated` so it's testable off
/// the main actor. Unknown lisapocket hosts fall back to the roster.
nonisolated static func parseDeepLink(_ url: URL) -> DeepLinkRoute {
guard url.scheme == "lisapocket" else { return .ignore }
guard url.host == "session" else { return .roster }
let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? []
let agent = items.first { $0.name == "agent" }?.value
let id = items.first { $0.name == "id" }?.value
if let agent, let id, !agent.isEmpty, !id.isEmpty {
return .session(agent: agent, id: id)
}
return .roster
}

// ── biometric lock ──
func setBiometricLock(_ on: Bool) {
biometricLockEnabled = on
UserDefaults.standard.set(on, forKey: "lisa.biometricLock")
if !on { locked = false }
}

/// Re-arm the lock when the app leaves the foreground (called on background).
func lockIfEnabled() {
if biometricLockEnabled && config.token != nil { locked = true }
}

/// Prompt Face ID / Touch ID (falling back to the device passcode). If no auth
/// is available at all, don't trap the user — just unlock.
func unlock() async {
let ctx = LAContext()
var err: NSError?
guard ctx.canEvaluatePolicy(.deviceOwnerAuthentication, error: &err) else { locked = false; return }
if let ok = try? await ctx.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: "Unlock Lisa Pocket"), ok {
locked = false
}
}
}
63 changes: 63 additions & 0 deletions packaging/ios-companion/Sources/ChatView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,23 @@ import SwiftUI
final class ChatModel: ObservableObject {
@Published var transcript = ""
@Published var sending = false
@Published var mood = ""
private var task: Task<Void, Never>?
private var moodTask: Task<Void, Never>?

/// Seed the mood from a ping, then track the `mood` SSE for live changes.
func startMood(_ client: LisaClient) {
moodTask?.cancel()
moodTask = Task { @MainActor in
if let p = try? await client.islandPing() { mood = p.mood }
do {
for try await msg in client.eventsStream() where msg.type == "mood" {
if let s = msg.slug { mood = s }
}
} catch { /* stream dropped — reseeded on next appear */ }
}
}
func stopMood() { moodTask?.cancel(); moodTask = nil }

func send(_ text: String, client: LisaClient) {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
Expand Down Expand Up @@ -32,6 +48,12 @@ struct ChatView: View {
var body: some View {
NavigationStack {
VStack(spacing: 0) {
if !model.mood.isEmpty {
MoodChip(mood: model.mood)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal).padding(.vertical, 6)
Divider()
}
ScrollView {
Text(model.transcript.isEmpty ? "Say hi to Lisa." : model.transcript)
.frame(maxWidth: .infinity, alignment: .leading)
Expand All @@ -54,6 +76,47 @@ struct ChatView: View {
.padding()
}
.navigationTitle("Chat")
.task(id: app.config) { model.startMood(app.client) }
.onDisappear { model.stopMood() }
}
}
}

/// Lisa's current mood as a small chip. A stand-in for the mood *portrait* —
/// the data is wired (mood SSE); bundling the mac-client's portrait art is a
/// follow-up (the iOS app has no asset catalog yet).
struct MoodChip: View {
let mood: String
var body: some View {
HStack(spacing: 6) {
Image(systemName: symbol).foregroundStyle(color)
Text(mood.capitalized).font(.subheadline.weight(.medium))
}
.padding(.horizontal, 10).padding(.vertical, 5)
.background(color.opacity(0.12), in: Capsule())
}

private var symbol: String {
switch mood.lowercased() {
case "happy", "content", "joyful", "playful": return "face.smiling"
case "curious", "intrigued": return "sparkle.magnifyingglass"
case "proud": return "star.fill"
case "weary", "tired": return "moon.zzz"
case "frustrated", "annoyed": return "exclamationmark.bubble"
case "affectionate", "warm": return "heart.fill"
case "awe", "wonder": return "sparkles"
default: return "circle.fill"
}
}
private var color: Color {
switch mood.lowercased() {
case "happy", "content", "joyful", "playful": return .green
case "curious", "intrigued", "awe", "wonder": return .blue
case "proud": return .yellow
case "weary", "tired": return .gray
case "frustrated", "annoyed": return .red
case "affectionate", "warm": return .pink
default: return .secondary
}
}
}
87 changes: 87 additions & 0 deletions packaging/ios-companion/Sources/DispatchLedgerView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import SwiftUI

/// LISA's own fire-and-forget dispatches (the ledger), distinct from the
/// observed-agent roster (docs/IOS_COMPANION_PLAN.md §6.2): pid / task / alive,
/// from /api/dispatch/list, with a captured log tail per entry from
/// /api/dispatch/status. Reached from the Dispatch tab's toolbar.
struct DispatchLedgerView: View {
@EnvironmentObject var app: AppState
@State private var items: [DispatchView] = []
@State private var error: String?
@State private var loaded = false

var body: some View {
Group {
if !loaded {
ProgressView()
} else if let error {
ContentUnavailableView("Couldn't load", systemImage: "exclamationmark.triangle", description: Text(error))
} else if items.isEmpty {
ContentUnavailableView("No dispatches", systemImage: "tray",
description: Text("Lisa hasn't dispatched any agents recently."))
} else {
List(items) { d in
NavigationLink { DispatchDetailView(entry: d) } label: {
HStack(spacing: 10) {
Circle().fill(d.alive ? .blue : .gray).frame(width: 8, height: 8)
VStack(alignment: .leading, spacing: 2) {
Text(d.task).font(.subheadline).lineLimit(1)
Text("\(d.agent) · pid \(d.pid)\(d.alive ? " · alive" : "")")
.font(.caption2).foregroundStyle(.secondary)
}
}
}
}
}
}
.navigationTitle("Dispatches")
.refreshable { await load() }
.task { await load() }
}

private func load() async {
do { items = try await app.client.dispatchList(); error = nil }
catch { self.error = (error as? LocalizedError)?.errorDescription ?? "\(error)" }
loaded = true
}
}

struct DispatchDetailView: View {
@EnvironmentObject var app: AppState
let entry: DispatchView
@State private var tail = ""
@State private var status: DispatchStatus?

var body: some View {
List {
Section("Dispatch") {
LabeledContent("Agent", value: entry.agent)
LabeledContent("Task", value: entry.task)
LabeledContent("pid", value: String(entry.pid))
LabeledContent("Alive", value: (status?.alive ?? entry.alive) ? "yes" : "no")
LabeledContent("cwd", value: entry.cwd)
}
Section("Log tail") {
if tail.isEmpty {
Text(entry.hasLog ? "…" : "No log captured.").font(.caption).foregroundStyle(.secondary)
} else {
ScrollView(.horizontal) {
Text(tail).font(.system(.caption2, design: .monospaced))
}
}
}
}
.navigationTitle(entry.agent)
.navigationBarTitleDisplayMode(.inline)
.refreshable { await load() }
.task { await load() }
}

private func load() async {
// status is gated by the control policy; tolerate a 403/404 (leave tail empty).
if let s = try? await app.client.dispatchStatus(id: entry.id) {
status = s
tail = s.tail ?? ""
}
}
}
Loading