diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 1babb6cc6ef..6be79f86e6c 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -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", diff --git a/apps/ios/CHANGELOG.md b/apps/ios/CHANGELOG.md index 8372e9b5507..95ce45cc102 100644 --- a/apps/ios/CHANGELOG.md +++ b/apps/ios/CHANGELOG.md @@ -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. diff --git a/apps/ios/Sources/Design/AgentProTab+Destinations.swift b/apps/ios/Sources/Design/AgentProTab+Destinations.swift index 4126abbb896..d30e8b97a78 100644 --- a/apps/ios/Sources/Design/AgentProTab+Destinations.swift +++ b/apps/ios/Sources/Design/AgentProTab+Destinations.swift @@ -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 { diff --git a/apps/ios/Sources/Design/AgentProTab+Overview.swift b/apps/ios/Sources/Design/AgentProTab+Overview.swift index 69a15b0eeef..85d433e80f9 100644 --- a/apps/ios/Sources/Design/AgentProTab+Overview.swift +++ b/apps/ios/Sources/Design/AgentProTab+Overview.swift @@ -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) diff --git a/apps/ios/Sources/Design/AgentProTab.swift b/apps/ios/Sources/Design/AgentProTab.swift index 2f0012cd2a8..c19e5fb3339 100644 --- a/apps/ios/Sources/Design/AgentProTab.swift +++ b/apps/ios/Sources/Design/AgentProTab.swift @@ -39,6 +39,7 @@ struct AgentProTab: View { case cron case usage case dreaming + case files } enum SkillStatusFilter: String, CaseIterable, Identifiable { diff --git a/apps/ios/Sources/Design/AgentWorkspaceFilesScreen.swift b/apps/ios/Sources/Design/AgentWorkspaceFilesScreen.swift new file mode 100644 index 00000000000..d86f58ee7df --- /dev/null +++ b/apps/ios/Sources/Design/AgentWorkspaceFilesScreen.swift @@ -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 + } + } +} diff --git a/apps/ios/Sources/Design/RootTabsPhoneControlHub.swift b/apps/ios/Sources/Design/RootTabsPhoneControlHub.swift index 9703702ce2c..15f8abe831e 100644 --- a/apps/ios/Sources/Design/RootTabsPhoneControlHub.swift +++ b/apps/ios/Sources/Design/RootTabsPhoneControlHub.swift @@ -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, diff --git a/apps/ios/Sources/RootTabs.swift b/apps/ios/Sources/RootTabs.swift index 9d3a5911fdb..ff2c6eb29d2 100644 --- a/apps/ios/Sources/RootTabs.swift +++ b/apps/ios/Sources/RootTabs.swift @@ -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, diff --git a/apps/ios/Sources/RootTabsNavigation.swift b/apps/ios/Sources/RootTabsNavigation.swift index 83036cc2a9a..7af1fe9f9ec 100644 --- a/apps/ios/Sources/RootTabsNavigation.swift +++ b/apps/ios/Sources/RootTabsNavigation.swift @@ -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, diff --git a/apps/ios/Tests/RootTabsPresentationTests.swift b/apps/ios/Tests/RootTabsPresentationTests.swift index f86f068a113..ff41a2088d0 100644 --- a/apps/ios/Tests/RootTabsPresentationTests.swift +++ b/apps/ios/Tests/RootTabsPresentationTests.swift @@ -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", diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatCodeHighlighter.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatCodeHighlighter.swift index fb19a44e513..8bf6ddddc5a 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatCodeHighlighter.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatCodeHighlighter.swift @@ -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 { diff --git a/docs/platforms/ios.md b/docs/platforms/ios.md index 8f0d0624c83..b45087a5c1a 100644 --- a/docs/platforms/ios.md +++ b/docs/platforms/ios.md @@ -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.