feat(ios): read-only workspace Files browser on the Agents surface (#100767)

* feat(ios): add read-only workspace Files browser to the Agents surface

* fix(ios): keep nav bar visible on pushed workspace file routes

* fix(ios): make workspace file navigation stack-safe

* docs(ios): note workspace file browser

* fix(ios): isolate workspace share exports

* chore(i18n): refresh iOS Files inventory

* refactor(ios): encode workspace requests before dispatch

* chore(i18n): refresh iOS inventory after rebase
This commit is contained in:
Peter Steinberger 2026-07-06 17:47:52 +01:00 committed by GitHub
parent 84f21bd9fc
commit 077e35f3b2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 578 additions and 66 deletions

View file

@ -7355,7 +7355,7 @@
},
{
"kind": "ui-named-argument",
"line": 64,
"line": 72,
"path": "apps/ios/Sources/Design/AgentProTab+Destinations.swift",
"source": "Skills",
"surface": "apple",
@ -7363,7 +7363,7 @@
},
{
"kind": "ui-named-argument",
"line": 110,
"line": 118,
"path": "apps/ios/Sources/Design/AgentProTab+Destinations.swift",
"source": "Cron Jobs",
"surface": "apple",
@ -7371,7 +7371,7 @@
},
{
"kind": "ui-named-argument",
"line": 140,
"line": 148,
"path": "apps/ios/Sources/Design/AgentProTab+Destinations.swift",
"source": "Usage",
"surface": "apple",
@ -7523,7 +7523,23 @@
},
{
"kind": "ui-named-argument",
"line": 209,
"line": 192,
"path": "apps/ios/Sources/Design/AgentProTab+Overview.swift",
"source": "Files",
"surface": "apple",
"id": "native.apple.f48d33a93f53ae56"
},
{
"kind": "ui-named-argument",
"line": 194,
"path": "apps/ios/Sources/Design/AgentProTab+Overview.swift",
"source": "Workspace files",
"surface": "apple",
"id": "native.apple.b84aa382768fe819"
},
{
"kind": "ui-named-argument",
"line": 216,
"path": "apps/ios/Sources/Design/AgentProTab+Overview.swift",
"source": "Dreaming",
"surface": "apple",
@ -7531,7 +7547,7 @@
},
{
"kind": "ui-named-argument",
"line": 223,
"line": 230,
"path": "apps/ios/Sources/Design/AgentProTab+Overview.swift",
"source": "Scheduled Work",
"surface": "apple",
@ -7539,7 +7555,7 @@
},
{
"kind": "conditional-branch",
"line": 302,
"line": 309,
"path": "apps/ios/Sources/Design/AgentProTab+Overview.swift",
"source": "Selected agent",
"surface": "apple",
@ -7547,7 +7563,7 @@
},
{
"kind": "conditional-branch",
"line": 302,
"line": 309,
"path": "apps/ios/Sources/Design/AgentProTab+Overview.swift",
"source": "Selects this agent",
"surface": "apple",
@ -7555,7 +7571,7 @@
},
{
"kind": "conditional-branch",
"line": 443,
"line": 450,
"path": "apps/ios/Sources/Design/AgentProTab+Overview.swift",
"source": "Cron unavailable",
"surface": "apple",
@ -7563,7 +7579,7 @@
},
{
"kind": "conditional-branch",
"line": 443,
"line": 450,
"path": "apps/ios/Sources/Design/AgentProTab+Overview.swift",
"source": "No scheduled jobs",
"surface": "apple",
@ -7571,7 +7587,7 @@
},
{
"kind": "conditional-branch",
"line": 446,
"line": 453,
"path": "apps/ios/Sources/Design/AgentProTab+Overview.swift",
"source": "Connect a gateway to load scheduled work.",
"surface": "apple",
@ -7579,7 +7595,7 @@
},
{
"kind": "conditional-branch",
"line": 446,
"line": 453,
"path": "apps/ios/Sources/Design/AgentProTab+Overview.swift",
"source": "The gateway has no visible cron jobs.",
"surface": "apple",
@ -7587,7 +7603,7 @@
},
{
"kind": "conditional-branch",
"line": 566,
"line": 573,
"path": "apps/ios/Sources/Design/AgentProTab+Overview.swift",
"source": "Loading skill status.",
"surface": "apple",
@ -7595,7 +7611,7 @@
},
{
"kind": "conditional-branch",
"line": 566,
"line": 573,
"path": "apps/ios/Sources/Design/AgentProTab+Overview.swift",
"source": "Skill status is available from the gateway.",
"surface": "apple",
@ -7603,7 +7619,7 @@
},
{
"kind": "conditional-branch",
"line": 588,
"line": 595,
"path": "apps/ios/Sources/Design/AgentProTab+Overview.swift",
"source": "Instance presence is available.",
"surface": "apple",
@ -7611,7 +7627,7 @@
},
{
"kind": "conditional-branch",
"line": 588,
"line": 595,
"path": "apps/ios/Sources/Design/AgentProTab+Overview.swift",
"source": "Loading instance presence.",
"surface": "apple",
@ -7619,7 +7635,7 @@
},
{
"kind": "conditional-branch",
"line": 607,
"line": 614,
"path": "apps/ios/Sources/Design/AgentProTab+Overview.swift",
"source": "\\(cronStatus.jobs)",
"surface": "apple",
@ -7627,7 +7643,7 @@
},
{
"kind": "conditional-branch",
"line": 613,
"line": 620,
"path": "apps/ios/Sources/Design/AgentProTab+Overview.swift",
"source": "Cron status is available.",
"surface": "apple",
@ -7635,7 +7651,7 @@
},
{
"kind": "conditional-branch",
"line": 613,
"line": 620,
"path": "apps/ios/Sources/Design/AgentProTab+Overview.swift",
"source": "Loading cron status.",
"surface": "apple",
@ -7643,7 +7659,7 @@
},
{
"kind": "conditional-branch",
"line": 618,
"line": 625,
"path": "apps/ios/Sources/Design/AgentProTab+Overview.swift",
"source": "Scheduler disabled",
"surface": "apple",
@ -7651,7 +7667,7 @@
},
{
"kind": "conditional-branch",
"line": 618,
"line": 625,
"path": "apps/ios/Sources/Design/AgentProTab+Overview.swift",
"source": "Scheduler enabled",
"surface": "apple",
@ -7659,7 +7675,7 @@
},
{
"kind": "conditional-branch",
"line": 643,
"line": 650,
"path": "apps/ios/Sources/Design/AgentProTab+Overview.swift",
"source": "Loading recent usage.",
"surface": "apple",
@ -7667,7 +7683,7 @@
},
{
"kind": "conditional-branch",
"line": 643,
"line": 650,
"path": "apps/ios/Sources/Design/AgentProTab+Overview.swift",
"source": "Recent usage is available.",
"surface": "apple",
@ -7675,7 +7691,7 @@
},
{
"kind": "conditional-branch",
"line": 662,
"line": 669,
"path": "apps/ios/Sources/Design/AgentProTab+Overview.swift",
"source": "Background memory status is available.",
"surface": "apple",
@ -7683,7 +7699,7 @@
},
{
"kind": "conditional-branch",
"line": 662,
"line": 669,
"path": "apps/ios/Sources/Design/AgentProTab+Overview.swift",
"source": "Loading dreaming status.",
"surface": "apple",
@ -8067,7 +8083,7 @@
},
{
"kind": "conditional-branch",
"line": 58,
"line": 59,
"path": "apps/ios/Sources/Design/AgentProTab.swift",
"source": "Enabled",
"surface": "apple",
@ -8075,7 +8091,7 @@
},
{
"kind": "conditional-branch",
"line": 59,
"line": 60,
"path": "apps/ios/Sources/Design/AgentProTab.swift",
"source": "Off",
"surface": "apple",
@ -8083,7 +8099,7 @@
},
{
"kind": "conditional-branch",
"line": 60,
"line": 61,
"path": "apps/ios/Sources/Design/AgentProTab.swift",
"source": "Setup",
"surface": "apple",
@ -8091,7 +8107,7 @@
},
{
"kind": "conditional-branch",
"line": 61,
"line": 62,
"path": "apps/ios/Sources/Design/AgentProTab.swift",
"source": "Blocked",
"surface": "apple",
@ -8099,7 +8115,7 @@
},
{
"kind": "conditional-branch",
"line": 77,
"line": 78,
"path": "apps/ios/Sources/Design/AgentProTab.swift",
"source": "All",
"surface": "apple",
@ -8107,7 +8123,7 @@
},
{
"kind": "conditional-branch",
"line": 78,
"line": 79,
"path": "apps/ios/Sources/Design/AgentProTab.swift",
"source": "Online",
"surface": "apple",
@ -8115,12 +8131,68 @@
},
{
"kind": "conditional-branch",
"line": 79,
"line": 80,
"path": "apps/ios/Sources/Design/AgentProTab.swift",
"source": "Ready",
"surface": "apple",
"id": "native.apple.a97cdae7c8e13f0e"
},
{
"kind": "ui-named-argument",
"line": 18,
"path": "apps/ios/Sources/Design/AgentWorkspaceFilesScreen.swift",
"source": "Files",
"surface": "apple",
"id": "native.apple.9c4e8b82edf0b324"
},
{
"kind": "ui-call",
"line": 62,
"path": "apps/ios/Sources/Design/AgentWorkspaceFilesScreen.swift",
"source": "This folder is empty.",
"surface": "apple",
"id": "native.apple.ada58d7ce8866f71"
},
{
"kind": "ui-call",
"line": 134,
"path": "apps/ios/Sources/Design/AgentWorkspaceFilesScreen.swift",
"source": "Load More",
"surface": "apple",
"id": "native.apple.35512c4d46a2da28"
},
{
"kind": "ui-call",
"line": 140,
"path": "apps/ios/Sources/Design/AgentWorkspaceFilesScreen.swift",
"source": "\\(self.entries.count) of \\(self.totalEntries)",
"surface": "apple",
"id": "native.apple.e617cdacc3bce5ea"
},
{
"kind": "ui-modifier",
"line": 244,
"path": "apps/ios/Sources/Design/AgentWorkspaceFilesScreen.swift",
"source": "Share file",
"surface": "apple",
"id": "native.apple.336a7eeb12d6e5f4"
},
{
"kind": "ui-modifier",
"line": 251,
"path": "apps/ios/Sources/Design/AgentWorkspaceFilesScreen.swift",
"source": "Could not share this file.",
"surface": "apple",
"id": "native.apple.0ab324ff650d720c"
},
{
"kind": "ui-call",
"line": 255,
"path": "apps/ios/Sources/Design/AgentWorkspaceFilesScreen.swift",
"source": "OK",
"surface": "apple",
"id": "native.apple.8218f673399060cc"
},
{
"kind": "ui-localized-call",
"line": 110,
@ -9621,13 +9693,21 @@
"kind": "ui-named-argument",
"line": 165,
"path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift",
"source": "Files",
"surface": "apple",
"id": "native.apple.3f2726ccd1acd987"
},
{
"kind": "ui-named-argument",
"line": 170,
"path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift",
"source": "Dreaming",
"surface": "apple",
"id": "native.apple.614c9b40919fb103"
},
{
"kind": "ui-named-argument",
"line": 170,
"line": 175,
"path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift",
"source": "Usage",
"surface": "apple",
@ -9635,7 +9715,7 @@
},
{
"kind": "ui-named-argument",
"line": 175,
"line": 180,
"path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift",
"source": "Cron Jobs",
"surface": "apple",
@ -9643,7 +9723,7 @@
},
{
"kind": "conditional-branch",
"line": 249,
"line": 254,
"path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift",
"source": "Online",
"surface": "apple",
@ -9651,7 +9731,7 @@
},
{
"kind": "conditional-branch",
"line": 250,
"line": 255,
"path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift",
"source": "Connecting",
"surface": "apple",
@ -9659,7 +9739,7 @@
},
{
"kind": "conditional-branch",
"line": 251,
"line": 256,
"path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift",
"source": "Attention",
"surface": "apple",
@ -9667,7 +9747,7 @@
},
{
"kind": "conditional-branch",
"line": 252,
"line": 257,
"path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift",
"source": "Offline",
"surface": "apple",
@ -13109,13 +13189,21 @@
"kind": "ui-named-argument",
"line": 487,
"path": "apps/ios/Sources/RootTabs.swift",
"source": "Files",
"surface": "apple",
"id": "native.apple.d1a5c9b0c4acbfe2"
},
{
"kind": "ui-named-argument",
"line": 494,
"path": "apps/ios/Sources/RootTabs.swift",
"source": "Dreaming",
"surface": "apple",
"id": "native.apple.ad3c65353227b63e"
},
{
"kind": "ui-named-argument",
"line": 494,
"line": 501,
"path": "apps/ios/Sources/RootTabs.swift",
"source": "Usage",
"surface": "apple",
@ -13123,7 +13211,7 @@
},
{
"kind": "ui-named-argument",
"line": 501,
"line": 508,
"path": "apps/ios/Sources/RootTabs.swift",
"source": "Cron Jobs",
"surface": "apple",
@ -13131,7 +13219,7 @@
},
{
"kind": "ui-modifier",
"line": 634,
"line": 641,
"path": "apps/ios/Sources/RootTabs.swift",
"source": "Hide Sidebar",
"surface": "apple",
@ -13139,7 +13227,7 @@
},
{
"kind": "ui-modifier",
"line": 761,
"line": 768,
"path": "apps/ios/Sources/RootTabs.swift",
"source": "Close canvas",
"surface": "apple",
@ -13147,7 +13235,7 @@
},
{
"kind": "conditional-branch",
"line": 1010,
"line": 1017,
"path": "apps/ios/Sources/RootTabs.swift",
"source": "Gateway needs attention",
"surface": "apple",
@ -13155,7 +13243,7 @@
},
{
"kind": "conditional-branch",
"line": 1010,
"line": 1017,
"path": "apps/ios/Sources/RootTabs.swift",
"source": "OpenClaw iOS",
"surface": "apple",
@ -13163,7 +13251,7 @@
},
{
"kind": "conditional-branch",
"line": 1046,
"line": 1053,
"path": "apps/ios/Sources/RootTabs.swift",
"source": "Available",
"surface": "apple",
@ -13171,7 +13259,7 @@
},
{
"kind": "conditional-branch",
"line": 1046,
"line": 1053,
"path": "apps/ios/Sources/RootTabs.swift",
"source": "Gateway default",
"surface": "apple",
@ -13179,7 +13267,7 @@
},
{
"kind": "conditional-branch",
"line": 63,
"line": 64,
"path": "apps/ios/Sources/RootTabsNavigation.swift",
"source": "Chat",
"surface": "apple",
@ -13187,7 +13275,7 @@
},
{
"kind": "conditional-branch",
"line": 64,
"line": 65,
"path": "apps/ios/Sources/RootTabsNavigation.swift",
"source": "Talk",
"surface": "apple",
@ -13195,7 +13283,7 @@
},
{
"kind": "conditional-branch",
"line": 65,
"line": 66,
"path": "apps/ios/Sources/RootTabsNavigation.swift",
"source": "Overview",
"surface": "apple",
@ -13203,7 +13291,7 @@
},
{
"kind": "conditional-branch",
"line": 66,
"line": 67,
"path": "apps/ios/Sources/RootTabsNavigation.swift",
"source": "Activity",
"surface": "apple",
@ -13211,7 +13299,7 @@
},
{
"kind": "conditional-branch",
"line": 67,
"line": 68,
"path": "apps/ios/Sources/RootTabsNavigation.swift",
"source": "Agents",
"surface": "apple",
@ -13219,7 +13307,7 @@
},
{
"kind": "conditional-branch",
"line": 68,
"line": 69,
"path": "apps/ios/Sources/RootTabsNavigation.swift",
"source": "Workboard",
"surface": "apple",
@ -13227,7 +13315,7 @@
},
{
"kind": "conditional-branch",
"line": 69,
"line": 70,
"path": "apps/ios/Sources/RootTabsNavigation.swift",
"source": "Skill Workshop",
"surface": "apple",
@ -13235,7 +13323,7 @@
},
{
"kind": "conditional-branch",
"line": 70,
"line": 71,
"path": "apps/ios/Sources/RootTabsNavigation.swift",
"source": "Instances",
"surface": "apple",
@ -13243,7 +13331,7 @@
},
{
"kind": "conditional-branch",
"line": 71,
"line": 72,
"path": "apps/ios/Sources/RootTabsNavigation.swift",
"source": "Sessions",
"surface": "apple",
@ -13251,7 +13339,15 @@
},
{
"kind": "conditional-branch",
"line": 72,
"line": 73,
"path": "apps/ios/Sources/RootTabsNavigation.swift",
"source": "Files",
"surface": "apple",
"id": "native.apple.253cb2debeb51859"
},
{
"kind": "conditional-branch",
"line": 74,
"path": "apps/ios/Sources/RootTabsNavigation.swift",
"source": "Dreaming",
"surface": "apple",
@ -13259,7 +13355,7 @@
},
{
"kind": "conditional-branch",
"line": 73,
"line": 75,
"path": "apps/ios/Sources/RootTabsNavigation.swift",
"source": "Usage",
"surface": "apple",
@ -13267,7 +13363,7 @@
},
{
"kind": "conditional-branch",
"line": 74,
"line": 76,
"path": "apps/ios/Sources/RootTabsNavigation.swift",
"source": "Cron Jobs",
"surface": "apple",
@ -13275,7 +13371,7 @@
},
{
"kind": "conditional-branch",
"line": 75,
"line": 77,
"path": "apps/ios/Sources/RootTabsNavigation.swift",
"source": "Terminal",
"surface": "apple",
@ -13283,7 +13379,7 @@
},
{
"kind": "conditional-branch",
"line": 76,
"line": 78,
"path": "apps/ios/Sources/RootTabsNavigation.swift",
"source": "Docs",
"surface": "apple",
@ -13291,7 +13387,7 @@
},
{
"kind": "conditional-branch",
"line": 77,
"line": 79,
"path": "apps/ios/Sources/RootTabsNavigation.swift",
"source": "Settings",
"surface": "apple",
@ -13299,7 +13395,7 @@
},
{
"kind": "conditional-branch",
"line": 78,
"line": 80,
"path": "apps/ios/Sources/RootTabsNavigation.swift",
"source": "Settings / Gateway",
"surface": "apple",
@ -13307,7 +13403,7 @@
},
{
"kind": "conditional-branch",
"line": 84,
"line": 86,
"path": "apps/ios/Sources/RootTabsNavigation.swift",
"source": "Connection",
"surface": "apple",

View file

@ -2,6 +2,7 @@
## Unreleased
- Added a read-only Files browser for agent workspaces with directory navigation, text and image previews, and share-sheet export.
- Fixed Apple Watch connection setup so pairing and companion-install changes refresh immediately and cold launches wait for WatchConnectivity activation before sending.
- Redesigned the Settings About screen with the animated mascot, app tagline, and Website/Docs/GitHub/Discord links.
- Fixed startup aborts caused by inactive Voice Wake initializing the simulator audio pipeline.

View file

@ -18,9 +18,17 @@ extension AgentProTab {
self.usageDestination
case .dreaming:
self.dreamingDestination
case .files:
self.filesDestination
}
}
var filesDestination: some View {
AgentWorkspaceFilesScreen(
agentId: self.activeAgentID,
headerLeadingAction: self.directHeaderLeadingAction(for: .files))
}
var agentsDestination: some View {
List {
Section {

View file

@ -187,6 +187,13 @@ extension AgentProTab {
detail: self.usageDetail,
color: self.gatewayConnected ? OpenClawBrand.accent : .secondary,
route: .usage)
self.metricTile(
icon: "folder",
title: "Files",
value: self.activeAgentID,
detail: "Workspace files",
color: self.gatewayConnected ? OpenClawBrand.accent : .secondary,
route: .files)
}
.padding(.horizontal, OpenClawProMetric.pagePadding)

View file

@ -39,6 +39,7 @@ struct AgentProTab: View {
case cron
case usage
case dreaming
case files
}
enum SkillStatusFilter: String, CaseIterable, Identifiable {

View file

@ -0,0 +1,376 @@
import OpenClawChatUI
import OpenClawKit
import OpenClawProtocol
import SwiftUI
/// Read-only browser for the selected agent's workspace, backed by the
/// `agents.workspace.list` / `agents.workspace.get` gateway RPCs.
struct AgentWorkspaceFilesScreen: View {
let agentId: String
let headerLeadingAction: OpenClawSidebarHeaderAction?
var body: some View {
ZStack {
OpenClawProBackground()
VStack(alignment: .leading, spacing: 0) {
if let headerLeadingAction {
OpenClawAdaptiveHeaderRow(
title: "Files",
subtitle: self.agentId,
titleFont: OpenClawType.title3SemiBold,
subtitleFont: OpenClawType.subheadMedium)
{
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
} accessory: {
EmptyView()
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
AgentWorkspaceDirectoryList(agentId: self.agentId, path: "")
}
}
.navigationTitle("Files")
.navigationBarTitleDisplayMode(.inline)
}
static func displayName(forPath path: String) -> String {
path.split(separator: "/").last.map(String.init) ?? path
}
}
struct AgentWorkspaceDirectoryList: View {
@Environment(NodeAppModel.self) var appModel
let agentId: String
let path: String
@State private var entries: [AgentsWorkspaceEntry] = []
@State private var totalEntries = 0
@State private var loading = false
@State private var loadingMore = false
@State private var errorText: String?
var body: some View {
List {
if let errorText {
Section {
Text(errorText)
.font(OpenClawType.footnote)
.foregroundStyle(OpenClawBrand.warn)
}
} else if self.entries.isEmpty, !self.loading {
Section {
Text("This folder is empty.")
.font(OpenClawType.footnote)
.foregroundStyle(.secondary)
}
}
Section {
ForEach(self.entries, id: \.path) { entry in
self.entryRow(entry)
}
if self.entries.count < self.totalEntries {
self.loadMoreRow
}
}
}
.listStyle(.insetGrouped)
.scrollContentBackground(.hidden)
.font(OpenClawType.body)
.overlay {
if self.loading, self.entries.isEmpty {
ProgressView()
}
}
.refreshable {
await self.reload()
}
.task(id: "\(self.agentId)|\(self.path)") {
await self.reload()
}
}
private func entryRow(_ entry: AgentsWorkspaceEntry) -> some View {
let isDirectory = self.isDirectory(entry)
return NavigationLink {
if isDirectory {
ZStack {
OpenClawProBackground()
AgentWorkspaceDirectoryList(agentId: self.agentId, path: entry.path)
}
.navigationTitle(AgentWorkspaceFilesScreen.displayName(forPath: entry.path))
.navigationBarTitleDisplayMode(.inline)
.toolbar(.visible, for: .navigationBar)
} else {
AgentWorkspaceFilePreview(agentId: self.agentId, path: entry.path)
.toolbar(.visible, for: .navigationBar)
}
} label: {
HStack(spacing: 12) {
Image(systemName: isDirectory ? "folder" : "doc.text")
.font(OpenClawType.subhead)
.foregroundStyle(isDirectory ? OpenClawBrand.accent : Color.secondary)
.frame(width: 24)
VStack(alignment: .leading, spacing: 2) {
Text(entry.name)
.font(OpenClawType.subhead)
.foregroundStyle(.primary)
.lineLimit(1)
if let detail = self.entryDetail(entry) {
Text(detail)
.font(OpenClawType.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
}
}
}
private var loadMoreRow: some View {
Button {
Task { await self.loadMore() }
} label: {
HStack {
Text("Load More")
.font(OpenClawType.subheadMedium)
Spacer()
if self.loadingMore {
ProgressView()
} else {
Text("\(self.entries.count) of \(self.totalEntries)")
.font(OpenClawType.caption)
.foregroundStyle(.secondary)
}
}
}
.disabled(self.loadingMore)
}
private func isDirectory(_ entry: AgentsWorkspaceEntry) -> Bool {
(entry.kind.value as? String) == "directory"
}
private func entryDetail(_ entry: AgentsWorkspaceEntry) -> String? {
var parts: [String] = []
if let size = entry.size {
parts.append(ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file))
}
if let updatedAtMs = entry.updatedatms {
let date = Date(timeIntervalSince1970: Double(updatedAtMs) / 1000)
parts.append(date.formatted(.relative(presentation: .named, unitsStyle: .abbreviated)))
}
return parts.isEmpty ? nil : parts.joined(separator: "")
}
@MainActor
private func reload() async {
self.loading = true
self.errorText = nil
defer { self.loading = false }
if let result = await self.fetchPage(offset: 0) {
self.entries = result.entries
self.totalEntries = result.totalentries
}
}
@MainActor
private func loadMore() async {
guard !self.loadingMore else { return }
self.loadingMore = true
defer { self.loadingMore = false }
if let result = await self.fetchPage(offset: self.entries.count) {
let known = Set(self.entries.map(\.path))
self.entries.append(contentsOf: result.entries.filter { !known.contains($0.path) })
self.totalEntries = result.totalentries
}
}
@MainActor
private func fetchPage(offset: Int) async -> AgentsWorkspaceListResult? {
do {
let params = AgentsWorkspaceListParams(
agentid: self.agentId,
path: self.path.isEmpty ? nil : self.path,
offset: offset == 0 ? nil : offset,
limit: nil)
let paramsJSON = try Self.encodeParams(params)
let data = try await self.appModel.operatorSession.request(
method: "agents.workspace.list",
paramsJSON: paramsJSON,
timeoutSeconds: 12)
return try JSONDecoder().decode(AgentsWorkspaceListResult.self, from: data)
} catch {
self.errorText = "Could not load this folder."
return nil
}
}
static func encodeParams(_ params: some Encodable) throws -> String {
let data = try JSONEncoder().encode(params)
return String(data: data, encoding: .utf8) ?? "{}"
}
}
struct AgentWorkspaceFilePreview: View {
@Environment(NodeAppModel.self) var appModel
let agentId: String
let path: String
private struct ShareItem: Identifiable {
let id = UUID()
let fileURL: URL
}
@State private var file: AgentsWorkspaceFile?
@State private var loading = false
@State private var errorText: String?
@State private var shareItem: ShareItem?
@State private var showsShareError = false
var body: some View {
ZStack {
OpenClawProBackground()
self.content
}
.navigationTitle(AgentWorkspaceFilesScreen.displayName(forPath: self.path))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
self.share()
} label: {
Image(systemName: "square.and.arrow.up")
}
.accessibilityLabel("Share file")
.disabled(self.file == nil)
}
}
.sheet(item: self.$shareItem) { item in
ChatTranscriptShareSheet(fileURL: item.fileURL)
}
.alert("Could not share this file.", isPresented: self.$showsShareError) {
Button {
self.showsShareError = false
} label: {
Text("OK")
.font(OpenClawType.subheadMedium)
}
}
.task(id: "\(self.agentId)|\(self.path)") {
await self.load()
}
}
@ViewBuilder
private var content: some View {
if let file {
self.preview(for: file)
} else if self.loading {
ProgressView()
} else if let errorText {
VStack(spacing: 8) {
Image(systemName: "doc.questionmark")
.font(OpenClawType.title3SemiBold)
.foregroundStyle(.secondary)
Text(errorText)
.font(OpenClawType.footnote)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.padding(OpenClawProMetric.pagePadding)
}
}
@ViewBuilder
private func preview(for file: AgentsWorkspaceFile) -> some View {
if self.isImage(file), let image = self.decodedImage(file) {
ScrollView([.horizontal, .vertical]) {
Image(uiImage: image)
.resizable()
.scaledToFit()
.frame(maxWidth: .infinity)
.padding(OpenClawProMetric.pagePadding)
}
} else {
ScrollView([.horizontal, .vertical]) {
Text(ChatCodeHighlightCache.highlighted(
code: file.content,
languageId: self.languageId))
.font(OpenClawType.monoSmall)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(OpenClawProMetric.pagePadding)
}
}
}
private var fileExtension: String {
(self.path as NSString).pathExtension.lowercased()
}
/// The highlighter's language ids line up with common file extensions;
/// unknown ids fall back to plain text rendering.
private var languageId: String? {
self.fileExtension.isEmpty ? nil : self.fileExtension
}
private func isImage(_ file: AgentsWorkspaceFile) -> Bool {
(file.encoding.value as? String) == "base64" && file.mimetype.hasPrefix("image/")
}
private func decodedImage(_ file: AgentsWorkspaceFile) -> UIImage? {
guard let data = Data(base64Encoded: file.content) else { return nil }
return UIImage(data: data)
}
@MainActor
private func load() async {
self.loading = true
self.file = nil
self.errorText = nil
defer { self.loading = false }
do {
let params = AgentsWorkspaceGetParams(agentid: self.agentId, path: self.path)
let paramsJSON = try AgentWorkspaceDirectoryList.encodeParams(params)
let data = try await self.appModel.operatorSession.request(
method: "agents.workspace.get",
paramsJSON: paramsJSON,
timeoutSeconds: 20)
self.file = try JSONDecoder().decode(AgentsWorkspaceGetResult.self, from: data).file
} catch {
self.errorText = "This file cannot be previewed. It may be binary or too large."
}
}
/// Mirrors the chat transcript export flow: write a temp copy, then hand
/// it to the system share sheet.
private func share() {
guard let file else { return }
let safeName = (file.name as NSString).lastPathComponent
guard !safeName.isEmpty, safeName != ".", safeName != ".." else {
self.showsShareError = true
return
}
// Each share gets stable bytes at a unique URL; a later same-basename
// export must not replace the item already handed to an extension.
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("OpenClawWorkspaceFiles", isDirectory: true)
.appendingPathComponent(UUID().uuidString, isDirectory: true)
let fileURL = directory.appendingPathComponent(safeName, isDirectory: false)
do {
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
if self.isImage(file) {
guard let data = Data(base64Encoded: file.content) else {
self.showsShareError = true
return
}
try data.write(to: fileURL, options: .atomic)
} else {
try file.content.write(to: fileURL, atomically: true, encoding: .utf8)
}
self.shareItem = ShareItem(fileURL: fileURL)
} catch {
self.showsShareError = true
}
}
}

View file

@ -159,6 +159,11 @@ struct RootTabsPhoneControlHub: View {
CommandSessionsScreen(
usesNativeNavigationChrome: true,
openChat: { self.openChatFromControlDetail(.sessions) })
case .files:
AgentProTab(
directRoute: .files,
headerTitle: "Files",
openSettings: { self.openGatewayDetail() })
case .dreaming:
AgentProTab(
directRoute: .dreaming,

View file

@ -480,6 +480,13 @@ struct RootTabs: View {
CommandSessionsScreen(
headerLeadingAction: self.sidebarHeaderLeadingAction,
openChat: { self.selectSidebarDestination(.chat) })
case .files:
AgentProTab(
directRoute: .files,
headerLeadingAction: self.sidebarHeaderLeadingAction,
headerTitle: "Files",
openSettings: { self.selectSidebarDestination(.gateway) })
.id(self.selectedSidebarDestination.id)
case .dreaming:
AgentProTab(
directRoute: .dreaming,

View file

@ -46,6 +46,7 @@ extension RootTabs {
case skillWorkshop
case instances
case sessions
case files
case dreaming
case usage
case cron
@ -69,6 +70,7 @@ extension RootTabs {
case .skillWorkshop: "Skill Workshop"
case .instances: "Instances"
case .sessions: "Sessions"
case .files: "Files"
case .dreaming: "Dreaming"
case .usage: "Usage"
case .cron: "Cron Jobs"
@ -97,6 +99,7 @@ extension RootTabs {
case .skillWorkshop: "hammer"
case .instances: "dot.radiowaves.left.and.right"
case .sessions: "doc.text"
case .files: "folder.fill"
case .dreaming: "moon.stars"
case .usage: "chart.bar.xaxis"
case .cron: "timer"
@ -117,7 +120,8 @@ extension RootTabs {
.agent
case .settings, .gateway:
.settings
case .overview, .activity, .workboard, .skillWorkshop, .instances, .sessions, .dreaming,
case .overview, .activity, .workboard, .skillWorkshop, .instances, .sessions, .files,
.dreaming,
.usage,
.cron, .terminal, .docs:
.control
@ -129,6 +133,7 @@ extension RootTabs {
case .gateway:
.gateway
case .chat, .talk, .overview, .activity, .agents, .workboard, .skillWorkshop, .instances, .sessions,
.files,
.dreaming,
.usage, .cron, .terminal, .settings, .docs:
nil
@ -199,7 +204,8 @@ extension RootTabs {
switch destination {
case .chat, .talk, .agents, .gateway, .settings:
true
case .overview, .activity, .workboard, .skillWorkshop, .instances, .sessions, .dreaming,
case .overview, .activity, .workboard, .skillWorkshop, .instances, .sessions, .files,
.dreaming,
.usage,
.cron, .terminal, .docs:
false
@ -283,6 +289,7 @@ extension RootTabs {
.skillWorkshop,
.instances,
.sessions,
.files,
.dreaming,
.usage,
.cron,

View file

@ -76,6 +76,7 @@ struct RootTabsPresentationTests {
.skillWorkshop,
.instances,
.sessions,
.files,
.dreaming,
.usage,
.cron,
@ -93,6 +94,7 @@ struct RootTabsPresentationTests {
"skillWorkshop",
"instances",
"sessions",
"files",
"dreaming",
"usage",
"cron",

View file

@ -354,14 +354,15 @@ enum ChatCodeHighlighter {
/// Content-keyed memo so streaming re-renders reuse finished highlight work
/// instead of re-tokenizing every completed block on each delta tick.
/// Public so app file previews reuse the chat renderer's highlighting.
@MainActor
enum ChatCodeHighlightCache {
public enum ChatCodeHighlightCache {
private static var cache: [String: AttributedString] = [:]
// Bounded: wholesale reset past the cap keeps long sessions flat; a miss
// just re-tokenizes one visible block, which is cheap.
private static let capacity = 160
static func highlighted(code: String, languageId: String?) -> AttributedString {
public static func highlighted(code: String, languageId: String?) -> AttributedString {
guard ChatCodeHighlighter.language(for: languageId) != nil,
ChatCodeHighlighter.isWithinHighlightLimits(code)
else {

View file

@ -14,6 +14,7 @@ Availability: iPhone app builds are distributed through Apple channels when enab
- Connects to a Gateway over WebSocket (LAN or tailnet).
- Exposes node capabilities: Canvas, Screen snapshot, Camera capture, Location, Talk mode, Voice wake.
- Receives `node.invoke` commands and reports node status events.
- Browses the selected agent's workspace read-only from the Agents surface (Files): directory drill-down, syntax-highlighted text previews, image previews, and share-sheet export. No write operations; previews are size-capped by the gateway.
- Keeps a small read-only offline cache of recent chat sessions and transcripts per paired gateway: cold opens paint the last known transcript immediately and refresh once the gateway responds, recent chats stay browsable while disconnected, and reset/forget purges the protected local cache.
- Queues text messages sent while disconnected in a durable per-gateway outbox (up to 50): queued bubbles show in the transcript, flush in order on reconnect with idempotency keys so nothing sends twice, retry with backoff before surfacing as "Not sent" with retry/delete in the message context menu, and expire instead of sending after 48 hours offline; reset/forget clears the queue with the cache.