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
11 changes: 11 additions & 0 deletions companion/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,17 @@ const ALLOWED: ReadonlyArray<{ method: string; path: RegExp }> = [
// recent activity. No settings, no transcript — read on open and on
// pull-to-refresh.
{ method: "GET", path: /^\/api\/bots\/[\w-]+\/overview$/ },
// Read-only: what the bot did, with the outcome, built from logs that
// already exist (server/activity.ts). No settings reachable through it.
{ method: "GET", path: /^\/api\/bots\/[\w-]+\/activity$/ },
// The section's shared team memory: people, places, decisions and terms.
// Reading, adding, answering a proposal, editing a detail, and removing
// are all content edits, not execution policy — the same line the profile
// and sidebar-section routes draw. The section rides as a query string.
{ method: "GET", path: /^\/api\/team-memory$/ },
{ method: "POST", path: /^\/api\/team-memory$/ },
{ method: "PATCH", path: /^\/api\/team-memory\/[\w-]+$/ },
{ method: "DELETE", path: /^\/api\/team-memory\/[\w-]+$/ },
// Paired-safe profile subset. The harness route itself rejects fields
// outside identity, standing instructions (soul, byte-capped), avatar,
// notifications, and voice preferences.
Expand Down
8 changes: 8 additions & 0 deletions companion/test/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ describe("what the app may do", () => {
["POST", "/api/bots/bot_123/always-allow"],
["POST", "/api/bots/bot_123/messages/msg_2/edit"],
["GET", "/api/bots/bot_123/overview"],
// Read-only: what the bot did, with the outcome (server/activity.ts).
["GET", "/api/bots/bot_123/activity"],
// The section's shared team memory: read it, add an entry, answer or
// edit one, remove one. Content, not execution policy.
["GET", "/api/team-memory"],
["POST", "/api/team-memory"],
["PATCH", "/api/team-memory/entry_1"],
["DELETE", "/api/team-memory/entry_1"],
["POST", "/api/bots/bot_123/active-branch"],
["POST", "/api/bots/bot_123/tasks"],
["POST", "/api/bots/bot_123/tasks/th_1"],
Expand Down
10 changes: 10 additions & 0 deletions ios/App/AgentProfileView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,16 @@ struct AgentProfileView: View {
} label: {
Label("What this bot does", systemImage: "list.bullet.rectangle")
}
NavigationLink {
BotActivityView(bot: current)
} label: {
Label("Activity", systemImage: "checklist")
}
NavigationLink {
TeamMemoryView(section: current.section ?? "")
} label: {
Label("Team memory", systemImage: "brain")
}
TextField("Name", text: $name)
.textInputAutocapitalization(.words)
TextField("Title", text: $title)
Expand Down
145 changes: 145 additions & 0 deletions ios/App/BotActivityView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import CompanionCore
import SwiftUI

/// What one bot did, newest first, grouped by day: every tool it used and
/// every approval it asked for, each with the outcome. The phone twin of the
/// desktop's Activity panel; read-only, like the overview beside it.
struct BotActivityView: View {
let bot: Bot
@EnvironmentObject private var session: Session
@State private var rows: [ActivityRow]?
@State private var loading = false
@State private var failed = false

private static let isoParser: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}()

private struct Day: Identifiable {
let id: String
let label: String
let rows: [ActivityRow]
}

private var days: [Day] {
guard let rows else { return [] }
let calendar = Calendar.current
var result: [Day] = []
for row in rows {
let date = Self.isoParser.date(from: row.at) ?? ISO8601DateFormatter().date(from: row.at) ?? Date()
let key = calendar.startOfDay(for: date)
let id = "\(key.timeIntervalSince1970)"
if let last = result.last, last.id == id {
result[result.count - 1] = Day(id: id, label: last.label, rows: last.rows + [row])
} else {
let label = calendar.isDateInToday(date) ? String(localized: "Today")
: calendar.isDateInYesterday(date) ? String(localized: "Yesterday")
: date.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated))
result.append(Day(id: id, label: label, rows: [row]))
}
}
return result
}

var body: some View {
List {
if let rows, rows.isEmpty {
Section {
Text("Nothing yet. Once \(bot.name) runs a tool or asks for an approval, it shows up here.")
.foregroundStyle(.secondary)
}
}
ForEach(days) { day in
Section(day.label) {
ForEach(Array(day.rows.enumerated()), id: \.offset) { _, row in
ActivityRowView(row: row)
}
}
}
if failed {
Section {
ContentUnavailableView("Couldn't load", systemImage: "wifi.exclamationmark")
}
}
}
.navigationTitle("\(bot.name) activity")
.overlay { if loading && rows == nil { ProgressView() } }
.task(id: session.connection?.id) {
rows = nil
failed = false
await load()
}
.refreshable { await load() }
}

private func load() async {
let connectionID = session.connection?.id
loading = true
defer {
if !Task.isCancelled, session.connection?.id == connectionID { loading = false }
}
let loaded = await session.botActivity(for: bot)
guard !Task.isCancelled, session.connection?.id == connectionID else { return }
if let loaded {
rows = loaded
failed = false
} else {
failed = true
}
}
}

private struct ActivityRowView: View {
let row: ActivityRow

private var time: String {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let date = formatter.date(from: row.at) ?? ISO8601DateFormatter().date(from: row.at)
return date.map { $0.formatted(date: .omitted, time: .shortened) } ?? ""
}

private var chip: (text: LocalizedStringKey, color: Color) {
switch row.outcome {
case "ran", "allowed": return ("\(row.outcome == "ran" ? "Ran" : "Allowed")", .green)
case "failed", "denied": return ("\(row.outcome == "failed" ? "Failed" : "Denied")", .red)
case "running": return ("Running", .accentColor)
case "waiting": return ("Needs you", .orange)
default: return ("\(row.outcome)", .secondary)
}
}

var body: some View {
HStack(alignment: .top, spacing: 10) {
Text(time)
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
.frame(width: 44, alignment: .leading)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
if let app = row.app {
Text(app).fontWeight(.medium)
Text("·").foregroundStyle(.secondary)
}
Text(row.label).lineLimit(1)
}
if let summary = row.summary, !summary.isEmpty {
Text(summary)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(2)
}
}
Spacer(minLength: 6)
Text(chip.text)
.font(.caption2.weight(.medium))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(chip.color.opacity(0.15), in: Capsule())
.foregroundStyle(chip.color)
}
.padding(.vertical, 2)
}
}
49 changes: 49 additions & 0 deletions ios/App/Session.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1676,6 +1676,55 @@ final class Session: ObservableObject {
}
}

/// What the bot did, with the outcome. Read-only, like the overview.
func botActivity(for bot: Bot) async -> [ActivityRow]? {
guard let client else { return nil }
let connectionID = connection?.id
do {
let rows = try await client.activity(botId: bot.id)
guard !Task.isCancelled, connection?.id == connectionID else { return nil }
return rows
} catch {
guard !Task.isCancelled, connection?.id == connectionID else { return nil }
guard !(error is CancellationError), (error as? URLError)?.code != .cancelled else { return nil }
actionError = error.localizedDescription
return nil
}
}

// MARK: - Team memory

/// The section's shared people, places, decisions and terms.
func teamMemory(section: String) async -> TeamMemoryPage? {
guard let client else { return nil }
let connectionID = connection?.id
do {
let page = try await client.teamMemory(section: section)
guard !Task.isCancelled, connection?.id == connectionID else { return nil }
return page
} catch {
guard !Task.isCancelled, connection?.id == connectionID else { return nil }
guard !(error is CancellationError), (error as? URLError)?.code != .cancelled else { return nil }
actionError = error.localizedDescription
return nil
}
}

/// One edit, and the page as it is afterwards; nil when it failed, with
/// the failure already shown.
func editTeamMemory(_ body: (CompanionClient) async throws -> [TeamMemoryEntry]) async -> [TeamMemoryEntry]? {
guard let client else { return nil }
do {
return try await body(client)
} catch let error as APIError where error.isUnauthorized {
status = .unauthorized
return nil
} catch {
actionError = error.localizedDescription
return nil
}
}

// MARK: - Routines

func loadRoutines() async -> (routines: [Routine], runs: [RoutineRun]) {
Expand Down
Loading
Loading