Skip to content

Commit 61a29a7

Browse files
authored
feat(android): read-only workspace Files browser with preview and share (#100776)
* feat(android): add read-only workspace Files browser with preview and share * fix(android): degrade malformed base64 image payloads to no-preview instead of crashing * fix(android): keep workspace paths verbatim from the gateway * test(android): cover the overview Files card in shell logic tests * fix(android): reset workspace browser across agents * fix(android): isolate workspace share exports * docs(android): note workspace file browser * chore(i18n): refresh Android Files inventory * docs(android): refresh workspace files map
1 parent ddddb3d commit 61a29a7

12 files changed

Lines changed: 775 additions & 52 deletions

File tree

apps/.i18n/native-source.json

Lines changed: 147 additions & 51 deletions
Large diffs are not rendered by default.

apps/android/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## Unreleased
44

5+
Adds a read-only Files browser for agent workspaces with directory navigation, text and image previews, and system share export.
6+
57
Android onboarding now completes after permission-triggered node approval and keeps Back navigation from cycling between permissions and approval.
68

79
Third-party Android builds can now opt into Always location through Android settings, with requested background checks disclosed in the persistent node notification while Play builds remain foreground-only. (#68581) Thanks @ioridev.

apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -666,6 +666,13 @@ class MainViewModel(
666666

667667
suspend fun forkChatSession(parentKey: String): String? = ensureRuntime().forkChatSession(parentKey)
668668

669+
suspend fun listWorkspaceFiles(
670+
path: String?,
671+
offset: Int? = null,
672+
): GatewayWorkspaceListing = ensureRuntime().listWorkspaceFiles(path = path, offset = offset)
673+
674+
suspend fun fetchWorkspaceFile(path: String): GatewayWorkspaceFile = ensureRuntime().fetchWorkspaceFile(path)
675+
669676
fun setChatThinkingLevel(level: String) {
670677
ensureRuntime().setChatThinkingLevel(level)
671678
}

apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2823,6 +2823,36 @@ class NodeRuntime private constructor(
28232823
}
28242824
}
28252825

2826+
/** Lists one directory of the active agent's workspace (read-only RPC). */
2827+
suspend fun listWorkspaceFiles(
2828+
path: String?,
2829+
offset: Int? = null,
2830+
): GatewayWorkspaceListing {
2831+
val params =
2832+
buildJsonObject {
2833+
put("agentId", JsonPrimitive(workspaceAgentId()))
2834+
if (!path.isNullOrEmpty()) put("path", JsonPrimitive(path))
2835+
if (offset != null && offset > 0) put("offset", JsonPrimitive(offset))
2836+
}
2837+
val res = operatorSession.request("agents.workspace.list", params.toString())
2838+
return parseWorkspaceListing(json.parseToJsonElement(res))
2839+
?: throw IllegalStateException("agents.workspace.list returned no listing")
2840+
}
2841+
2842+
/** Fetches one workspace file preview (UTF-8 text or base64 image). */
2843+
suspend fun fetchWorkspaceFile(path: String): GatewayWorkspaceFile {
2844+
val params =
2845+
buildJsonObject {
2846+
put("agentId", JsonPrimitive(workspaceAgentId()))
2847+
put("path", JsonPrimitive(path))
2848+
}
2849+
val res = operatorSession.request("agents.workspace.get", params.toString(), timeoutMs = 30_000)
2850+
return parseWorkspaceFile(json.parseToJsonElement(res))
2851+
?: throw IllegalStateException("agents.workspace.get returned no file")
2852+
}
2853+
2854+
private fun workspaceAgentId(): String = resolveActiveAgentId().ifEmpty { "main" }
2855+
28262856
private suspend fun refreshAgentsFromGateway() {
28272857
if (!operatorConnected) return
28282858
try {
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package ai.openclaw.app
2+
3+
import ai.openclaw.app.node.asObjectOrNull
4+
import ai.openclaw.app.node.asStringOrNull
5+
import kotlinx.serialization.json.JsonArray
6+
import kotlinx.serialization.json.JsonElement
7+
import kotlinx.serialization.json.JsonPrimitive
8+
import kotlinx.serialization.json.longOrNull
9+
10+
/** One entry from the read-only `agents.workspace.list` gateway RPC. */
11+
data class GatewayWorkspaceEntry(
12+
val path: String,
13+
val name: String,
14+
val isDirectory: Boolean,
15+
val size: Long?,
16+
val updatedAtMs: Long?,
17+
)
18+
19+
/** One directory page of an agent workspace listing. */
20+
data class GatewayWorkspaceListing(
21+
val path: String,
22+
val entries: List<GatewayWorkspaceEntry>,
23+
val totalEntries: Int,
24+
val offset: Int,
25+
)
26+
27+
/** One previewable workspace file from `agents.workspace.get`. */
28+
data class GatewayWorkspaceFile(
29+
val path: String,
30+
val name: String,
31+
val size: Long,
32+
val mimeType: String,
33+
val isBase64: Boolean,
34+
val content: String,
35+
)
36+
37+
private fun JsonElement?.asLongOrNull(): Long? = (this as? JsonPrimitive)?.longOrNull
38+
39+
internal fun parseWorkspaceListing(root: JsonElement): GatewayWorkspaceListing? {
40+
val obj = root.asObjectOrNull() ?: return null
41+
val entries =
42+
(obj["entries"] as? JsonArray)?.mapNotNull { item ->
43+
val entry = item.asObjectOrNull() ?: return@mapNotNull null
44+
// Paths/names are opaque workspace identifiers echoed back to the
45+
// gateway; never trim them or entries with edge whitespace break.
46+
val path = entry["path"].asStringOrNull().orEmpty()
47+
val name = entry["name"].asStringOrNull().orEmpty()
48+
if (path.isEmpty() || name.isEmpty()) return@mapNotNull null
49+
GatewayWorkspaceEntry(
50+
path = path,
51+
name = name,
52+
isDirectory = entry["kind"].asStringOrNull() == "directory",
53+
size = entry["size"].asLongOrNull(),
54+
updatedAtMs = entry["updatedAtMs"].asLongOrNull(),
55+
)
56+
} ?: emptyList()
57+
return GatewayWorkspaceListing(
58+
path = obj["path"].asStringOrNull().orEmpty(),
59+
entries = entries,
60+
totalEntries = obj["totalEntries"].asLongOrNull()?.toInt() ?: entries.size,
61+
offset = obj["offset"].asLongOrNull()?.toInt() ?: 0,
62+
)
63+
}
64+
65+
internal fun parseWorkspaceFile(root: JsonElement): GatewayWorkspaceFile? {
66+
val file = root.asObjectOrNull()?.get("file").asObjectOrNull() ?: return null
67+
val path = file["path"].asStringOrNull().orEmpty()
68+
if (path.isEmpty()) return null
69+
return GatewayWorkspaceFile(
70+
path = path,
71+
name =
72+
file["name"]
73+
.asStringOrNull()
74+
.orEmpty()
75+
.ifEmpty { path.substringAfterLast('/') },
76+
size = file["size"].asLongOrNull() ?: 0L,
77+
mimeType = file["mimeType"].asStringOrNull().orEmpty().ifEmpty { "text/plain" },
78+
isBase64 = file["encoding"].asStringOrNull() == "base64",
79+
content = file["content"].asStringOrNull().orEmpty(),
80+
)
81+
}

apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ import androidx.compose.material.icons.filled.Storage
7575
import androidx.compose.material.icons.filled.Tune
7676
import androidx.compose.material.icons.outlined.AccessTime
7777
import androidx.compose.material.icons.outlined.ChatBubbleOutline
78+
import androidx.compose.material.icons.outlined.Folder
7879
import androidx.compose.material.icons.outlined.Inventory2
7980
import androidx.compose.material.icons.outlined.MicNone
8081
import androidx.compose.material.icons.outlined.Settings
@@ -116,6 +117,7 @@ internal enum class Tab(
116117
Sessions(key = "sessions", label = "Sessions", icon = Icons.Outlined.AccessTime),
117118
Settings(key = "settings", label = "Settings", icon = Icons.Outlined.Settings),
118119
ProvidersModels(key = "providers-models", label = "Providers", icon = Icons.Outlined.Inventory2),
120+
Files(key = "files", label = "Files", icon = Icons.Outlined.Folder),
119121
}
120122

121123
private val shellNavTabs = listOf(Tab.Overview, Tab.Chat, Tab.Voice, Tab.Settings)
@@ -235,6 +237,11 @@ fun ShellScreen(
235237
onOpenCommand = { commandOpen = true },
236238
onOpenChat = { nav.selectTab(Tab.Chat) },
237239
)
240+
Tab.Files ->
241+
WorkspaceFilesScreen(
242+
viewModel = viewModel,
243+
onBack = nav::back,
244+
)
238245
Tab.Settings ->
239246
SettingsShellScreen(
240247
viewModel = viewModel,
@@ -981,6 +988,14 @@ internal fun overviewMetricCardSpecs(
981988
status = if (sessionCount > 0) ClawStatus.Success else ClawStatus.Neutral,
982989
tab = Tab.Sessions,
983990
),
991+
OverviewMetricCardSpec(
992+
title = "Files",
993+
value = if (isConnected) "Browse" else "Offline",
994+
subtitle = "Agent workspace files",
995+
icon = Icons.Outlined.Folder,
996+
status = if (isConnected) ClawStatus.Success else ClawStatus.Neutral,
997+
tab = Tab.Files,
998+
),
984999
)
9851000
}
9861001

0 commit comments

Comments
 (0)