Vectrum
Sign In

Android: Genome map for Empryo threads

The phone asks the host for the thread's Genome (ClientMessage::Genome)
and shows it like the desktop: the import graph of the top files, sized
by rank and colored by folder with the desktop's hash, where tapping a dot
opens its file, and the ranked files opening to their symbols. It asks
again every few seconds while it shows, and gives up with a reason after
20 seconds on hosts too old to answer. Phones open it from a DNA button in
an Empryo thread's top bar; tablets from the sidebar's foot; both from
"Open a surface".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JjohannSocommitted 1 parent beca812commit2a2cb2f

Showing 8 changed files with 458 additions and 7 deletions.

+2−1README.mdView File
@@ -153,7 +153,8 @@ Gruvbox, Rosé Pine, Solarized and Dracula.
153153 the last two days. A file opens to its symbols, exported ones first with their signatures,
154154 and lights up with its imports in the graph. It follows the thread to its worktree and
155155 reloads as Empryo updates the map. For a thread on another device, that device reads its
156- Genome and sends it over, and again only when Empryo changed it.
156+ Genome and sends it over, and again only when Empryo changed it. The Android app has it
157+ too: the DNA button in an Empryo thread's top bar, or Genome map in the right panel.
157158 - Background tasks: when a command, monitor or subagent Claude left running finishes, the
158159 transcript says so and Claude picks the work back up and reports back on its own. A
159160 long-running command can be sent to the background from its tool card, like Ctrl+B in
+28−0android/app/src/main/java/cc/vectrum/code/data/AppState.ktView File
@@ -16,6 +16,7 @@ import cc.vectrum.code.net.ClientMessage
1616 import cc.vectrum.code.net.Connection
1717 import cc.vectrum.code.net.ConnectionStatus
1818 import cc.vectrum.code.net.Folder
19+ import cc.vectrum.code.net.GenomeAnswer
1920 import cc.vectrum.code.net.ProjectInfo
2021 import cc.vectrum.code.net.RemoteFileDiff
2122 import cc.vectrum.code.net.RemoteInstance
@@ -33,6 +34,7 @@ import cc.vectrum.code.net.ThreadSummary
3334 import cc.vectrum.code.net.WireJson
3435 import cc.vectrum.code.net.withPort
3536 import kotlinx.coroutines.CompletableDeferred
37+ import kotlinx.coroutines.withTimeoutOrNull
3638 import kotlinx.serialization.Serializable
3739 import kotlinx.serialization.builtins.ListSerializer
3840 import kotlinx.serialization.json.JsonElement
@@ -213,6 +215,7 @@ class HostState(saved: SavedHost, private val app: AppState) {
213215 private val createdCallbacks = mutableMapOf<Long, (String) -> Unit>()
214216 private val folderRequests = mutableMapOf<String, CompletableDeferred<ServerMessage.Folders>>()
215217 private val diffRequests = mutableMapOf<Long, CompletableDeferred<ServerMessage.DiffReply>>()
218+ private val genomeRequests = mutableMapOf<Long, CompletableDeferred<GenomeAnswer>>()
216219 private val deviceRequests = mutableMapOf<Long, CompletableDeferred<ServerMessage.DeviceReply>>()
217220 private val terminals = mutableMapOf<Long, TerminalListener>()
218221
@@ -286,6 +289,8 @@ class HostState(saved: SavedHost, private val app: AppState) {
286289 folderRequests.clear()
287290 diffRequests.values.forEach { it.complete(ServerMessage.DiffReply(error = "The host is offline.")) }
288291 diffRequests.clear()
292+ genomeRequests.values.forEach { it.complete(GenomeAnswer(error = "The host is offline.")) }
293+ genomeRequests.clear()
289294 deviceRequests.values.forEach { it.complete(ServerMessage.DeviceReply(error = "The host is offline.")) }
290295 deviceRequests.clear()
291296 terminals.values.forEach { it.onExit(reason) }
@@ -324,6 +329,7 @@ class HostState(saved: SavedHost, private val app: AppState) {
324329 favorites = message.favorites
325330 }
326331 is ServerMessage.DiffReply -> diffRequests.remove(message.request)?.complete(message)
332+ is ServerMessage.GenomeReply -> genomeRequests.remove(message.request)?.complete(message.answer)
327333 is ServerMessage.Welcome -> learnRoutes(message.routes)
328334 is ServerMessage.Routes -> learnRoutes(message.routes)
329335 is ServerMessage.Denied -> {}
@@ -398,6 +404,28 @@ class HostState(saved: SavedHost, private val app: AppState) {
398404 return reply.await()
399405 }
400406
407+ /**
408+ * Empryo's Genome of a thread's checkout, read on the host; `since` is when
409+ * the one this app has was written. Older hosts don't answer, so it gives
410+ * up after a while and says why.
411+ */
412+ suspend fun genome(thread: String?, project: String, since: Long?): GenomeAnswer {
413+ val request = nextRequest++
414+ val reply = CompletableDeferred<GenomeAnswer>()
415+ genomeRequests[request] = reply
416+ if (!send(ClientMessage.Genome(request, thread, project, since))) {
417+ genomeRequests.remove(request)
418+ return GenomeAnswer(written = since, error = "$name is offline.")
419+ }
420+ return withTimeoutOrNull(20_000) { reply.await() } ?: run {
421+ genomeRequests.remove(request)
422+ GenomeAnswer(
423+ written = since,
424+ error = "$name didn't send its Genome. Its Vectrum Code may be too old to; update it from its sidebar's corner.",
425+ )
426+ }
427+ }
428+
401429 /** Sends a `DeviceCall` (an object tagged by `call`) and waits for its reply. */
402430 suspend fun device(call: JsonObject): ServerMessage.DeviceReply {
403431 val request = nextRequest++
+63−0android/app/src/main/java/cc/vectrum/code/net/Protocol.ktView File
@@ -416,6 +416,14 @@ sealed class ClientMessage {
416416 val path: String?,
417417 val ignoreWhitespace: Boolean,
418418 ) : ClientMessage()
419+
420+ /**
421+ * Empryo's Genome of a thread's checkout (the project's, for a thread not
422+ * created yet). `since` is when the one this app has was written, so an
423+ * unchanged one isn't sent again. Older hosts don't answer.
424+ */
425+ @Serializable @SerialName("genome")
426+ data class Genome(val request: Long, val thread: String?, val project: String, val since: Long?) : ClientMessage()
419427 }
420428
421429 @Serializable
@@ -548,8 +556,63 @@ sealed class ServerMessage {
548556
549557 @Serializable @SerialName("diff_reply")
550558 data class DiffReply(val request: Long = 0, val files: List<RemoteFileDiff> = emptyList(), val error: String? = null) : ServerMessage()
559+
560+ @Serializable @SerialName("genome_reply")
561+ data class GenomeReply(val request: Long = 0, val answer: GenomeAnswer = GenomeAnswer()) : ServerMessage()
551562 }
552563
564+ /** What the host found of a checkout's Genome (src/remote/mod.rs). */
565+ @Serializable
566+ data class GenomeAnswer(
567+ /** When Empryo last wrote it, in milliseconds since the epoch; `null` when there's none yet. */
568+ val written: Long? = null,
569+ /** The map, unless it's unchanged since the one asked about, or missing. */
570+ val genome: Genome? = null,
571+ val unchanged: Boolean = false,
572+ val error: String? = null,
573+ )
574+
575+ /** Empryo's ranked map of a checkout (src/genome.rs): its top files, their imports and their layout. */
576+ @Serializable
577+ data class Genome(
578+ val totalFiles: Long = 0,
579+ val totalSymbols: Long = 0,
580+ val totalEdges: Long = 0,
581+ /** The highest ranked files, highest first. */
582+ val files: List<GenomeFile> = emptyList(),
583+ /** Imports between those files: `from` depends on `to`. */
584+ val edges: List<GenomeEdge> = emptyList(),
585+ /** Where each file sits in the map, from 0 to 1 on both axes: `[x, y]`. */
586+ val layout: List<List<Float>> = emptyList(),
587+ )
588+
589+ @Serializable
590+ data class GenomeFile(
591+ val path: String = "",
592+ val language: String = "",
593+ val lines: Long = 0,
594+ val symbolCount: Long = 0,
595+ val rank: Double = 0.0,
596+ /** How many files import it. */
597+ val dependents: Long = 0,
598+ val dependencies: Long = 0,
599+ /** Changed in the last 48 hours. */
600+ val recent: Boolean = false,
601+ val symbols: List<GenomeSymbol> = emptyList(),
602+ )
603+
604+ @Serializable
605+ data class GenomeSymbol(
606+ val name: String = "",
607+ val kind: String = "",
608+ val line: Long = 0,
609+ val exported: Boolean = false,
610+ val signature: String? = null,
611+ )
612+
613+ @Serializable
614+ data class GenomeEdge(val from: Int = 0, val to: Int = 0, val weight: Double = 0.0)
615+
553616 /** A message from the host, or `null` for one this app doesn't know. */
554617 fun parseServerMessage(text: String): ServerMessage? = try {
555618 WireJson.decodeFromString(ServerMessage.serializer(), text)
+1−1android/app/src/main/java/cc/vectrum/code/ui/Nav.ktView File
@@ -27,7 +27,7 @@ sealed interface Screen {
2727 }
2828
2929 /** The surfaces the right panel can show next to a thread, as the desktop's "Open a surface". */
30- enum class Surface { Subagents, Terminal, Device }
30+ enum class Surface { Subagents, Terminal, Device, Genome }
3131
3232 /** Where the app is: the sheet's screen, a back stack, the sidebar drawer and the right panel. */
3333 class Nav {
+8−1android/app/src/main/java/cc/vectrum/code/ui/chat/Header.ktView File
@@ -158,7 +158,7 @@ fun ChatHeader(app: AppState, nav: Nav, thread: OpenThread) {
158158 /**
159159 * The chat header as a phone's bar: the way back to the threads, the title
160160 * over its project (or the thread that delegated it), which opens the
161- * thread menu, and the right panel's toggle.
161+ * thread menu, the Genome map for Empryo threads, and the right panel's toggle.
162162 */
163163 @Composable
164164 fun RowScope.PhoneChatBar(app: AppState, nav: Nav, thread: OpenThread) {
@@ -209,6 +209,13 @@ fun RowScope.PhoneChatBar(app: AppState, nav: Nav, thread: OpenThread) {
209209 runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, android.net.Uri.parse(pr.url))) }
210210 }
211211 }
212+ // Empryo's map of the thread's code, as the sidebar's foot offers it on bigger screens.
213+ if (summary.provider == "empryo") {
214+ BarAction(Lucide.Dna, selected = nav.surface == Surface.Genome) {
215+ nav.surfacePicker = false
216+ nav.surface = if (nav.surface == Surface.Genome) null else Surface.Genome
217+ }
218+ }
212219 Box {
213220 BarAction(Lucide.PanelRightOpen, selected = nav.surface != null) { nav.surfacePicker = true }
214221 if (subagentsWorking > 0 && nav.surface != Surface.Subagents) {
+338−0android/app/src/main/java/cc/vectrum/code/ui/panels/Genome.ktaddedView File
@@ -0,0 +1,338 @@
1+ package cc.vectrum.code.ui.panels
2+
3+ import androidx.compose.foundation.Canvas
4+ import androidx.compose.foundation.gestures.detectTapGestures
5+ import androidx.compose.foundation.layout.Arrangement
6+ import androidx.compose.foundation.layout.Box
7+ import androidx.compose.foundation.layout.BoxWithConstraints
8+ import androidx.compose.foundation.layout.Column
9+ import androidx.compose.foundation.layout.Row
10+ import androidx.compose.foundation.layout.fillMaxSize
11+ import androidx.compose.foundation.layout.fillMaxWidth
12+ import androidx.compose.foundation.layout.height
13+ import androidx.compose.foundation.layout.offset
14+ import androidx.compose.foundation.layout.padding
15+ import androidx.compose.foundation.layout.size
16+ import androidx.compose.foundation.layout.width
17+ import androidx.compose.foundation.rememberScrollState
18+ import androidx.compose.foundation.shape.CircleShape
19+ import androidx.compose.foundation.shape.RoundedCornerShape
20+ import androidx.compose.foundation.verticalScroll
21+ import androidx.compose.foundation.background
22+ import androidx.compose.runtime.Composable
23+ import androidx.compose.runtime.LaunchedEffect
24+ import androidx.compose.runtime.getValue
25+ import androidx.compose.runtime.mutableStateOf
26+ import androidx.compose.runtime.remember
27+ import androidx.compose.runtime.setValue
28+ import androidx.compose.ui.Alignment
29+ import androidx.compose.ui.Modifier
30+ import androidx.compose.ui.draw.clip
31+ import androidx.compose.ui.geometry.Offset
32+ import androidx.compose.ui.graphics.Color
33+ import androidx.compose.ui.graphics.Path
34+ import androidx.compose.ui.graphics.drawscope.Stroke
35+ import androidx.compose.ui.input.pointer.pointerInput
36+ import androidx.compose.ui.platform.LocalDensity
37+ import androidx.compose.ui.text.font.FontWeight
38+ import androidx.compose.ui.unit.Dp
39+ import androidx.compose.ui.unit.dp
40+ import androidx.compose.ui.unit.sp
41+ import cc.vectrum.code.data.AppState
42+ import cc.vectrum.code.data.ThreadRef
43+ import cc.vectrum.code.net.Genome
44+ import cc.vectrum.code.net.GenomeFile
45+ import cc.vectrum.code.theme.Mono
46+ import cc.vectrum.code.theme.Palette
47+ import cc.vectrum.code.theme.Rule
48+ import cc.vectrum.code.theme.Tag
49+ import cc.vectrum.code.theme.Txt
50+ import cc.vectrum.code.theme.palette
51+ import cc.vectrum.code.theme.well
52+ import cc.vectrum.code.ui.Lucide
53+ import cc.vectrum.code.ui.Nav
54+ import cc.vectrum.code.ui.sidebar.pressTint
55+ import kotlinx.coroutines.delay
56+ import kotlin.math.max
57+ import kotlin.math.sqrt
58+
59+ // The Genome map (src/ui/workspace/genome.rs): Empryo's ranked map of the
60+ // thread's checkout, which the host reads and sends over. The graph shows
61+ // the highest ranked files, sized by rank and colored by folder, with a
62+ // line for each import; the list below ranks them, and a file opens to its
63+ // symbols. While it shows, it asks the host every few seconds whether
64+ // Empryo changed the map.
65+
66+ /** How often an open map asks for Empryo's updates. */
67+ private const val WATCH_EVERY = 3_000L
68+
69+ /** How many files the map labels by name. */
70+ private const val LABELED = 6
71+
72+ private val MapHeight = 260.dp
73+
74+ /** What the panel shows. */
75+ private sealed interface GenomeLoad {
76+ data object Loading : GenomeLoad
77+
78+ /** Empryo hasn't mapped this checkout yet. */
79+ data object Missing : GenomeLoad
80+
81+ data class Failed(val error: String) : GenomeLoad
82+
83+ data class Ready(val genome: Genome) : GenomeLoad
84+ }
85+
86+ /** Whether a thread runs on Empryo, which keeps a Genome. */
87+ fun hasGenome(app: AppState, ref: ThreadRef) = app.thread(ref)?.provider == "empryo"
88+
89+ @Composable
90+ fun GenomePanel(app: AppState, nav: Nav, ref: ThreadRef) {
91+ val p = palette
92+ val host = app.host(ref.host)
93+ val project = app.thread(ref)?.project.orEmpty()
94+ var load by remember(ref) { mutableStateOf<GenomeLoad>(GenomeLoad.Loading) }
95+ var written by remember(ref) { mutableStateOf<Long?>(null) }
96+ var selected by remember(ref) { mutableStateOf<String?>(null) }
97+
98+ LaunchedEffect(ref, host) {
99+ if (host == null) return@LaunchedEffect
100+ while (true) {
101+ val answer = host.genome(ref.thread, project, written)
102+ written = answer.written
103+ if (!answer.unchanged) {
104+ load = when {
105+ answer.error != null -> GenomeLoad.Failed(answer.error)
106+ answer.genome != null -> GenomeLoad.Ready(answer.genome)
107+ else -> GenomeLoad.Missing
108+ }
109+ }
110+ delay(WATCH_EVERY)
111+ }
112+ }
113+
114+ val genome = (load as? GenomeLoad.Ready)?.genome
115+ Column(Modifier.fillMaxSize()) {
116+ PanelHeader(
117+ Lucide.Dna,
118+ "Genome",
119+ tag = genome?.let { "${it.totalFiles} files · ${it.totalSymbols} symbols" },
120+ ) { nav.surface = null }
121+ Box(Modifier.padding(horizontal = 14.dp, vertical = 4.dp)) { Rule() }
122+ when (val state = load) {
123+ GenomeLoad.Loading -> Message("Reading the Genome…", null)
124+ GenomeLoad.Missing -> Message(
125+ "No Genome here yet",
126+ "Empryo maps a checkout when a thread starts in it. Send this thread a message and the map appears here.",
127+ )
128+ is GenomeLoad.Failed -> Message("Couldn't read the Genome", state.error)
129+ is GenomeLoad.Ready -> {
130+ val selectedIx = state.genome.files.indexOfFirst { it.path == selected }.takeIf { it >= 0 }
131+ Column(
132+ Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = 6.dp, vertical = 4.dp),
133+ verticalArrangement = Arrangement.spacedBy(2.dp),
134+ ) {
135+ GenomeMap(state.genome, selectedIx) { ix ->
136+ val path = state.genome.files[ix].path
137+ selected = if (selected == path) null else path
138+ }
139+ Row(Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 6.dp)) {
140+ Txt(
141+ "Top ${state.genome.files.size} of ${state.genome.totalFiles} files by rank · ${state.genome.totalEdges} imports",
142+ Modifier.weight(1f),
143+ color = p.ink3,
144+ size = 11.5.sp,
145+ )
146+ written?.let { Txt("updated ${timeOfDay(it)}", color = p.ink3, size = 11.5.sp) }
147+ }
148+ Box(Modifier.padding(horizontal = 8.dp)) { Rule() }
149+ state.genome.files.forEachIndexed { ix, file ->
150+ FileRow(ix, file, open = selectedIx == ix) {
151+ selected = if (selected == file.path) null else file.path
152+ }
153+ }
154+ }
155+ }
156+ }
157+ }
158+ }
159+
160+ @Composable
161+ private fun Message(title: String, detail: String?) {
162+ val p = palette
163+ Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
164+ Txt(title, color = p.ink2, size = 13.sp)
165+ if (!detail.isNullOrBlank()) Txt(detail, color = p.ink3, size = 12.sp, lineHeight = 17.sp)
166+ }
167+ }
168+
169+ /**
170+ * The import graph: a dot per file, sized by rank and colored by folder,
171+ * with a line for each import. The file opened in the list lights up with
172+ * its neighbors; tapping a dot opens its file.
173+ */
174+ @Composable
175+ private fun GenomeMap(genome: Genome, focus: Int?, onTap: (Int) -> Unit) {
176+ val p = palette
177+ val density = LocalDensity.current
178+ val maxRank = max(genome.files.maxOfOrNull { it.rank } ?: 0.0, 1e-9)
179+ fun radius(file: GenomeFile): Dp = (3.5 + 8.5 * sqrt(file.rank / maxRank)).dp
180+ val neighbors = focus?.let { f -> genome.edges.mapNotNull { if (it.from == f) it.to else if (it.to == f) it.from else null }.toSet() }.orEmpty()
181+ BoxWithConstraints(
182+ Modifier
183+ .fillMaxWidth()
184+ .height(MapHeight)
185+ .padding(horizontal = 4.dp)
186+ .well(p, RoundedCornerShape(12.dp))
187+ .clip(RoundedCornerShape(12.dp)),
188+ ) {
189+ val inset = 16.dp
190+ val width = maxWidth
191+ fun spot(ix: Int): Pair<Dp, Dp> {
192+ val xy = genome.layout.getOrNull(ix) ?: listOf(0.5f, 0.5f)
193+ return Pair(inset + (width - inset * 2) * xy[0], inset + (MapHeight - inset * 2) * xy.getOrElse(1) { 0.5f })
194+ }
195+ val dim = p.ink3.copy(alpha = 0.3f)
196+ val lit = p.tints[2 % p.tints.size].copy(alpha = 0.9f)
197+ Canvas(
198+ Modifier.fillMaxSize().pointerInput(genome) {
199+ detectTapGestures { tap ->
200+ val hit = genome.files.indices.minByOrNull { ix ->
201+ val (x, y) = spot(ix)
202+ val dx = x.toPx() - tap.x
203+ val dy = y.toPx() - tap.y
204+ dx * dx + dy * dy
205+ } ?: return@detectTapGestures
206+ val (x, y) = spot(hit)
207+ val reach = radius(genome.files[hit]).toPx() + 14.dp.toPx()
208+ val dx = x.toPx() - tap.x
209+ val dy = y.toPx() - tap.y
210+ if (dx * dx + dy * dy <= reach * reach) onTap(hit)
211+ }
212+ },
213+ ) {
214+ for (edge in genome.edges) {
215+ if (edge.from !in genome.files.indices || edge.to !in genome.files.indices) continue
216+ val on = focus != null && (edge.from == focus || edge.to == focus)
217+ val (x1, y1) = spot(edge.from)
218+ val (x2, y2) = spot(edge.to)
219+ val from = Offset(x1.toPx(), y1.toPx())
220+ val to = Offset(x2.toPx(), y2.toPx())
221+ // A slight bend tells crossing lines apart.
222+ val mid = Offset((from.x + to.x) / 2 + (to.y - from.y) * 0.12f, (from.y + to.y) / 2 - (to.x - from.x) * 0.12f)
223+ val path = Path().apply {
224+ moveTo(from.x, from.y)
225+ quadraticTo(mid.x, mid.y, to.x, to.y)
226+ }
227+ drawPath(path, if (on) lit else dim, style = Stroke(width = (if (on) 1.6f else 1f) * density.density))
228+ }
229+ genome.files.forEachIndexed { ix, file ->
230+ val (x, y) = spot(ix)
231+ val center = Offset(x.toPx(), y.toPx())
232+ val r = radius(file).toPx()
233+ val faded = focus != null && ix != focus && ix !in neighbors
234+ val color = folderColor(file.path, p)
235+ drawCircle(if (faded) color.copy(alpha = 0.3f) else color, r, center)
236+ if (ix == focus) drawCircle(p.ink, r, center, style = Stroke(width = 2 * density.density))
237+ else if (ix in neighbors) drawCircle(lit, r, center, style = Stroke(width = density.density))
238+ }
239+ }
240+ // Names for the top files, and for the one in focus.
241+ val labeled = ((0 until minOf(LABELED, genome.files.size)) + listOfNotNull(focus)).distinct()
242+ for (ix in labeled) {
243+ val file = genome.files[ix]
244+ val (x, y) = spot(ix)
245+ val name = file.path.substringAfterLast('/')
246+ val labelWidth = minOf((name.length * 6.2 + 10).dp, 150.dp)
247+ val left = (x - labelWidth / 2).coerceIn(2.dp, width - labelWidth - 2.dp)
248+ val r = radius(file)
249+ val below = y + r + 18.dp < MapHeight
250+ Box(
251+ Modifier
252+ .offset(left, if (below) y + r + 2.dp else y - r - 17.dp)
253+ .width(labelWidth),
254+ contentAlignment = Alignment.Center,
255+ ) {
256+ Txt(
257+ name,
258+ Modifier.background(p.page.copy(alpha = 0.75f), RoundedCornerShape(4.dp)).padding(horizontal = 4.dp),
259+ color = if (ix == focus) p.ink else p.ink2,
260+ size = 10.5.sp,
261+ family = Mono,
262+ maxLines = 1,
263+ )
264+ }
265+ }
266+ }
267+ }
268+
269+ /** A ranked file: its rank, folder color, path, whether it's new and how many files import it; open, its symbols. */
270+ @Composable
271+ private fun FileRow(ix: Int, file: GenomeFile, open: Boolean, onClick: () -> Unit) {
272+ val p = palette
273+ val dir = file.path.substringBeforeLast('/', "").let { if (it.isEmpty()) "" else "$it/" }
274+ val name = file.path.substringAfterLast('/')
275+ Column {
276+ Row(
277+ Modifier
278+ .fillMaxWidth()
279+ .height(40.dp)
280+ .then(if (open) Modifier.background(p.rowHover, RoundedCornerShape(8.dp)) else Modifier)
281+ .pressTint(8.dp, onClick)
282+ .padding(horizontal = 10.dp),
283+ horizontalArrangement = Arrangement.spacedBy(8.dp),
284+ verticalAlignment = Alignment.CenterVertically,
285+ ) {
286+ Txt("${ix + 1}", Modifier.width(20.dp), color = p.ink3, size = 10.5.sp, family = Mono)
287+ Box(Modifier.size(8.dp).background(folderColor(file.path, p), CircleShape))
288+ Row(Modifier.weight(1f)) {
289+ Txt(dir, Modifier.weight(1f, fill = false), color = p.ink3, size = 13.sp, maxLines = 1)
290+ Txt(name, color = p.ink, size = 13.sp, maxLines = 1)
291+ }
292+ if (file.recent) Tag("new")
293+ if (file.dependents > 0) Txt("→${file.dependents}", color = p.ink2, size = 11.sp, family = Mono)
294+ }
295+ if (open) {
296+ Column(
297+ Modifier.fillMaxWidth().padding(start = 46.dp, end = 10.dp, bottom = 8.dp),
298+ verticalArrangement = Arrangement.spacedBy(3.dp),
299+ ) {
300+ Txt(
301+ "${file.language} · ${file.lines} lines · imported by ${file.dependents} · imports ${file.dependencies}",
302+ color = p.ink3,
303+ size = 11.5.sp,
304+ )
305+ if (file.symbols.isEmpty()) Txt("No symbols", color = p.ink3, size = 12.sp)
306+ for (symbol in file.symbols) {
307+ val signature = symbol.signature?.lineSequence()?.firstOrNull()?.trim()?.takeIf { it.isNotEmpty() }
308+ ?: "${symbol.kind} ${symbol.name}"
309+ Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
310+ Txt(
311+ if (symbol.exported) "+" else " ",
312+ Modifier.width(10.dp),
313+ color = if (symbol.exported) p.greenInk else p.ink3,
314+ size = 11.5.sp,
315+ family = Mono,
316+ )
317+ Txt(signature, Modifier.weight(1f), color = p.ink2, size = 11.5.sp, family = Mono, maxLines = 1)
318+ Txt(":${symbol.line}", color = p.ink3, size = 11.5.sp, family = Mono)
319+ }
320+ }
321+ val more = file.symbolCount - file.symbols.size
322+ if (more > 0) Txt("… $more more", color = p.ink3, size = 11.5.sp)
323+ }
324+ }
325+ }
326+ }
327+
328+ /** A file's color in the map: one of the palette's tints, the same for every file in a folder. */
329+ private fun folderColor(path: String, p: Palette): Color {
330+ val folder = path.substringBeforeLast('/', "")
331+ // The desktop's hash, so a folder has the same color on both.
332+ var hash = 7u
333+ for (byte in folder.encodeToByteArray()) hash = hash * 31u + byte.toUByte().toUInt()
334+ return p.tints[(hash % p.tints.size.toUInt()).toInt()]
335+ }
336+
337+ private fun timeOfDay(millis: Long): String =
338+ java.text.DateFormat.getTimeInstance(java.text.DateFormat.SHORT).format(java.util.Date(millis))
+6−3android/app/src/main/java/cc/vectrum/code/ui/panels/RightPanel.ktView File
@@ -60,6 +60,7 @@ private val SURFACES = listOf(
6060 Triple(Surface.Subagents, Lucide.Bot, "Subagents"),
6161 Triple(Surface.Device, Lucide.Smartphone, "Device"),
6262 Triple(Surface.Terminal, Lucide.SquareTerminal, "Terminal"),
63+ Triple(Surface.Genome, Lucide.Dna, "Genome map"),
6364 )
6465
6566 /** The right panel over a thread: "Open a surface", or the surface picked. */
@@ -84,8 +85,9 @@ fun RightPanel(app: AppState, nav: Nav, ref: ThreadRef, shape: Shape = RoundedCo
8485 ) {
8586 val surface = nav.surface
8687 when {
87- nav.surfacePicker || surface == null -> SurfacePicker(nav)
88+ nav.surfacePicker || surface == null -> SurfacePicker(nav, genome = hasGenome(app, ref))
8889 surface == Surface.Subagents -> SubagentsPanel(app, nav, ref)
90+ surface == Surface.Genome -> GenomePanel(app, nav, ref)
8991 // The terminal and device panels draw their own tab bar with a close button.
9092 surface == Surface.Terminal -> TerminalPanel(app, nav, ref)
9193 else -> DevicePanel(app, nav, ref)
@@ -112,7 +114,7 @@ fun PanelHeader(icon: Lucide, title: String, tag: String? = null, onClose: () ->
112114
113115 /** "Open a surface": the surfaces to pick from, on a raised card. */
114116 @Composable
115- private fun SurfacePicker(nav: Nav) {
117+ private fun SurfacePicker(nav: Nav, genome: Boolean) {
116118 val p = palette
117119 Column(Modifier.fillMaxSize()) {
118120 Row(Modifier.fillMaxWidth().height(48.dp).padding(end = 10.dp), verticalAlignment = Alignment.CenterVertically) {
@@ -135,7 +137,8 @@ private fun SurfacePicker(nav: Nav) {
135137 style = TextStyle(textAlign = TextAlign.Center),
136138 )
137139 Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
138- for ((surface, icon, name) in SURFACES) {
140+ // The Genome map is Empryo's; other agents' threads don't list it.
141+ for ((surface, icon, name) in SURFACES.filter { it.first != Surface.Genome || genome }) {
139142 Row(
140143 Modifier
141144 .fillMaxWidth()
+12−1android/app/src/main/java/cc/vectrum/code/ui/sidebar/Sidebar.ktView File
@@ -67,6 +67,8 @@ import cc.vectrum.code.ui.LucideIcon
6767 import cc.vectrum.code.ui.MenuItem
6868 import cc.vectrum.code.ui.Nav
6969 import cc.vectrum.code.ui.Screen
70+ import cc.vectrum.code.ui.Surface
71+ import cc.vectrum.code.ui.panels.hasGenome
7072 import cc.vectrum.code.ui.pair.AddProjectSheet
7173 import cc.vectrum.code.util.initials
7274 import cc.vectrum.code.util.tintFor
@@ -419,7 +421,7 @@ internal fun InboxEmpty(app: AppState, nav: Nav, scope: ProjectKey?, hasProjects
419421 }
420422 }
421423
422- /** The foot: Settings, and each host with its connection light. */
424+ /** The foot: Settings, the Genome map for an Empryo thread, and each host with its connection light. */
423425 @Composable
424426 private fun Footer(app: AppState, nav: Nav) {
425427 val p = palette
@@ -429,6 +431,15 @@ private fun Footer(app: AppState, nav: Nav) {
429431 verticalAlignment = Alignment.CenterVertically,
430432 ) {
431433 Cap(icon = Lucide.Settings, kind = CapKind.Ghost) { nav.go(Screen.Settings()) }
434+ // Empryo's map of the open thread's code, while the thread runs on Empryo.
435+ val thread = (nav.screen as? Screen.Thread)?.ref
436+ if (thread != null && hasGenome(app, thread)) {
437+ Cap(icon = Lucide.Dna, kind = CapKind.Ghost) {
438+ nav.sidebarOpen = false
439+ nav.surfacePicker = false
440+ nav.surface = Surface.Genome
441+ }
442+ }
432443 Row(
433444 Modifier.weight(1f).horizontalScroll(rememberScrollState()),
434445 horizontalArrangement = Arrangement.spacedBy(2.dp),