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
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ public enum SidebarRows {
}.sorted { ($0.1, $0.0.id) < ($1.1, $1.0.id) }
for (surface, _) in joined {
var row = Row(id: "surface:\(surface.id)", nodeId: "surface:\(surface.id)", kind: .space,
label: surface.name, mark: .glyph(Rows.surfaceGlyph(surface.type, access: surface.access)), agentId: surface.chatAgentId, targetKind: "surface")
label: surface.name, mark: .glyph(Rows.surfaceGlyph(surface.type, access: surface.access, policyVault: surface.policyVault)), agentId: surface.chatAgentId, targetKind: "surface")
row.targetId = surface.id
row.surfaceType = surface.type
rows.append(row)
Expand Down
11 changes: 7 additions & 4 deletions packages/happy-ios/Sources/HappyCore/Agents/Rows.swift
Original file line number Diff line number Diff line change
Expand Up @@ -351,16 +351,19 @@ public enum Rows {
}
if space.title == nil { return .seedling(seed: space.id) }
switch space.target.kind {
case "surface": return .glyph(surfaceGlyph(places.surface(space.target.id)?.type, access: places.surface(space.target.id)?.access))
case "surface":
let surface = places.surface(space.target.id)
return .glyph(surfaceGlyph(surface?.type, access: surface?.access, policyVault: surface?.policyVault))
case "task", "bot", "agent": return .placeholder(seed: space.target.id)
default: return .glyph("agents")
}
}

/// The glyph desktop's Create menu gives each kind of Surface; a vault
/// the person's role may not open (`access: "none"`) wears a lock.
public static func surfaceGlyph(_ type: String?, access: String? = nil) -> String {
if type == "vault", access == VaultAccess.Level.none.rawValue { return lockGlyph }
/// the person's role may not open, or the server's Policy vault, wears
/// a lock. The mark says nothing about whether its documents can be edited.
public static func surfaceGlyph(_ type: String?, access: String? = nil, policyVault: Bool? = nil) -> String {
if type == "vault", access == VaultAccess.Level.none.rawValue || policyVault == true { return lockGlyph }
return switch type {
case "todo": "tasks"
case "calendar": "calendar"
Expand Down
5 changes: 3 additions & 2 deletions packages/happy-ios/Sources/HappyCore/Agents/SearchShown.swift
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,9 @@ extension Search {
case "surface":
guard let surfaceId = result.surfaceId else { return nil }
let name = result.name ?? ""
let type = names.surfaces[surfaceId]?.type ?? result.surfaceType
shown = Shown(title: name, detail: surfaceTypeLabel(type), glyph: Rows.surfaceGlyph(type),
let surface = names.surfaces[surfaceId]
let type = surface?.type ?? result.surfaceType
shown = Shown(title: name, detail: surfaceTypeLabel(type), glyph: Rows.surfaceGlyph(type, access: surface?.access, policyVault: surface?.policyVault),
opens: .surface(id: surfaceId, type: type ?? "", name: name))
case "agent":
guard let agentId = result.agentId, result.archived != true, result.workspaceId != nil else { return nil }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -437,8 +437,8 @@ final class HomeViewController: UIViewController {
row = Row(id: id, nodeId: id, kind: .result, label: held.label, mark: .glyph(Self.glyph(held)))
runs[id] = { [weak self] in self?.open(held) }
case .surface(let surface):
row = Row(id: id, nodeId: id, kind: .result, label: surface.name, mark: .glyph(Rows.surfaceGlyph(surface.type)))
runs[id] = { [weak self] in self?.openSurface(AgentWire.Surface(id: surface.id, type: surface.type, name: surface.name), row: id) }
row = Row(id: id, nodeId: id, kind: .result, label: surface.name, mark: .glyph(Rows.surfaceGlyph(surface.type, access: surface.access, policyVault: surface.policyVault)))
runs[id] = { [weak self] in self?.openSurface(surface, row: id) }
case .artifact(let artifact):
row = Row(id: id, nodeId: id, kind: .result, label: artifact.title, mark: .glyph(artifact.live ? "globe-outline" : HappyCore.Search.artifactGlyph(artifact.type)))
runs[id] = { [weak self] in self?.openArtifact(id: artifact.id, type: artifact.type, title: artifact.title, row: id) }
Expand Down Expand Up @@ -477,10 +477,12 @@ final class HomeViewController: UIViewController {

/// A sidebar row's glyph among the results, as the computer's result
/// for it would have.
private static func glyph(_ row: Row) -> String {
static func glyph(_ row: Row) -> String {
if row.kind == .session { return "chatbubbles-outline" }
switch row.targetKind {
case "surface": return Rows.surfaceGlyph(row.surfaceType)
case "surface":
if case .glyph(let glyph) = row.mark { return glyph }
return Rows.surfaceGlyph(row.surfaceType)
case "task", "bot", "agent": return "agents"
default:
if case .glyph(let glyph) = row.mark { return glyph }
Expand Down Expand Up @@ -556,7 +558,8 @@ final class HomeViewController: UIViewController {
}
let actions = HappyCore.Search.ranked(quickActions().filter { !$0.id.hasPrefix("new-work") || session?.gate.allows(.contentWrite) ?? true }, query: query) { $0.title }
let computer = session?.accountID
let entries = SettingsIndex.computer + (session.map(ComputeViewController.offered) == true ? [SettingsIndex.computes] : [])
let entries = SettingsIndex.computer(isCloud: session?.isCloud == true)
+ (session.map(ComputeViewController.offered) == true ? [SettingsIndex.computes] : [])
let settings = HappyCore.Search.ranked(entries, query: query) { $0.title }
return [
section("actions", Strings.quickActions, actions.map { ($0.id, $0.title, $0.glyph, $0.run) }),
Expand Down
5 changes: 5 additions & 0 deletions packages/happy-ios/Sources/HappyUI/Agents/SettingsIndex.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ enum SettingsIndex {
[profile, changeAgentName, changeAgentAvatar, supportBoba, appearance, hideProjects, shimmerInLowPower, agentVoice, agents, dictationOpensTask, multilingualDictation, dataStorage, connection, analytics, connectAgent, addAccount] + aboutRows
}

/// Cloud has no project settings; paired and self-hosted agents keep them.
static func computer(isCloud: Bool) -> [Entry] {
computer.filter { !isCloud || $0.id != hideProjects.id }
}

/// What a Happy account's settings show under its accounts card, in
/// their order: the same places as a computer's, with the account's own
/// pages beside them. Sign out is left out, as Disconnect is.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -644,8 +644,9 @@ final class SettingsViewController: UIViewController, UIScrollViewDelegate, UISe
themes.map { theme in
Row(id: "theme-\(theme)", title: Self.themeName(theme), value: theme == current ? "✓" : nil, accessory: .none, action: { [weak self] in self?.setTheme(theme) })
},
[Row(SettingsIndex.hideProjects, accessory: .toggle, isOn: projectsHidden, toggled: { [weak self] on in
guard let self, let engine = context.account.agentConnections.syncEngine(for: id) else { return }
HappyCloud.isCloud(context.account.agentConnections.server(for: id)) ? [] : [Row(SettingsIndex.hideProjects, accessory: .toggle, isOn: projectsHidden, toggled: { [weak self] on in
guard let self, shown?.id == id, !HappyCloud.isCloud(context.account.agentConnections.server(for: id)),
let engine = context.account.agentConnections.syncEngine(for: id) else { return }
projectsHidden = on
Task { try? await engine.setProjectsHidden(on) }
})],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import Foundation
import XCTest
@testable import HappyCore

final class PolicyVaultPresentationTests: XCTestCase {
func testJoinedPolicyVaultUsesItsFlagRatherThanItsName() throws {
let data = Data(#"""
{"cursor":"c","profile":{"userId":"me"},"userSettings":[],"projects":[],"workspaces":[],
"surfaces":[
{"id":"policy","name":"Instructions","type":"vault","members":[{"userId":"me","orderKey":"a"}],"access":"write","policyVault":true},
{"id":"ordinary","name":"Policy","type":"vault","members":[{"userId":"me","orderKey":"b"}],"access":"write"}]}
"""#.utf8)
let rows = SidebarRows.make(try JSONDecoder().decode(DesktopSnapshot.self, from: data), settings: [:])
XCTAssertEqual(rows.first { $0.targetId == "policy" }?.mark, .glyph(Rows.lockGlyph))
XCTAssertEqual(rows.first { $0.targetId == "ordinary" }?.mark, .glyph("vault"))
}

func testPolicyVaultSpaceKeepsItsLock() {
let surface = AgentWire.Surface(id: "policy", type: "vault", name: "Instructions", access: "write", policyVault: true)
let space = AgentWire.Space(id: "s", title: surface.name, icon: nil, target: .init(kind: "surface", id: surface.id, agentId: nil, workspaceId: nil), version: 1, createdAt: 0, updatedAt: 0)
XCTAssertEqual(Rows.mark(space, places: Places(surfaces: [surface])), .glyph(Rows.lockGlyph))
XCTAssertTrue(surface.vaultAccess.canWrite(.open), "the icon does not make policy documents read-only")
}

func testRemoteSurfaceResultsPreservePolicyAndAccessLocks() throws {
let result = try JSONDecoder().decode(Search.Result.self, from: Data(#"{"type":"surface","key":"surface:v","rank":1,"surfaceId":"v","surfaceType":"vault","name":"Policy"}"#.utf8))
for (access, policy, glyph) in [("write", true, Rows.lockGlyph), ("none", false, Rows.lockGlyph), ("read", false, "vault")] {
let surface = AgentWire.Surface(id: "v", type: "vault", name: "Policy", access: access, policyVault: policy)
XCTAssertEqual(Search.shown(result, names: .init(surfaces: ["v": surface]))?.glyph, glyph)
}
XCTAssertEqual(Search.shown(result, names: .init())?.glyph, "vault", "a name is not policy metadata")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
#if canImport(UIKit)
import Foundation
@testable import HappyCore
import HappyWire
@testable import HappyUI
import UIKit
import XCTest

private struct CleanupTransport: AgentTransport {
func request(method: String, path: String, credential: String, body: Data?, headers: [String: String]) async throws -> HTTP1Response {
if path == "/v0/surfaces" {
return HTTP1Response(status: 200, body: Data(#"{"surfaces":[{"id":"policy","type":"vault","name":"Instructions","access":"write","policyVault":true}]}"#.utf8))
}
return HTTP1Response(status: 404)
}
func stream(path: String, credential: String, headers: [String: String]) async throws -> HTTP1Stream { throw URLError(.notConnectedToInternet) }
func reset() async {}
func close() async {}
}

@MainActor
final class HostedCloudSettingsCleanupTests: XCTestCase {
private func rig(server: String?) async throws -> (AppContext, String) {
let account = AccountController(serverConfig: ServerConfig(storage: InMemoryKeyValueStore()), clientID: "cleanup-test",
credentialStore: InMemoryCredentialStore(), storeDirectory: nil,
agentConnectionCredentialStore: InMemoryAgentConnectionCredentialStore(), agentTransport: { _ in CleanupTransport() })
await account.agentConnections.setForeground(false)
let connection: AgentConnection
if let server {
connection = try await account.agentConnections.addServer(AgentOrigin(server),
signIn: ServerSignInRecord(method: HappyCloud.method(server: server), grant: OAuthGrant(clientId: "test"), userId: "me"),
access: OAuthAccess(token: "test", expiresAt: nil), name: "Settings test")
} else {
let credentials = try JSONDecoder().decode(AgentConnectionCredentials.self, from: Data(#"{"address":"cleanup-test","port":1,"deviceID":"\#(UUID().uuidString)","deviceToken":"test"}"#.utf8))
connection = try await account.agentConnections.add(credentials, name: "Paired test")
}
addTeardownBlock { @MainActor in
Session.forget(connection.id)
try? await account.agentConnections.remove(connection.id)
}
return (AppContext(account: account, variant: .preview, version: "test"), connection.id)
}

private func host(_ controller: UIViewController) async throws -> UINavigationController {
let navigation = UINavigationController(rootViewController: controller)
let scene = try await hostedActiveScene()
let previous = scene.keyWindow
let window = hostedKeyWindow(in: scene, root: navigation)
addTeardownBlock { @MainActor in window.isHidden = true; previous?.makeKey() }
window.layoutIfNeeded()
return navigation
}

private func until(_ condition: () -> Bool) async throws {
let deadline = ContinuousClock.now + .seconds(5)
while !condition(), ContinuousClock.now < deadline { try await Task.sleep(for: .milliseconds(20)) }
XCTAssertTrue(condition())
}

private func checkProjects(server: String?, offered: Bool) async throws {
let (context, id) = try await rig(server: server)
let settings = SettingsViewController(context: context, computer: id)
let navigation = try await host(settings)
settings.tapRow("appearance")
try await until { navigation.topViewController is SettingsPageViewController && navigation.transitionCoordinator == nil }
let appearance = try XCTUnwrap(navigation.topViewController as? SettingsPageViewController)
XCTAssertEqual(appearance.shownRowIDs.joined().contains("hide-projects"), offered)

navigation.popToRootViewController(animated: false)
let search = try XCTUnwrap(settings.navigationItem.searchController)
settings.willPresentSearchController(search)
search.searchBar.text = "Hide projects"
settings.updateSearchResults(for: search)
XCTAssertEqual(settings.shownRowIDs.joined().contains("hide-projects"), offered)
settings.willDismissSearchController(search)

let home = HomeViewController(context: context, computer: id)
navigation.setViewControllers([home], animated: false)
home.loadViewIfNeeded()
home.beginSearch()
home.search("Hide projects")
let results = try XCTUnwrap(home.view.subviews.compactMap { $0 as? HomeSearchResultsView }.first)
XCTAssertEqual(results.list.shownRows.contains { $0.id == "search:settings:hide-projects" }, offered)
}

func testCloudHasNoProjectVisibilitySettingOrSearchResult() async throws {
try await checkProjects(server: HappyCloud.server(named: "settings-test"), offered: false)
}

func testSelfHostedKeepsProjectVisibility() async throws {
try await checkProjects(server: "https://self-hosted.example", offered: true)
}

func testPairedAgentKeepsProjectVisibility() async throws {
try await checkProjects(server: nil, offered: true)
}

func testLocalSearchKeepsThePolicyVaultLock() async throws {
let (context, id) = try await rig(server: HappyCloud.server(named: "settings-test"))
let home = HomeViewController(context: context, computer: id)
_ = try await host(home)
home.beginSearch()
home.search("Instructions")
let results = try XCTUnwrap(home.view.subviews.compactMap { $0 as? HomeSearchResultsView }.first)
try await until { results.list.shownRows.contains { $0.label == "Instructions" } }
XCTAssertEqual(results.list.shownRows.first { $0.label == "Instructions" }?.mark, .glyph(Rows.lockGlyph))
}
}
#endif
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#if canImport(UIKit)
import HappyCore
@testable import HappyUI
import XCTest

@MainActor
final class PolicyVaultSearchTests: XCTestCase {
func testHomeSearchKeepsAnExistingSurfaceLock() {
var row = Row(id: "surface:v", nodeId: "surface:v", kind: .space, label: "Instructions", mark: .glyph(Rows.lockGlyph), targetKind: "surface")
row.surfaceType = "vault"
XCTAssertEqual(HomeViewController.glyph(row), Rows.lockGlyph)
row.mark = .none
XCTAssertEqual(HomeViewController.glyph(row), "vault", "unknown metadata keeps the ordinary fallback")
}

func testProjectVisibilitySearchIsCloudSpecific() {
XCTAssertFalse(SettingsIndex.computer(isCloud: true).contains { $0.id == "hide-projects" })
XCTAssertTrue(SettingsIndex.computer(isCloud: false).contains { $0.id == "hide-projects" })
XCTAssertEqual(SettingsIndex.computer(isCloud: true).map(\.id), SettingsIndex.computer.filter { $0.id != "hide-projects" }.map(\.id))
}
}
#endif
Loading