Vectrum
Sign In

Android: sidebar inbox, thread menu, command palette and right panel

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

Showing 20 changed files with 2590 additions and 13 deletions.

+9−0android/app/src/main/java/cc/vectrum/code/ui/Icons.ktView File
@@ -69,11 +69,16 @@ enum class Lucide(val res: Int) {
6969 FolderGit2(R.drawable.lucide_folder_git_2),
7070 FolderOpen(R.drawable.lucide_folder_open),
7171 FolderPlus(R.drawable.lucide_folder_plus),
72+ FolderTree(R.drawable.lucide_folder_tree),
7273 Gauge(R.drawable.lucide_gauge),
7374 GitBranch(R.drawable.lucide_git_branch),
7475 GitCommitHorizontal(R.drawable.lucide_git_commit_horizontal),
7576 GitFork(R.drawable.lucide_git_fork),
77+ GitMerge(R.drawable.lucide_git_merge),
7678 GitPullRequest(R.drawable.lucide_git_pull_request),
79+ GitPullRequestArrow(R.drawable.lucide_git_pull_request_arrow),
80+ GitPullRequestClosed(R.drawable.lucide_git_pull_request_closed),
81+ GitPullRequestDraft(R.drawable.lucide_git_pull_request_draft),
7782 Github(R.drawable.lucide_github),
7883 Globe(R.drawable.lucide_globe),
7984 Hammer(R.drawable.lucide_hammer),
@@ -86,6 +91,7 @@ enum class Lucide(val res: Int) {
8691 Keyboard(R.drawable.lucide_keyboard),
8792 Laptop(R.drawable.lucide_laptop),
8893 Layers(R.drawable.lucide_layers),
94+ Link(R.drawable.lucide_link),
8995 Link2(R.drawable.lucide_link_2),
9096 List(R.drawable.lucide_list),
9197 ListChecks(R.drawable.lucide_list_checks),
@@ -95,12 +101,15 @@ enum class Lucide(val res: Int) {
95101 Lock(R.drawable.lucide_lock),
96102 LockOpen(R.drawable.lucide_lock_open),
97103 Mail(R.drawable.lucide_mail),
104+ MailOpen(R.drawable.lucide_mail_open),
98105 MapPin(R.drawable.lucide_map_pin),
99106 MemoryStick(R.drawable.lucide_memory_stick),
100107 MessageCircleQuestionMark(R.drawable.lucide_message_circle_question_mark),
101108 MessageSquare(R.drawable.lucide_message_square),
109+ MessageSquarePlus(R.drawable.lucide_message_square_plus),
102110 Mic(R.drawable.lucide_mic),
103111 Minus(R.drawable.lucide_minus),
112+ Monitor(R.drawable.lucide_monitor),
104113 MonitorSmartphone(R.drawable.lucide_monitor_smartphone),
105114 Moon(R.drawable.lucide_moon),
106115 MoonStar(R.drawable.lucide_moon_star),
+549−4android/app/src/main/java/cc/vectrum/code/ui/panels/CommandPalette.ktView File
@@ -1,17 +1,562 @@
11 package cc.vectrum.code.ui.panels
22
3+ import android.content.Context
4+ import androidx.activity.compose.BackHandler
5+ import androidx.compose.foundation.background
6+ import androidx.compose.foundation.clickable
7+ import androidx.compose.foundation.interaction.MutableInteractionSource
8+ import androidx.compose.foundation.interaction.collectIsPressedAsState
9+ import androidx.compose.foundation.layout.Arrangement
310 import androidx.compose.foundation.layout.Box
11+ import androidx.compose.foundation.layout.Column
12+ import androidx.compose.foundation.layout.Row
13+ import androidx.compose.foundation.layout.defaultMinSize
14+ import androidx.compose.foundation.layout.fillMaxSize
15+ import androidx.compose.foundation.layout.fillMaxWidth
16+ import androidx.compose.foundation.layout.height
17+ import androidx.compose.foundation.layout.heightIn
418 import androidx.compose.foundation.layout.padding
19+ import androidx.compose.foundation.layout.width
20+ import androidx.compose.foundation.layout.widthIn
21+ import androidx.compose.foundation.rememberScrollState
22+ import androidx.compose.foundation.shape.RoundedCornerShape
23+ import androidx.compose.foundation.text.BasicTextField
24+ import androidx.compose.foundation.text.KeyboardActions
25+ import androidx.compose.foundation.text.KeyboardOptions
26+ import androidx.compose.foundation.verticalScroll
527 import androidx.compose.runtime.Composable
28+ import androidx.compose.runtime.LaunchedEffect
29+ import androidx.compose.runtime.getValue
30+ import androidx.compose.runtime.mutableStateListOf
31+ import androidx.compose.runtime.mutableStateOf
32+ import androidx.compose.runtime.remember
33+ import androidx.compose.runtime.setValue
34+ import androidx.compose.ui.Alignment
635 import androidx.compose.ui.Modifier
36+ import androidx.compose.ui.focus.FocusRequester
37+ import androidx.compose.ui.focus.focusRequester
38+ import androidx.compose.ui.graphics.Color
39+ import androidx.compose.ui.graphics.SolidColor
40+ import androidx.compose.ui.platform.LocalContext
41+ import androidx.compose.ui.text.SpanStyle
42+ import androidx.compose.ui.text.TextStyle
43+ import androidx.compose.ui.text.buildAnnotatedString
44+ import androidx.compose.ui.text.font.FontWeight
45+ import androidx.compose.ui.text.input.ImeAction
46+ import androidx.compose.ui.text.style.TextOverflow
47+ import androidx.compose.ui.text.withStyle
748 import androidx.compose.ui.unit.dp
49+ import androidx.compose.ui.unit.sp
50+ import androidx.compose.foundation.text.BasicText
851 import cc.vectrum.code.data.AppState
9- import cc.vectrum.code.data.OpenThread
10- import cc.vectrum.code.data.ThreadRef
52+ import cc.vectrum.code.data.HostState
53+ import cc.vectrum.code.theme.Badge
1154 import cc.vectrum.code.theme.Engraved
55+ import cc.vectrum.code.theme.Rule
56+ import cc.vectrum.code.theme.Sans
57+ import cc.vectrum.code.theme.Tag
58+ import cc.vectrum.code.theme.Themes
59+ import cc.vectrum.code.theme.Txt
60+ import cc.vectrum.code.theme.menu
61+ import cc.vectrum.code.theme.palette
62+ import cc.vectrum.code.ui.Lucide
63+ import cc.vectrum.code.ui.LucideIcon
1264 import cc.vectrum.code.ui.Nav
1365 import cc.vectrum.code.ui.Screen
66+ import cc.vectrum.code.ui.pair.AddProjectSheet
67+ import cc.vectrum.code.ui.sidebar.Entry
68+ import cc.vectrum.code.ui.sidebar.allEntries
69+ import cc.vectrum.code.ui.sidebar.copy
70+ import cc.vectrum.code.ui.sidebar.displayPath
71+ import cc.vectrum.code.ui.sidebar.projectName
72+ import cc.vectrum.code.ui.sidebar.relativeTimeLong
73+ import cc.vectrum.code.util.initials
74+ import cc.vectrum.code.util.tintFor
75+
76+ // The command palette (src/ui/workspace/palette.rs), as T3 Code has it:
77+ // Actions and Recent Threads at rest, plus Projects, Settings and Threads
78+ // while searching, with submenus for starting a thread in a project, the
79+ // theme and the appearance. A leading ">" searches actions only. What runs
80+ // on the desktop alone (usage, restarting an agent, scratch threads) is
81+ // left out.
82+
83+ private const val RECENT_THREADS = 12
84+
85+ /** Where the palette is: its root, or one of its submenus. */
86+ private enum class PaletteView(val placeholder: String) {
87+ Root("Search commands, projects, and threads..."),
88+ NewThreadIn("Search..."),
89+ Themes("Search..."),
90+ Appearance("Search..."),
91+ }
92+
93+ /** What choosing a row does. */
94+ private sealed interface PaletteAction {
95+ /** Runs and closes the palette, unless it hands over to something that replaces it. */
96+ class Run(val close: Boolean = true, val run: () -> Unit) : PaletteAction
97+
98+ class Submenu(val view: PaletteView) : PaletteAction
99+ }
100+
101+ /** What leads a row: an icon, or a project's initials in its tint. */
102+ private sealed interface Glyph {
103+ class Icon(val icon: Lucide) : Glyph
104+
105+ class ProjectBadge(val name: String) : Glyph
106+ }
107+
108+ /** One row of the palette. */
109+ private class PaletteItem(
110+ val group: String,
111+ val title: String,
112+ val glyph: Glyph,
113+ val action: PaletteAction,
114+ /** Set in bold after the title: "New thread in **project**". */
115+ val emphasis: String? = null,
116+ val description: String? = null,
117+ /** Shown before the description: the server of `XPS · ~/Work/app`. */
118+ val descriptionIcon: Lucide? = null,
119+ val timestamp: String? = null,
120+ /** An engraved tag: `Current` on the theme in use. */
121+ val tag: String? = null,
122+ extraTerms: List<String> = emptyList(),
123+ ) {
124+ /** What a search matches, most important first. */
125+ val terms: List<String> = listOfNotNull(title) + listOfNotNull(description) + extraTerms
126+
127+ /** Every word must appear in a term; an exact match beats a prefix beats a substring, and earlier terms win. */
128+ fun score(words: List<String>): Int? {
129+ val lower = terms.map { it.lowercase() }
130+ var total = 0
131+ for (word in words) {
132+ val best = lower.mapIndexedNotNull { ix, term ->
133+ val rank = when {
134+ term == word -> 0
135+ term.startsWith(word) -> 1
136+ term.contains(word) -> 2
137+ else -> return@mapIndexedNotNull null
138+ }
139+ rank * 10 + minOf(ix, 9)
140+ }.minOrNull() ?: return null
141+ total += best
142+ }
143+ return total
144+ }
145+ }
146+
147+ /** Settings the palette finds by name, with the page they're on. Desktop-only ones are left out. */
148+ private val SETTINGS = listOf(
149+ "Model" to "general",
150+ "Permissions" to "general",
151+ "Workspace" to "general",
152+ "Text generation model" to "general",
153+ "Start from origin" to "general",
154+ "Auto-resume limited threads" to "general",
155+ "Snooze limited threads" to "general",
156+ "Working section (beta)" to "general",
157+ "Auto-settle merged threads" to "general",
158+ "Auto-settle inactive threads" to "general",
159+ "Unpin confirmation" to "general",
160+ "Delete confirmation" to "general",
161+ "Thread notifications" to "behavior",
162+ "Time format" to "behavior",
163+ "Response streaming" to "behavior",
164+ "Fold finished turns" to "behavior",
165+ "Show costs" to "behavior",
166+ "Hide whitespace changes" to "behavior",
167+ "Diff layout" to "behavior",
168+ "Follow-up behavior" to "behavior",
169+ "Thread cards" to "appearance",
170+ "Theme" to "appearance",
171+ "Providers" to "providers",
172+ "Connections" to "connections",
173+ "Archived threads" to "archive",
174+ "About" to "about",
175+ )
176+
177+ private fun pageLabel(page: String) = page.replaceFirstChar { it.uppercase() }
178+
179+ /** The command palette over a scrim, shown while `nav.palette` is set. */
180+ @Composable
181+ fun CommandPalette(app: AppState, nav: Nav) {
182+ val p = palette
183+ val context = LocalContext.current
184+ val stack = remember { mutableStateListOf(PaletteView.Root) }
185+ var query by remember { mutableStateOf("") }
186+ var addingProject by remember { mutableStateOf<HostState?>(null) }
187+ var adding by remember { mutableStateOf(false) }
188+ val view = stack.last()
189+ val focus = remember { FocusRequester() }
190+
191+ if (adding) {
192+ // Add project replaces the palette, and closes it when it's done.
193+ AddProjectSheet(app, addingProject, true) { nav.palette = false }
194+ return
195+ }
196+
197+ BackHandler(enabled = stack.size > 1) {
198+ stack.removeAt(stack.lastIndex)
199+ query = ""
200+ }
201+
202+ fun execute(item: PaletteItem) {
203+ when (val action = item.action) {
204+ is PaletteAction.Submenu -> {
205+ stack.add(action.view)
206+ query = ""
207+ }
208+ is PaletteAction.Run -> {
209+ if (action.close) nav.palette = false
210+ action.run()
211+ }
212+ }
213+ }
214+
215+ val dark = p.dark
216+ val items = paletteItems(app, nav, context, view, query.isNotBlank(), dark, onAddProject = { host ->
217+ addingProject = host
218+ adding = true
219+ })
220+ val actionsOnly = view == PaletteView.Root && query.trimStart().startsWith(">")
221+ val results = results(items, query, actionsOnly)
222+
223+ Box(
224+ Modifier
225+ .fillMaxSize()
226+ .background(Color.Black.copy(alpha = if (p.dark) 0.4f else 0.16f))
227+ .clickable(interactionSource = null, indication = null) { nav.palette = false },
228+ contentAlignment = Alignment.TopCenter,
229+ ) {
230+ Column(
231+ Modifier
232+ .padding(horizontal = 8.dp, vertical = 16.dp)
233+ .widthIn(max = 600.dp)
234+ .fillMaxWidth()
235+ .menu(p, RoundedCornerShape(18.dp))
236+ .clickable(interactionSource = null, indication = null) {},
237+ ) {
238+ Row(
239+ Modifier.fillMaxWidth().height(48.dp).padding(horizontal = 14.dp),
240+ horizontalArrangement = Arrangement.spacedBy(10.dp),
241+ verticalAlignment = Alignment.CenterVertically,
242+ ) {
243+ if (stack.size > 1) {
244+ Box(
245+ Modifier.clickable(interactionSource = null, indication = null) {
246+ stack.removeAt(stack.lastIndex)
247+ query = ""
248+ },
249+ ) { LucideIcon(Lucide.ArrowLeft, size = 16.dp, tint = p.ink2) }
250+ } else {
251+ LucideIcon(Lucide.Search, size = 16.dp, tint = p.fieldInk)
252+ }
253+ Box(Modifier.weight(1f), contentAlignment = Alignment.CenterStart) {
254+ if (query.isEmpty()) Txt(view.placeholder, color = p.fieldInk, size = 14.sp, maxLines = 1)
255+ BasicTextField(
256+ value = query,
257+ onValueChange = { query = it },
258+ singleLine = true,
259+ textStyle = TextStyle(color = p.ink, fontSize = 14.sp, fontFamily = Sans),
260+ cursorBrush = SolidColor(p.ink),
261+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Go),
262+ keyboardActions = KeyboardActions(onGo = { results.firstOrNull()?.let { execute(it) } }),
263+ modifier = Modifier.fillMaxWidth().focusRequester(focus),
264+ )
265+ }
266+ }
267+ Rule()
268+ Column(
269+ Modifier
270+ .heightIn(max = 448.dp)
271+ .verticalScroll(rememberScrollState())
272+ .padding(6.dp),
273+ ) {
274+ if (results.isEmpty()) {
275+ val message = when {
276+ actionsOnly -> "No matching actions."
277+ view == PaletteView.Root -> "No matching commands, projects, or threads."
278+ else -> "No matches."
279+ }
280+ Box(Modifier.fillMaxWidth().padding(vertical = 40.dp), contentAlignment = Alignment.Center) {
281+ Txt(message, color = p.ink3, size = 13.sp)
282+ }
283+ }
284+ var lastGroup: String? = null
285+ results.forEachIndexed { ix, item ->
286+ if (item.group != lastGroup) {
287+ lastGroup = item.group
288+ Engraved(item.group, Modifier.padding(start = 10.dp, end = 10.dp, top = if (ix == 0) 4.dp else 10.dp, bottom = 4.dp))
289+ }
290+ PaletteRow(item, highlighted = ix == 0 && query.isNotBlank()) { execute(item) }
291+ }
292+ }
293+ }
294+ }
295+ LaunchedEffect(view) { focus.requestFocus() }
296+ }
297+
298+ /** The rows that match the query, in their groups, best first. */
299+ private fun results(items: List<PaletteItem>, raw: String, actionsOnly: Boolean): List<PaletteItem> {
300+ val query = raw.trimStart().removePrefix(">").trim().lowercase()
301+ val filtered = items.filter { !actionsOnly || it.group == "Actions" }
302+ val words = query.split(Regex("\\s+")).filter { it.isNotEmpty() }
303+ if (words.isEmpty()) return filtered
304+ val groups = filtered.map { it.group }.distinct()
305+ return filtered.mapIndexedNotNull { order, item ->
306+ val score = item.score(words) ?: return@mapIndexedNotNull null
307+ // Thread titles tie, keeping the most recent first.
308+ Triple(groups.indexOf(item.group), if (item.group == "Threads") 0 else score, order) to item
309+ }.sortedWith(compareBy({ it.first.first }, { it.first.second }, { it.first.third })).map { it.second }
310+ }
311+
312+ /** The project a new thread starts in: the open thread's, or the first. */
313+ private fun contextualProject(app: AppState, nav: Nav): Pair<HostState, String>? {
314+ val screen = nav.screen
315+ if (screen is Screen.Thread) {
316+ val host = app.host(screen.ref.host)
317+ val summary = host?.threads?.get(screen.ref.thread)
318+ if (host != null && summary != null) return host to summary.project
319+ }
320+ if (screen is Screen.NewThread && screen.host != null && screen.project != null) {
321+ app.host(screen.host)?.let { return it to screen.project }
322+ }
323+ val host = app.hosts.firstOrNull { it.projects.isNotEmpty() } ?: return null
324+ return host to host.projects.first().path
325+ }
326+
327+ /** A project's row, which starts a thread in it. */
328+ private fun projectItem(app: AppState, nav: Nav, host: HostState, path: String, group: String = "Projects"): PaletteItem {
329+ val name = projectName(host, path)
330+ return PaletteItem(
331+ group = group,
332+ title = name,
333+ glyph = Glyph.ProjectBadge(name),
334+ description = "${host.name} · ${displayPath(path)}",
335+ descriptionIcon = Lucide.Server,
336+ action = PaletteAction.Run { nav.go(Screen.NewThread(host.saved.id, path)) },
337+ )
338+ }
339+
340+ private fun paletteItems(
341+ app: AppState,
342+ nav: Nav,
343+ context: Context,
344+ view: PaletteView,
345+ searching: Boolean,
346+ showingDark: Boolean,
347+ onAddProject: (HostState?) -> Unit,
348+ ): List<PaletteItem> = when (view) {
349+ PaletteView.Root -> buildList {
350+ addAll(actions(app, nav, context, onAddProject))
351+ if (searching) {
352+ for (host in app.hosts) for (project in host.projects) add(projectItem(app, nav, host, project.path))
353+ for ((title, page) in SETTINGS) {
354+ add(
355+ PaletteItem(
356+ "Settings",
357+ title,
358+ Glyph.Icon(Lucide.Settings),
359+ description = "Settings · ${pageLabel(page)}",
360+ action = PaletteAction.Run { nav.go(Screen.Settings(page)) },
361+ ),
362+ )
363+ }
364+ addAll(threads(app, nav, Int.MAX_VALUE, "Threads"))
365+ } else {
366+ addAll(threads(app, nav, RECENT_THREADS, "Recent Threads"))
367+ }
368+ }
369+ PaletteView.NewThreadIn -> {
370+ val current = contextualProject(app, nav)
371+ val all = app.hosts.flatMap { host -> host.projects.map { host to it.path } }
372+ val ordered = listOfNotNull(current?.takeIf { it in all }) + all.filter { it != current }
373+ ordered.map { (host, path) -> projectItem(app, nav, host, path) }
374+ }
375+ PaletteView.Themes -> {
376+ val prefs = app.prefs
377+ val mode = prefs.themeMode
378+ Themes.all.map { theme ->
379+ val dark = theme.palette.dark
380+ val current = (dark && theme.id == prefs.darkTheme && (showingDark || mode == "dark")) ||
381+ (!dark && theme.id == prefs.lightTheme && (!showingDark || mode == "light"))
382+ PaletteItem(
383+ "Themes",
384+ theme.name,
385+ Glyph.Icon(Lucide.Palette),
386+ description = if (dark) "Dark" else "Light",
387+ tag = if (current) "Current" else null,
388+ action = PaletteAction.Run {
389+ // The theme becomes the light or dark one, and its mode unless the mode follows the system.
390+ prefs.set(if (dark) "dark_theme" else "light_theme", theme.id)
391+ if (prefs.themeMode != "system") prefs.set("theme_mode", if (dark) "dark" else "light")
392+ },
393+ )
394+ }
395+ }
396+ PaletteView.Appearance -> listOf("system" to "System", "light" to "Light", "dark" to "Dark").map { (mode, label) ->
397+ PaletteItem(
398+ "Appearance",
399+ label,
400+ Glyph.Icon(Lucide.Monitor),
401+ tag = if (app.prefs.themeMode == mode) "Current" else null,
402+ action = PaletteAction.Run { app.prefs.set("theme_mode", mode) },
403+ )
404+ }
405+ }
406+
407+ private fun actions(app: AppState, nav: Nav, context: Context, onAddProject: (HostState?) -> Unit): List<PaletteItem> = buildList {
408+ val contextual = contextualProject(app, nav)
409+ if (contextual != null) {
410+ val (host, path) = contextual
411+ val name = projectName(host, path)
412+ add(
413+ PaletteItem(
414+ "Actions",
415+ "New thread in ",
416+ Glyph.Icon(Lucide.SquarePen),
417+ emphasis = name,
418+ extraTerms = listOf("New thread in $name", "new chat"),
419+ action = PaletteAction.Run { nav.go(Screen.NewThread(host.saved.id, path)) },
420+ ),
421+ )
422+ add(
423+ PaletteItem(
424+ "Actions",
425+ "New thread in...",
426+ Glyph.Icon(Lucide.SquarePen),
427+ extraTerms = listOf("project", "new chat"),
428+ action = PaletteAction.Submenu(PaletteView.NewThreadIn),
429+ ),
430+ )
431+ }
432+ val screen = nav.screen
433+ if (screen is Screen.Thread) {
434+ val host = app.host(screen.ref.host)
435+ val summary = host?.threads?.get(screen.ref.thread)
436+ if (host != null && summary != null) {
437+ val pr = host.open[summary.id]?.detail?.pullRequest
438+ val (title, value) = if (pr != null) "Copy PR link" to pr.url else "Copy thread ID" to summary.id
439+ add(
440+ PaletteItem(
441+ "Actions",
442+ title,
443+ Glyph.Icon(Lucide.Link),
444+ description = value,
445+ action = PaletteAction.Run {
446+ copy(context, app, value, if (pr != null) "PR link copied" else "Thread ID copied")
447+ },
448+ ),
449+ )
450+ }
451+ }
452+ if (app.hosts.any { it.connected }) {
453+ add(
454+ PaletteItem(
455+ "Actions",
456+ "Add project",
457+ Glyph.Icon(Lucide.FolderPlus),
458+ extraTerms = listOf("open folder", "local folder", "clone", "new project"),
459+ action = PaletteAction.Run(close = false) {
460+ val host = contextual?.first ?: app.hosts.singleOrNull()
461+ onAddProject(host)
462+ },
463+ ),
464+ )
465+ }
466+ add(
467+ PaletteItem(
468+ "Actions",
469+ "Change theme",
470+ Glyph.Icon(Lucide.Palette),
471+ extraTerms = listOf("color scheme", "colors"),
472+ action = PaletteAction.Submenu(PaletteView.Themes),
473+ ),
474+ )
475+ add(
476+ PaletteItem(
477+ "Actions",
478+ "Change appearance",
479+ Glyph.Icon(Lucide.Monitor),
480+ extraTerms = listOf("light", "dark", "system", "mode"),
481+ action = PaletteAction.Submenu(PaletteView.Appearance),
482+ ),
483+ )
484+ add(
485+ PaletteItem(
486+ "Actions",
487+ "Open settings",
488+ Glyph.Icon(Lucide.Settings),
489+ extraTerms = listOf("preferences"),
490+ action = PaletteAction.Run { nav.go(Screen.Settings()) },
491+ ),
492+ )
493+ }
494+
495+ /** Threads, most recently updated first: the recent ones at rest, all while searching. */
496+ private fun threads(app: AppState, nav: Nav, limit: Int, group: String): List<PaletteItem> {
497+ val active = (nav.screen as? Screen.Thread)?.ref
498+ return allEntries(app)
499+ .sortedByDescending { it.summary.updated }
500+ .take(limit)
501+ .map { entry: Entry ->
502+ val description = buildString {
503+ append(entry.projectName)
504+ entry.branch?.let { append(" · #$it") }
505+ if (entry.ref == active) append(" · Current thread")
506+ }
507+ PaletteItem(
508+ group,
509+ entry.summary.title,
510+ Glyph.Icon(Lucide.MessageSquare),
511+ description = description,
512+ timestamp = relativeTimeLong(entry.summary.updated),
513+ action = PaletteAction.Run { nav.openThread(entry.ref) },
514+ )
515+ }
516+ }
14517
15- // Placeholder until it's built.
16518 @Composable
17- fun CommandPalette(app: AppState, nav: Nav) {}
519+ private fun PaletteRow(item: PaletteItem, highlighted: Boolean, onClick: () -> Unit) {
520+ val p = palette
521+ val interaction = remember { MutableInteractionSource() }
522+ val pressed by interaction.collectIsPressedAsState()
523+ val shape = RoundedCornerShape(10.dp)
524+ Row(
525+ Modifier
526+ .fillMaxWidth()
527+ .defaultMinSize(minHeight = 40.dp)
528+ .then(if (pressed || highlighted) Modifier.background(p.menuRowHover, shape) else Modifier)
529+ .clickable(interactionSource = interaction, indication = null, onClick = onClick)
530+ .padding(horizontal = 10.dp, vertical = 6.dp),
531+ horizontalArrangement = Arrangement.spacedBy(10.dp),
532+ verticalAlignment = Alignment.CenterVertically,
533+ ) {
534+ Box(Modifier.width(20.dp), contentAlignment = Alignment.Center) {
535+ when (val glyph = item.glyph) {
536+ is Glyph.Icon -> LucideIcon(glyph.icon, size = 16.dp, tint = p.icon)
537+ is Glyph.ProjectBadge -> Badge(initials(glyph.name), tintFor(glyph.name))
538+ }
539+ }
540+ Column(Modifier.weight(1f)) {
541+ BasicText(
542+ buildAnnotatedString {
543+ append(item.title)
544+ item.emphasis?.let { withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) { append(it) } }
545+ },
546+ style = TextStyle(color = p.ink, fontSize = 13.5.sp, fontFamily = Sans),
547+ maxLines = 1,
548+ overflow = TextOverflow.Ellipsis,
549+ )
550+ val description = item.description
551+ if (description != null) {
552+ Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) {
553+ item.descriptionIcon?.let { LucideIcon(it, size = 12.dp, tint = p.ink3) }
554+ Txt(description, color = p.ink3, size = 12.sp, maxLines = 1)
555+ }
556+ }
557+ }
558+ item.tag?.let { Tag(it) }
559+ item.timestamp?.let { Txt(it, color = p.ink3, size = 12.sp, maxLines = 1) }
560+ if (item.action is PaletteAction.Submenu) LucideIcon(Lucide.ChevronRight, size = 16.dp, tint = p.ink3)
561+ }
562+ }
+144−5android/app/src/main/java/cc/vectrum/code/ui/panels/RightPanel.ktView File
@@ -1,17 +1,156 @@
11 package cc.vectrum.code.ui.panels
22
3+ import androidx.compose.animation.AnimatedVisibility
4+ import androidx.compose.animation.core.MutableTransitionState
5+ import androidx.compose.animation.fadeIn
6+ import androidx.compose.animation.slideInHorizontally
7+ import androidx.compose.foundation.clickable
8+ import androidx.compose.foundation.layout.Arrangement
39 import androidx.compose.foundation.layout.Box
10+ import androidx.compose.foundation.layout.Column
11+ import androidx.compose.foundation.layout.Row
12+ import androidx.compose.foundation.layout.fillMaxSize
13+ import androidx.compose.foundation.layout.fillMaxWidth
14+ import androidx.compose.foundation.layout.height
415 import androidx.compose.foundation.layout.padding
16+ import androidx.compose.foundation.layout.widthIn
17+ import androidx.compose.foundation.shape.RoundedCornerShape
518 import androidx.compose.runtime.Composable
19+ import androidx.compose.runtime.remember
20+ import androidx.compose.ui.Alignment
621 import androidx.compose.ui.Modifier
22+ import androidx.compose.ui.draw.clip
23+ import androidx.compose.ui.text.font.FontWeight
24+ import androidx.compose.ui.text.style.TextAlign
725 import androidx.compose.ui.unit.dp
26+ import androidx.compose.ui.unit.sp
27+ import androidx.compose.ui.text.TextStyle
828 import cc.vectrum.code.data.AppState
9- import cc.vectrum.code.data.OpenThread
1029 import cc.vectrum.code.data.ThreadRef
11- import cc.vectrum.code.theme.Engraved
30+ import cc.vectrum.code.theme.Cap
31+ import cc.vectrum.code.theme.CapKind
32+ import cc.vectrum.code.theme.CapSize
33+ import cc.vectrum.code.theme.Tag
34+ import cc.vectrum.code.theme.Txt
35+ import cc.vectrum.code.theme.palette
36+ import cc.vectrum.code.theme.plate
37+ import cc.vectrum.code.theme.raised
38+ import cc.vectrum.code.ui.Lucide
39+ import cc.vectrum.code.ui.LucideIcon
1240 import cc.vectrum.code.ui.Nav
13- import cc.vectrum.code.ui.Screen
41+ import cc.vectrum.code.ui.SheetRadius
42+ import cc.vectrum.code.ui.Surface
43+ import cc.vectrum.code.ui.device.DevicePanel
44+ import cc.vectrum.code.ui.sidebar.pressTint
45+ import cc.vectrum.code.ui.terminal.TerminalPanel
1446
15- // Placeholder until it's built.
47+ // The right panel (src/ui/workspace/surfaces.rs): one surface at a time
48+ // next to a thread, opened from "Open a surface". On a phone it slides
49+ // over the sheet, full height, with a header to close it. There's no
50+ // Browser: the protocol can't stream it.
51+
52+ /** The surfaces the picker lists, each with its icon and name. */
53+ private val SURFACES = listOf(
54+ Triple(Surface.Subagents, Lucide.Bot, "Subagents"),
55+ Triple(Surface.Device, Lucide.Smartphone, "Device"),
56+ Triple(Surface.Terminal, Lucide.SquareTerminal, "Terminal"),
57+ )
58+
59+ /** The right panel over a thread: "Open a surface", or the surface picked. */
60+ @Composable
61+ fun RightPanel(app: AppState, nav: Nav, ref: ThreadRef) {
62+ val p = palette
63+ val shown = remember { MutableTransitionState(false) }
64+ shown.targetState = true
65+ val shape = RoundedCornerShape(SheetRadius)
66+ AnimatedVisibility(
67+ visibleState = shown,
68+ enter = slideInHorizontally { it / 3 } + fadeIn(),
69+ ) {
70+ Box(
71+ Modifier
72+ .fillMaxSize()
73+ .plate(p, shape)
74+ .clip(shape)
75+ // The thread underneath doesn't take touches meant for the panel.
76+ .clickable(interactionSource = null, indication = null) {},
77+ ) {
78+ val surface = nav.surface
79+ when {
80+ nav.surfacePicker || surface == null -> SurfacePicker(nav)
81+ surface == Surface.Subagents -> SubagentsPanel(app, nav, ref)
82+ else -> Column(Modifier.fillMaxSize()) {
83+ val (icon, title) = if (surface == Surface.Terminal) Lucide.SquareTerminal to "Terminal" else Lucide.Smartphone to "Device"
84+ PanelHeader(icon, title) { nav.surface = null }
85+ Box(Modifier.weight(1f).fillMaxWidth()) {
86+ if (surface == Surface.Terminal) TerminalPanel(app, nav, ref) else DevicePanel(app, nav, ref)
87+ }
88+ }
89+ }
90+ }
91+ }
92+ }
93+
94+ /** A panel's header on a phone: its icon and title, a tag, and the close button. */
95+ @Composable
96+ fun PanelHeader(icon: Lucide, title: String, tag: String? = null, onClose: () -> Unit) {
97+ val p = palette
98+ Row(
99+ Modifier.fillMaxWidth().height(48.dp).padding(start = 16.dp, end = 10.dp),
100+ horizontalArrangement = Arrangement.spacedBy(10.dp),
101+ verticalAlignment = Alignment.CenterVertically,
102+ ) {
103+ LucideIcon(icon, size = 16.dp, tint = p.icon)
104+ Txt(title, Modifier.weight(1f), size = 13.5.sp, weight = FontWeight.Medium, maxLines = 1)
105+ if (tag != null) Tag(tag)
106+ Cap(icon = Lucide.X, kind = CapKind.Ghost, size = CapSize.Compact, onClick = onClose)
107+ }
108+ }
109+
110+ /** "Open a surface": the surfaces to pick from, on a raised card. */
16111 @Composable
17- fun RightPanel(app: AppState, nav: Nav, ref: ThreadRef) {}
112+ private fun SurfacePicker(nav: Nav) {
113+ val p = palette
114+ Column(Modifier.fillMaxSize()) {
115+ Row(Modifier.fillMaxWidth().height(48.dp).padding(end = 10.dp), verticalAlignment = Alignment.CenterVertically) {
116+ Box(Modifier.weight(1f))
117+ Cap(icon = Lucide.X, kind = CapKind.Ghost, size = CapSize.Compact) { nav.surfacePicker = false }
118+ }
119+ Box(Modifier.weight(1f).fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) {
120+ Column(
121+ Modifier
122+ .widthIn(max = 360.dp)
123+ .fillMaxWidth()
124+ .raised(p, RoundedCornerShape(18.dp))
125+ .padding(horizontal = 16.dp, vertical = 20.dp),
126+ ) {
127+ Txt(
128+ "Open a surface",
129+ Modifier.fillMaxWidth().padding(bottom = 14.dp),
130+ size = 14.sp,
131+ weight = FontWeight.Medium,
132+ style = TextStyle(textAlign = TextAlign.Center),
133+ )
134+ Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
135+ for ((surface, icon, name) in SURFACES) {
136+ Row(
137+ Modifier
138+ .fillMaxWidth()
139+ .height(40.dp)
140+ .pressTint(8.dp) {
141+ nav.surface = surface
142+ nav.surfacePicker = false
143+ }
144+ .padding(horizontal = 10.dp),
145+ horizontalArrangement = Arrangement.spacedBy(10.dp),
146+ verticalAlignment = Alignment.CenterVertically,
147+ ) {
148+ LucideIcon(icon, size = 16.dp, tint = p.ink2)
149+ Txt(name, size = 13.5.sp, maxLines = 1)
150+ }
151+ }
152+ }
153+ }
154+ }
155+ }
156+ }
+344−0android/app/src/main/java/cc/vectrum/code/ui/panels/Subagents.ktaddedView File
@@ -0,0 +1,344 @@
1+ package cc.vectrum.code.ui.panels
2+
3+ import androidx.compose.foundation.layout.Arrangement
4+ import androidx.compose.foundation.layout.Box
5+ import androidx.compose.foundation.layout.Column
6+ import androidx.compose.foundation.layout.Row
7+ import androidx.compose.foundation.layout.fillMaxSize
8+ import androidx.compose.foundation.layout.fillMaxWidth
9+ import androidx.compose.foundation.layout.height
10+ import androidx.compose.foundation.layout.padding
11+ import androidx.compose.foundation.rememberScrollState
12+ import androidx.compose.foundation.verticalScroll
13+ import androidx.compose.runtime.Composable
14+ import androidx.compose.runtime.getValue
15+ import androidx.compose.ui.Alignment
16+ import androidx.compose.ui.Modifier
17+ import androidx.compose.ui.unit.dp
18+ import androidx.compose.ui.unit.sp
19+ import cc.vectrum.code.data.AppState
20+ import cc.vectrum.code.data.HostState
21+ import cc.vectrum.code.data.ThreadRef
22+ import cc.vectrum.code.net.SavedItem
23+ import cc.vectrum.code.net.ThreadAction
24+ import cc.vectrum.code.net.str
25+ import cc.vectrum.code.theme.Cap
26+ import cc.vectrum.code.theme.CapKind
27+ import cc.vectrum.code.theme.CapSize
28+ import cc.vectrum.code.theme.Engraved
29+ import cc.vectrum.code.theme.Led
30+ import cc.vectrum.code.theme.Mono
31+ import cc.vectrum.code.theme.Rule
32+ import cc.vectrum.code.theme.StatusLed
33+ import cc.vectrum.code.theme.Txt
34+ import cc.vectrum.code.theme.palette
35+ import cc.vectrum.code.ui.Lucide
36+ import cc.vectrum.code.ui.LucideIcon
37+ import cc.vectrum.code.ui.Nav
38+ import cc.vectrum.code.ui.sidebar.pressTint
39+ import cc.vectrum.code.ui.sidebar.rememberNow
40+ import cc.vectrum.code.util.formatDuration
41+ import kotlinx.serialization.json.JsonArray
42+ import kotlinx.serialization.json.JsonElement
43+ import kotlinx.serialization.json.JsonNull
44+ import kotlinx.serialization.json.JsonObject
45+ import kotlinx.serialization.json.jsonPrimitive
46+
47+ // The subagents panel (src/ui/workspace/subagents.rs): every subagent the
48+ // thread's agent started, the working ones first with what each is doing
49+ // right now (its latest tool call, its progress summary, how long it's run,
50+ // its tool uses and tokens), then the finished ones. A task delegated to
51+ // the app runs in a thread of its own, which a tap opens.
52+
53+ private enum class TaskStatus { Running, Completed, Failed, Stopped }
54+
55+ private fun taskStatus(status: String) = when (status) {
56+ "completed" -> TaskStatus.Completed
57+ "failed" -> TaskStatus.Failed
58+ "running", "pending" -> TaskStatus.Running
59+ else -> TaskStatus.Stopped
60+ }
61+
62+ /** How the Stop button stops a subagent, where the host can. */
63+ private sealed interface Stop {
64+ /** Claude's background-task control, by task id. */
65+ class Task(val taskId: String) : Stop
66+
67+ /** Pi runs its subagents inside one tool call: stopping one stops the turn. */
68+ data object Turn : Stop
69+
70+ /** A task delegated to the app: its thread, by id. */
71+ class Thread(val id: String) : Stop
72+ }
73+
74+ /** A subagent as the panel lists it. */
75+ private class Subagent(
76+ val id: String,
77+ val description: String,
78+ /** The kind of agent, e.g. `Explore`. */
79+ val kind: String?,
80+ val status: TaskStatus,
81+ val background: Boolean,
82+ val toolUses: Int,
83+ val tokens: Long?,
84+ val started: Long,
85+ val finished: Long?,
86+ /** Its latest tool call while it works: `[tool name, input]`. */
87+ val tool: JsonArray?,
88+ val summary: String?,
89+ /** The thread a task delegated to the app runs in, by id. */
90+ val thread: String?,
91+ val stop: Stop?,
92+ )
93+
94+ /** The app's own tools, under whatever prefix the agent gives them. */
95+ private fun bare(name: String) = name.removePrefix("mcp__vectrum-code__").removePrefix("mcp__vectrum_code__")
96+
97+ private fun field(input: JsonElement, vararg keys: String): String? = keys.firstNotNullOfOrNull { input.str(it) }
98+
99+ /** A thread's subagents, in the order they started. */
100+ private fun subagentsOf(items: List<SavedItem>, tasks: List<cc.vectrum.code.net.RemoteTask>): List<Subagent> {
101+ val subagents = mutableListOf<Subagent>()
102+ items.forEachIndexed { ix, item ->
103+ val tool = item as? SavedItem.Tool ?: return@forEachIndexed
104+ val progress = tool.subagent ?: return@forEachIndexed
105+ val callRunning = tool.state == "running"
106+ val delegation = bare(tool.name) == "delegate_task"
107+ val swarm = tool.name == "swarm"
108+ if (progress.members.isNotEmpty()) {
109+ progress.members.forEachIndexed { member, entry ->
110+ var status = taskStatus(entry.status)
111+ // A Pi subagent runs inside its call; a swarm agent or a delegated task outlives it.
112+ if (status == TaskStatus.Running && !callRunning && !(swarm || delegation)) status = TaskStatus.Stopped
113+ val running = status == TaskStatus.Running
114+ subagents.add(
115+ Subagent(
116+ id = "$ix/$member",
117+ description = entry.label,
118+ kind = entry.kind,
119+ status = status,
120+ background = false,
121+ toolUses = entry.toolUses,
122+ tokens = entry.tokens,
123+ started = entry.started,
124+ finished = entry.finished,
125+ tool = entry.tool.takeIf { running },
126+ summary = entry.summary,
127+ thread = entry.id.takeIf { delegation },
128+ // A swarm agent stops from the host's own jcode, which a client can't reach.
129+ stop = when {
130+ !running -> null
131+ delegation -> Stop.Thread(entry.id)
132+ swarm -> null
133+ else -> Stop.Turn
134+ },
135+ ),
136+ )
137+ }
138+ return@forEachIndexed
139+ }
140+ // Only a swarm call that spawns an agent starts one.
141+ if (swarm && tool.input.str("action") != "spawn") return@forEachIndexed
142+ val background = tool.background
143+ val status = when {
144+ background != null && (tool.state == "running" || tool.state == "done") -> taskStatus(background)
145+ tool.state == "error" -> TaskStatus.Failed
146+ tool.state == "running" -> TaskStatus.Running
147+ else -> TaskStatus.Completed
148+ }
149+ val running = status == TaskStatus.Running
150+ val several = (tool.input as? JsonObject)?.let { it["tasks"] ?: it["chain"] } as? JsonArray
151+ val label = field(tool.input, "description", "label", "title", "task")?.takeIf { it.isNotBlank() }
152+ ?: several?.let { "${it.size} subagents" }
153+ ?: "Subagent"
154+ // The host doesn't send the call's task id; its background task, by the same description, has it.
155+ val task = tasks.singleOrNull { it.description == label }?.taskId
156+ subagents.add(
157+ Subagent(
158+ id = "$ix",
159+ description = label,
160+ kind = field(tool.input, "subagent_type", "agent"),
161+ status = status,
162+ background = background != null,
163+ toolUses = tool.subagentToolCount,
164+ tokens = progress.tokens,
165+ started = progress.started,
166+ finished = progress.finished,
167+ tool = progress.tool.takeIf { running },
168+ summary = progress.summary,
169+ thread = null,
170+ stop = task?.takeIf { running }?.let { Stop.Task(it) },
171+ ),
172+ )
173+ }
174+ return subagents
175+ }
176+
177+ /** How long it ran, or has run while it still does. */
178+ private fun elapsed(started: Long, finished: Long?, running: Boolean, now: Long): Long? {
179+ val end = finished ?: if (running) now else return null
180+ return if (started > 0) (end - started).coerceAtLeast(0) else null
181+ }
182+
183+ /** `812 tokens`, `23.4k tokens`, `1.2M tokens`. */
184+ fun formatTokens(tokens: Long): String = when {
185+ tokens < 1_000 -> "$tokens tokens"
186+ tokens < 1_000_000 -> String.format(java.util.Locale.ROOT, "%.1fk tokens", tokens / 1_000.0).replace(".0k", "k")
187+ else -> String.format(java.util.Locale.ROOT, "%.1fM tokens", tokens / 1_000_000.0).replace(".0M", "M")
188+ }
189+
190+ /** The subagents panel for a thread. */
191+ @Composable
192+ fun SubagentsPanel(app: AppState, nav: Nav, ref: ThreadRef) {
193+ val p = palette
194+ val host = app.host(ref.host)
195+ val thread = host?.open?.get(ref.thread)
196+ val all = if (thread != null) subagentsOf(thread.items, thread.detail.backgroundTasks) else emptyList()
197+ val working = all.filter { it.status == TaskStatus.Running }
198+ val finished = all.filter { it.status != TaskStatus.Running }.asReversed()
199+ val now by rememberNow(1_000)
200+ val cwd = thread?.detail?.cwd.orEmpty()
201+ Column(Modifier.fillMaxSize()) {
202+ PanelHeader(Lucide.Bot, "Subagents", tag = if (working.isNotEmpty()) "${working.size} working" else null) {
203+ nav.surface = null
204+ }
205+ Box(Modifier.padding(horizontal = 14.dp, vertical = 4.dp)) { Rule() }
206+ Column(
207+ Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = 6.dp, vertical = 4.dp),
208+ verticalArrangement = Arrangement.spacedBy(2.dp),
209+ ) {
210+ if (working.isEmpty() && finished.isEmpty()) {
211+ Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
212+ Txt("No subagents in this thread", color = p.ink2, size = 13.sp)
213+ Txt(
214+ "When the agent hands work to a subagent, it shows up here with what it's doing.",
215+ color = p.ink3,
216+ size = 12.sp,
217+ lineHeight = 17.sp,
218+ )
219+ }
220+ }
221+ if (working.isNotEmpty()) {
222+ Engraved("Working", Modifier.padding(start = 10.dp, end = 10.dp, top = 8.dp, bottom = 4.dp))
223+ for (subagent in working) SubagentRow(nav, host!!, ref, subagent, cwd, now)
224+ }
225+ if (finished.isNotEmpty()) {
226+ Engraved("Finished · ${finished.size}", Modifier.padding(start = 10.dp, end = 10.dp, top = 8.dp, bottom = 4.dp))
227+ for (subagent in finished) SubagentRow(nav, host!!, ref, subagent, cwd, now)
228+ }
229+ }
230+ }
231+ }
232+
233+ /**
234+ * One subagent: its light, what it was asked to do and for how long, its
235+ * kind and totals, and while it works, what it's doing now.
236+ */
237+ @Composable
238+ private fun SubagentRow(nav: Nav, host: HostState, ref: ThreadRef, subagent: Subagent, cwd: String, now: Long) {
239+ val p = palette
240+ val running = subagent.status == TaskStatus.Running
241+ val led = when (subagent.status) {
242+ TaskStatus.Running -> Led.Live
243+ TaskStatus.Failed -> Led.Failed
244+ else -> Led.Off
245+ }
246+ // A delegated task's agent can stop to ask the user something.
247+ val child = subagent.thread?.let { host.threads[it] }
248+ val waiting = running && child?.attention == true
249+ val status = when {
250+ waiting -> "Waiting for you"
251+ subagent.status == TaskStatus.Failed -> "Failed"
252+ subagent.status == TaskStatus.Stopped -> "Stopped"
253+ else -> null
254+ }
255+ val meta = listOfNotNull(
256+ subagent.kind?.takeIf { it.isNotEmpty() && it != "general-purpose" },
257+ subagent.toolUses.takeIf { it > 0 }?.let { "$it ${if (it == 1) "tool" else "tools"}" },
258+ subagent.tokens?.takeIf { it > 0 }?.let { formatTokens(it) },
259+ "background".takeIf { subagent.background },
260+ )
261+ val elapsed = elapsed(subagent.started, subagent.finished, running, now)
262+ // A tap opens the thread a delegated task runs in.
263+ val opens = child != null && child.parent == ref.thread
264+ Row(
265+ Modifier
266+ .fillMaxWidth()
267+ .then(if (opens) Modifier.pressTint(12.dp) { nav.openThread(ThreadRef(ref.host, child.id)) } else Modifier)
268+ .padding(horizontal = 10.dp, vertical = 7.dp),
269+ horizontalArrangement = Arrangement.spacedBy(10.dp),
270+ ) {
271+ Box(Modifier.height(18.dp), contentAlignment = Alignment.Center) { StatusLed(led, pulse = running) }
272+ Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
273+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
274+ Txt(subagent.description, Modifier.weight(1f), color = if (running) p.ink else p.ink2, size = 13.sp, maxLines = 1)
275+ if (status != null) {
276+ Txt(status, color = if (subagent.status == TaskStatus.Failed) p.red else p.ink3, size = 11.5.sp, maxLines = 1)
277+ }
278+ if (elapsed != null) Txt(formatDuration(elapsed), color = p.ink3, size = 11.sp, family = Mono, maxLines = 1)
279+ subagent.stop?.let { stop ->
280+ Cap(icon = Lucide.Square, kind = CapKind.Ghost, size = CapSize.Mini) {
281+ when (stop) {
282+ is Stop.Task -> host.act(ref.thread, ThreadAction.StopTask(stop.taskId))
283+ Stop.Turn -> host.act(ref.thread, ThreadAction.Interrupt)
284+ is Stop.Thread -> host.act(stop.id, ThreadAction.Interrupt)
285+ }
286+ }
287+ }
288+ }
289+ subagent.tool?.let { call ->
290+ val name = runCatching { call[0].jsonPrimitive.content }.getOrNull()
291+ if (name != null) {
292+ val (icon, label) = describeCall(name, call.getOrNull(1) ?: JsonNull, cwd)
293+ Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
294+ LucideIcon(icon, size = 13.dp, tint = p.ink3)
295+ Txt(label, Modifier.weight(1f), color = p.ink2, size = 12.sp, maxLines = 1)
296+ }
297+ }
298+ }
299+ if (running) subagent.summary?.let { Txt(it, color = p.ink3, size = 12.sp, maxLines = 2, lineHeight = 16.sp) }
300+ if (meta.isNotEmpty()) Txt(meta.joinToString(" · "), color = p.ink3, size = 10.5.sp, family = Mono, maxLines = 1)
301+ }
302+ }
303+ }
304+
305+ /** A path relative to the thread's folder. */
306+ private fun relativePath(path: String, cwd: String): String =
307+ if (cwd.isNotEmpty() && path.startsWith("$cwd/")) path.removePrefix("$cwd/") else path
308+
309+ /** A tool call's icon and label as the timeline shows them, for a call known only by its name and input. */
310+ private fun describeCall(name: String, input: JsonElement, cwd: String): Pair<Lucide, String> {
311+ fun firstLine(text: String) = text.lineSequence().firstOrNull().orEmpty().take(200)
312+ when (bare(name)) {
313+ "delegate_task" -> return Lucide.Bot to (field(input, "title") ?: field(input, "task"))?.let(::firstLine).orEmpty().ifEmpty { "Subagent" }
314+ "task_cancel" -> return Lucide.Bot to "Stopped a subagent"
315+ "orchestrator_capabilities" -> return Lucide.Bot to "Looked up agents and models"
316+ "task_status" -> return Lucide.Bot to "Checked on a subagent"
317+ "device_list" -> return Lucide.Smartphone to "Listed devices"
318+ "device_open" -> return Lucide.Smartphone to "Opened a device"
319+ "device_close" -> return Lucide.Smartphone to "Closed devices"
320+ "device_screenshot" -> return Lucide.Smartphone to "Took a device screenshot"
321+ }
322+ val path = { keys: Array<String> -> field(input, *keys)?.let { relativePath(it, cwd) } }
323+ return when (name.lowercase()) {
324+ "bash" -> Lucide.Terminal to (field(input, "command")?.let(::firstLine) ?: "Ran command")
325+ "bashoutput", "killshell" -> Lucide.Terminal to name
326+ "read", "notebookread" -> Lucide.FileText to (path(arrayOf("file_path", "path", "notebook_path"))?.let { "Read $it" } ?: "Read file")
327+ "write", "edit", "multiedit", "notebookedit" ->
328+ Lucide.SquarePen to (path(arrayOf("file_path", "path", "notebook_path")) ?: "Changed a file")
329+ "grep", "glob", "find" -> Lucide.Search to (field(input, "pattern", "query")?.let { "Searched for ${firstLine(it)}" } ?: "Searched code")
330+ "ls" -> Lucide.Search to (path(arrayOf("path"))?.let { "Listed $it" } ?: "Listed files")
331+ "websearch" -> Lucide.Globe to (field(input, "query")?.let { "Web search: $it" } ?: "Web search")
332+ "webfetch" -> Lucide.Globe to (field(input, "url") ?: "Fetched a page")
333+ "task", "agent" -> Lucide.Bot to (field(input, "description") ?: "Subagent")
334+ "subagent" -> Lucide.Bot to (field(input, "description", "task")?.let(::firstLine) ?: "Subagent")
335+ "swarm" -> Lucide.Bot to "Swarm"
336+ "monitor" -> Lucide.Eye to (field(input, "description", "command")?.let(::firstLine) ?: "Monitor")
337+ else -> if (name.startsWith("mcp__")) {
338+ val parts = name.removePrefix("mcp__").split("__", limit = 2)
339+ Lucide.Hammer to "${parts[0]}: ${parts.getOrElse(1) { "" }.replace('_', ' ')}"
340+ } else {
341+ Lucide.Wrench to name
342+ }
343+ }
344+ }
+221−0android/app/src/main/java/cc/vectrum/code/ui/sidebar/Inbox.ktaddedView File
@@ -0,0 +1,221 @@
1+ package cc.vectrum.code.ui.sidebar
2+
3+ import androidx.compose.runtime.Composable
4+ import androidx.compose.runtime.LaunchedEffect
5+ import androidx.compose.runtime.State
6+ import androidx.compose.runtime.mutableStateMapOf
7+ import androidx.compose.runtime.produceState
8+ import androidx.compose.runtime.snapshotFlow
9+ import cc.vectrum.code.data.AppState
10+ import cc.vectrum.code.data.HostState
11+ import cc.vectrum.code.data.OpenThread
12+ import cc.vectrum.code.data.ThreadRef
13+ import cc.vectrum.code.net.SavedItem
14+ import cc.vectrum.code.net.ThreadDetail
15+ import cc.vectrum.code.net.ThreadSummary
16+ import cc.vectrum.code.ui.Nav
17+ import cc.vectrum.code.ui.Screen
18+ import cc.vectrum.code.util.initials
19+ import cc.vectrum.code.util.tintFor
20+ import kotlinx.coroutines.delay
21+
22+ // The desktop's inbox (src/ui/workspace/sidebar.rs): threads from every
23+ // paired host in T3 Code's sections, each row's status, and the bits of
24+ // state the desktop keeps per thread that the protocol doesn't carry
25+ // (unread, how long a turn has run), tracked here as the desktop does.
26+
27+ /** A project anywhere: the host it's on and its path there. */
28+ data class ProjectKey(val host: String, val path: String)
29+
30+ /** A project's name as the desktop shows it: the host's name for it, or its folder's. */
31+ fun projectName(host: HostState, path: String): String =
32+ host.projects.find { it.path == path }?.name?.ifBlank { null }
33+ ?: path.trimEnd('/').substringAfterLast('/').ifBlank { path }
34+
35+ /** A path with the home folder as `~`, as the desktop shows paths. */
36+ fun displayPath(path: String): String {
37+ val home = Regex("^(/home/[^/]+|/Users/[^/]+|/root)(?=/|$)").find(path) ?: return path
38+ return "~" + path.substring(home.range.last + 1)
39+ }
40+
41+ /** A thread in the sidebar: its host and summary, with what the desktop derives from them. */
42+ class Entry(val host: HostState, val summary: ThreadSummary) {
43+ val id: String get() = summary.id
44+ val ref: ThreadRef get() = ThreadRef(host.saved.id, summary.id)
45+ val key: String get() = "${host.saved.id}/${summary.id}"
46+ val project: ProjectKey get() = ProjectKey(host.saved.id, summary.project)
47+ val projectName: String = projectName(host, summary.project)
48+ val initials: String get() = initials(projectName)
49+ val tint: Int get() = tintFor(projectName)
50+
51+ /** The thread's transcript and detail, while it's open and loaded. */
52+ val open: OpenThread? get() = host.open[summary.id]?.takeIf { it.loaded }
53+ val detail: ThreadDetail? get() = open?.detail
54+
55+ /** Its worktree's branch, or its project checkout's. */
56+ val branch: String? get() = summary.branch ?: host.projects.find { it.path == summary.project }?.branch
57+ val inWorktree: Boolean get() = summary.branch != null
58+
59+ /** The folder it works in, when known: its worktree (once loaded), or its project. */
60+ val path: String? get() = if (summary.branch == null) summary.project else detail?.cwd?.ifBlank { null }
61+
62+ fun snoozed(now: Long): Boolean = (summary.snoozedUntil ?: 0) > now
63+
64+ val working: Boolean get() = summary.status == "working"
65+
66+ /** Whether it can be snoozed now: not while it waits on the user. */
67+ val canSnooze: Boolean get() = !summary.attention && detail?.preparing != true
68+ }
69+
70+ /** What a row's status slot says, in T3's order of precedence. */
71+ enum class TopStatus { Approval, Input, Working, Waiting, Limited, Failed, Done }
72+
73+ /** The status a thread's row shows, if any. What only an open thread's transcript tells shows only for open ones. */
74+ fun topStatus(entry: Entry, unread: Boolean, now: Long): TopStatus? {
75+ val summary = entry.summary
76+ val open = entry.open
77+ val items = open?.items
78+ val approval = items?.any {
79+ it is SavedItem.Tool && it.permission == "pending" && it.name != "AskUserQuestion"
80+ } == true
81+ if (approval) return TopStatus.Approval
82+ if (summary.attention) return TopStatus.Input
83+ val detail = open?.detail
84+ if (summary.status == "working" || detail?.preparing == true) return TopStatus.Working
85+ if (summary.status == "idle" && detail?.backgroundTasks?.isNotEmpty() == true) return TopStatus.Waiting
86+ if ((detail?.limitResets ?: 0) > now) return TopStatus.Limited
87+ val failed = summary.status == "exited" ||
88+ items?.asReversed()?.takeWhile { it !is SavedItem.User }?.any { it is SavedItem.Notice && it.error } == true
89+ if (failed && summary.hasItems) return TopStatus.Failed
90+ if (unread) return TopStatus.Done
91+ return null
92+ }
93+
94+ /** The threads the inbox shows, in its sections. */
95+ class InboxLists(
96+ val pinned: List<Entry>,
97+ val active: List<Entry>,
98+ val working: List<Entry>,
99+ val snoozed: List<Entry>,
100+ val settled: List<Entry>,
101+ ) {
102+ val isEmpty: Boolean
103+ get() = pinned.isEmpty() && active.isEmpty() && working.isEmpty() && snoozed.isEmpty() && settled.isEmpty()
104+
105+ /** Every thread, in shelf order: what search goes through. */
106+ val ordered: List<Entry> get() = pinned + active + working + snoozed + settled
107+ }
108+
109+ /** Every thread on every host the sidebar lists: no subagents' threads, none without a message yet. */
110+ fun allEntries(app: AppState): List<Entry> = app.hosts.flatMap { host ->
111+ host.threads.values.filter { it.parent == null && it.hasItems }.map { Entry(host, it) }
112+ }
113+
114+ /** The inbox's threads in their sections, within the project filter. */
115+ fun inbox(app: AppState, scope: ProjectKey?, workingShelf: Boolean, now: Long): InboxLists {
116+ val pinned = mutableListOf<Entry>()
117+ val active = mutableListOf<Entry>()
118+ val working = mutableListOf<Entry>()
119+ val snoozed = mutableListOf<Entry>()
120+ val settled = mutableListOf<Entry>()
121+ for (entry in allEntries(app)) {
122+ if (scope != null && entry.project != scope) continue
123+ val summary = entry.summary
124+ when {
125+ summary.settled -> settled
126+ entry.snoozed(now) -> snoozed
127+ summary.pinned -> pinned
128+ workingShelf && entry.working && !summary.attention -> working
129+ else -> active
130+ }.add(entry)
131+ }
132+ val newest = compareByDescending<Entry> { it.summary.updated }
133+ return InboxLists(
134+ pinned.sortedWith(newest),
135+ active.sortedWith(newest),
136+ working.sortedWith(newest),
137+ snoozed.sortedBy { it.summary.snoozedUntil ?: 0 },
138+ settled.sortedWith(newest),
139+ )
140+ }
141+
142+ /**
143+ * What the desktop keeps per thread and the host doesn't send: whether a
144+ * thread finished while it wasn't open (unread, so it shows Done), and when
145+ * its running turn started.
146+ */
147+ object Activity {
148+ val unread = mutableStateMapOf<String, Boolean>()
149+ val workingSince = mutableStateMapOf<String, Long>()
150+ private val wasWorking = HashMap<String, Boolean>()
151+ private val seenDetail = HashMap<String, ThreadDetail>()
152+
153+ fun isUnread(entry: Entry): Boolean = unread[entry.key] == true
154+
155+ fun markUnread(entry: Entry) {
156+ unread[entry.key] = true
157+ }
158+
159+ private class Seen(val key: String, val working: Boolean, val detail: ThreadDetail?)
160+
161+ /** Follows every host's threads while the app runs. */
162+ @Composable
163+ fun Track(app: AppState, nav: Nav) {
164+ LaunchedEffect(app, nav) {
165+ snapshotFlow {
166+ val screen = nav.screen
167+ val openKey = (screen as? Screen.Thread)?.ref?.let { "${it.host}/${it.thread}" }
168+ openKey to app.hosts.flatMap { host ->
169+ host.threads.values.map { summary ->
170+ val open = host.open[summary.id]?.takeIf { it.loaded }
171+ Seen("${host.saved.id}/${summary.id}", summary.status == "working" || open?.detail?.preparing == true, open?.detail)
172+ }
173+ }
174+ }.collect { (openKey, threads) -> update(openKey, threads) }
175+ }
176+ }
177+
178+ private fun update(openKey: String?, threads: List<Seen>) {
179+ val now = System.currentTimeMillis()
180+ for (thread in threads) {
181+ val before = wasWorking[thread.key]
182+ val detail = thread.detail
183+ if (detail != null && seenDetail[thread.key] !== detail) {
184+ seenDetail[thread.key] = detail
185+ detail.turnElapsedMs?.let { workingSince[thread.key] = now - it }
186+ }
187+ if (thread.working && before == false && thread.key !in workingSince) workingSince[thread.key] = now
188+ if (!thread.working) workingSince.remove(thread.key)
189+ if (before == true && !thread.working && thread.key != openKey) unread[thread.key] = true
190+ wasWorking[thread.key] = thread.working
191+ }
192+ if (openKey != null) unread.remove(openKey)
193+ }
194+ }
195+
196+ /** How long the running turn has worked: `12s`, `4m`, `1h 5m`. */
197+ fun workingFor(since: Long, now: Long): String {
198+ val secs = ((now - since) / 1_000).coerceAtLeast(0)
199+ return when {
200+ secs < 60 -> "${secs}s"
201+ secs < 3_600 -> "${secs / 60}m"
202+ else -> "${secs / 3_600}h ${secs % 3_600 / 60}m"
203+ }
204+ }
205+
206+ /** The time now, ticking every [period] milliseconds. */
207+ @Composable
208+ fun rememberNow(period: Long): State<Long> = produceState(System.currentTimeMillis(), period) {
209+ while (true) {
210+ value = System.currentTimeMillis()
211+ delay(period - System.currentTimeMillis() % period)
212+ }
213+ }
214+
215+ /** Shortens a long branch name in the middle: `feature/…/sidebar`. */
216+ fun middleTruncate(text: String, max: Int): String {
217+ if (text.length <= max) return text
218+ val head = (max - 1) / 2
219+ val tail = max - 1 - head
220+ return text.take(head) + "…" + text.takeLast(tail)
221+ }
+491−4android/app/src/main/java/cc/vectrum/code/ui/sidebar/Sidebar.ktView File
@@ -1,19 +1,506 @@
11 package cc.vectrum.code.ui.sidebar
22
3+ import androidx.compose.foundation.Image
4+ import androidx.compose.foundation.clickable
5+ import androidx.compose.foundation.horizontalScroll
6+ import androidx.compose.foundation.layout.Arrangement
37 import androidx.compose.foundation.layout.Box
8+ import androidx.compose.foundation.layout.BoxWithConstraints
9+ import androidx.compose.foundation.layout.Column
10+ import androidx.compose.foundation.layout.ColumnScope
11+ import androidx.compose.foundation.layout.Row
12+ import androidx.compose.foundation.layout.Spacer
13+ import androidx.compose.foundation.layout.fillMaxSize
14+ import androidx.compose.foundation.layout.fillMaxWidth
15+ import androidx.compose.foundation.layout.height
16+ import androidx.compose.foundation.layout.heightIn
417 import androidx.compose.foundation.layout.padding
18+ import androidx.compose.foundation.layout.size
19+ import androidx.compose.foundation.rememberScrollState
20+ import androidx.compose.foundation.shape.CircleShape
21+ import androidx.compose.foundation.shape.RoundedCornerShape
22+ import androidx.compose.foundation.text.BasicTextField
23+ import androidx.compose.foundation.text.KeyboardActions
24+ import androidx.compose.foundation.text.KeyboardOptions
25+ import androidx.compose.foundation.verticalScroll
526 import androidx.compose.runtime.Composable
27+ import androidx.compose.runtime.getValue
28+ import androidx.compose.runtime.mutableIntStateOf
29+ import androidx.compose.runtime.mutableStateOf
30+ import androidx.compose.runtime.remember
31+ import androidx.compose.runtime.setValue
32+ import androidx.compose.ui.Alignment
633 import androidx.compose.ui.Modifier
34+ import androidx.compose.ui.focus.FocusRequester
35+ import androidx.compose.ui.focus.focusRequester
36+ import androidx.compose.ui.graphics.ColorFilter
37+ import androidx.compose.ui.graphics.SolidColor
38+ import androidx.compose.ui.platform.LocalConfiguration
39+ import androidx.compose.ui.platform.LocalFocusManager
40+ import androidx.compose.ui.res.painterResource
41+ import androidx.compose.ui.text.TextStyle
42+ import androidx.compose.ui.text.font.FontWeight
43+ import androidx.compose.ui.text.input.ImeAction
44+ import androidx.compose.ui.text.style.TextAlign
745 import androidx.compose.ui.unit.dp
46+ import androidx.compose.ui.unit.sp
47+ import cc.vectrum.code.R
848 import cc.vectrum.code.data.AppState
9- import cc.vectrum.code.data.OpenThread
49+ import cc.vectrum.code.data.HostState
1050 import cc.vectrum.code.data.ThreadRef
11- import cc.vectrum.code.theme.Engraved
51+ import cc.vectrum.code.net.ConnectionStatus
52+ import cc.vectrum.code.theme.Badge
53+ import cc.vectrum.code.theme.Cap
54+ import cc.vectrum.code.theme.CapKind
55+ import cc.vectrum.code.theme.CapSize
56+ import cc.vectrum.code.theme.Led
57+ import cc.vectrum.code.theme.Sans
58+ import cc.vectrum.code.theme.StatusLed
59+ import cc.vectrum.code.theme.Tag
60+ import cc.vectrum.code.theme.Txt
61+ import cc.vectrum.code.theme.metal
62+ import cc.vectrum.code.theme.palette
63+ import cc.vectrum.code.theme.well
64+ import cc.vectrum.code.ui.BottomSheet
65+ import cc.vectrum.code.ui.ConfirmDialog
66+ import cc.vectrum.code.ui.Lucide
67+ import cc.vectrum.code.ui.LucideIcon
68+ import cc.vectrum.code.ui.MenuItem
1269 import cc.vectrum.code.ui.Nav
1370 import cc.vectrum.code.ui.Screen
71+ import cc.vectrum.code.ui.pair.AddProjectSheet
72+ import cc.vectrum.code.util.initials
73+ import cc.vectrum.code.util.tintFor
1474
15- // Placeholder until it's built.
75+ // The thread sidebar (src/ui/workspace/sidebar.rs), laid out like T3 Code's
76+ // default inbox: the brand row, search with the project filter, Add project
77+ // and New thread, then pinned and active cards and the Working, Snoozed and
78+ // Settled shelves, with Settings and the hosts at the foot. Threads come
79+ // from every paired host.
80+
81+ /** How many settled threads show at first, and how many more each "Show more" adds. */
82+ private const val SETTLED_PAGE = 10
83+ private const val SETTLED_MORE = 25
84+
85+ /** The sidebar: on a phone, the drawer; on a tablet, beside the sheet. */
1686 @Composable
1787 fun Sidebar(app: AppState, nav: Nav, modifier: Modifier = Modifier) {
18- Box(modifier.padding(16.dp)) { Engraved("Sidebar") }
88+ Activity.Track(app, nav)
89+ var query by remember { mutableStateOf("") }
90+ var scope by remember { mutableStateOf<ProjectKey?>(null) }
91+ var scopePicker by remember { mutableStateOf(false) }
92+ var addingProject by remember { mutableStateOf(false) }
93+ var menuFor by remember { mutableStateOf<ThreadRef?>(null) }
94+ var unpinning by remember { mutableStateOf<Entry?>(null) }
95+ // A filter on a project that's gone shows everything again.
96+ val validScope = scope?.takeIf { key -> app.host(key.host)?.projects?.any { it.path == key.path } == true }
97+ val hasProjects = app.hosts.any { it.projects.isNotEmpty() }
98+
99+ Column(modifier) {
100+ Chrome(nav, onBrand = { scope = null })
101+ Header(
102+ app = app,
103+ nav = nav,
104+ query = query,
105+ onQuery = { query = it },
106+ scope = validScope,
107+ pickerOpen = scopePicker,
108+ hasProjects = hasProjects,
109+ onScopePicker = { scopePicker = true },
110+ onAddProject = { addingProject = true },
111+ onOpenFirst = {
112+ val first = searchHits(app, validScope, query).firstOrNull()
113+ if (first != null) nav.openThread(first.ref)
114+ },
115+ )
116+ val onMenu = { entry: Entry -> menuFor = entry.ref }
117+ if (query.isNotBlank()) {
118+ SearchResults(app, nav, validScope, query, Modifier.weight(1f), onMenu)
119+ } else {
120+ InboxList(
121+ app,
122+ nav,
123+ validScope,
124+ hasProjects,
125+ Modifier.weight(1f),
126+ onMenu,
127+ onUnpin = { entry -> if (app.prefs.bool("confirm_unpin")) unpinning = entry else unpin(app, entry) },
128+ onAddProject = { addingProject = true },
129+ )
130+ }
131+ Footer(app, nav)
132+ }
133+
134+ unpinning?.let { entry ->
135+ ConfirmDialog(
136+ title = "Unpin thread \"${entry.summary.title}\"?",
137+ message = "This will move the thread out of your pinned section.",
138+ confirm = "Confirm",
139+ onDismiss = { unpinning = null },
140+ ) { unpin(app, entry) }
141+ }
142+
143+ ThreadMenu(app, nav, menuFor, onDismiss = { menuFor = null }, scope = validScope, onScope = { scope = it })
144+ ScopePicker(app, scopePicker, validScope, onDismiss = { scopePicker = false }) {
145+ scope = it
146+ scopePicker = false
147+ }
148+ val addHost = validScope?.let { app.host(it.host) } ?: app.hosts.singleOrNull()
149+ AddProjectSheet(app, addHost, addingProject) { addingProject = false }
150+ }
151+
152+ /** The brand row: the drawer's close button on a phone, the mark, "Vectrum" and `CODE`, and the command palette. */
153+ @Composable
154+ private fun Chrome(nav: Nav, onBrand: () -> Unit) {
155+ val p = palette
156+ val phone = LocalConfiguration.current.screenWidthDp < 840
157+ Row(
158+ Modifier.fillMaxWidth().height(52.dp).padding(start = 10.dp, end = 12.dp),
159+ horizontalArrangement = Arrangement.spacedBy(8.dp),
160+ verticalAlignment = Alignment.CenterVertically,
161+ ) {
162+ if (phone) {
163+ Cap(icon = Lucide.PanelLeftClose, kind = CapKind.Ghost, size = CapSize.Compact) { nav.sidebarOpen = false }
164+ }
165+ Row(
166+ Modifier.height(28.dp).clickable(interactionSource = null, indication = null, onClick = onBrand),
167+ horizontalArrangement = Arrangement.spacedBy(8.dp),
168+ verticalAlignment = Alignment.CenterVertically,
169+ ) {
170+ Image(
171+ painterResource(R.drawable.vectrum_logo),
172+ contentDescription = "Vectrum",
173+ modifier = Modifier.size(18.dp),
174+ colorFilter = ColorFilter.tint(p.ink),
175+ )
176+ Txt("Vectrum", size = 15.sp, weight = FontWeight.SemiBold, maxLines = 1)
177+ Tag("Code")
178+ }
179+ Spacer(Modifier.weight(1f))
180+ // The desktop's Ctrl+K.
181+ Cap(icon = Lucide.Search, kind = CapKind.Ghost, size = CapSize.Compact, iconColor = p.ink3) { nav.palette = true }
182+ }
183+ }
184+
185+ /** Search, then the project filter, Add project and New thread. */
186+ @Composable
187+ private fun Header(
188+ app: AppState,
189+ nav: Nav,
190+ query: String,
191+ onQuery: (String) -> Unit,
192+ scope: ProjectKey?,
193+ pickerOpen: Boolean,
194+ hasProjects: Boolean,
195+ onScopePicker: () -> Unit,
196+ onAddProject: () -> Unit,
197+ onOpenFirst: () -> Unit,
198+ ) {
199+ val p = palette
200+ val focus = remember { FocusRequester() }
201+ val focusManager = LocalFocusManager.current
202+ Row(
203+ Modifier.fillMaxWidth().padding(start = 8.dp, end = 8.dp, bottom = 8.dp),
204+ horizontalArrangement = Arrangement.spacedBy(4.dp),
205+ verticalAlignment = Alignment.CenterVertically,
206+ ) {
207+ Row(
208+ Modifier
209+ .weight(1f)
210+ .height(32.dp)
211+ .well(p, RoundedCornerShape(12.dp))
212+ .clickable(interactionSource = null, indication = null) { focus.requestFocus() }
213+ .padding(start = 10.dp, end = 6.dp),
214+ horizontalArrangement = Arrangement.spacedBy(8.dp),
215+ verticalAlignment = Alignment.CenterVertically,
216+ ) {
217+ LucideIcon(Lucide.Search, size = 14.dp, tint = p.fieldInk)
218+ Box(Modifier.weight(1f), contentAlignment = Alignment.CenterStart) {
219+ if (query.isEmpty()) Txt("Search", color = p.fieldInk, size = 13.sp, maxLines = 1)
220+ BasicTextField(
221+ value = query,
222+ onValueChange = onQuery,
223+ singleLine = true,
224+ textStyle = TextStyle(color = p.ink, fontSize = 13.sp, fontFamily = Sans),
225+ cursorBrush = SolidColor(p.ink),
226+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Go),
227+ keyboardActions = KeyboardActions(onGo = {
228+ focusManager.clearFocus()
229+ onOpenFirst()
230+ }),
231+ modifier = Modifier.fillMaxWidth().focusRequester(focus),
232+ )
233+ }
234+ if (query.isNotEmpty()) {
235+ Cap(Modifier.size(18.dp), icon = Lucide.X, kind = CapKind.Ghost, size = CapSize.Mini) { onQuery("") }
236+ }
237+ }
238+ if (hasProjects) {
239+ val scoped = scope?.let { key -> app.host(key.host)?.let { projectName(it, key.path) } }
240+ if (scoped != null) {
241+ // The scoped project's badge stands in for the folder.
242+ Box(
243+ Modifier
244+ .size(26.dp)
245+ .then(if (pickerOpen) Modifier.metal(CircleShape, p.pressed.brush, p.pressedSh) else Modifier)
246+ .pressTint(13.dp, onScopePicker),
247+ contentAlignment = Alignment.Center,
248+ ) { Badge(initials(scoped), tintFor(scoped)) }
249+ } else {
250+ Cap(icon = Lucide.Folder, kind = CapKind.Ghost, size = CapSize.Compact, selected = pickerOpen, onClick = onScopePicker)
251+ }
252+ Cap(icon = Lucide.FolderPlus, kind = CapKind.Ghost, size = CapSize.Compact, onClick = onAddProject)
253+ }
254+ Cap(icon = Lucide.SquarePen, kind = CapKind.Ghost, size = CapSize.Compact, enabled = hasProjects) {
255+ nav.go(if (scope != null) Screen.NewThread(scope.host, scope.path) else Screen.NewThread())
256+ }
257+ }
258+ }
259+
260+ /** Threads whose title matches the search, in shelf order. Conversations live on the hosts, so only titles are searched. */
261+ private fun searchHits(app: AppState, scope: ProjectKey?, query: String): List<Entry> {
262+ val needle = query.trim().lowercase()
263+ if (needle.isEmpty()) return emptyList()
264+ val lists = inbox(app, scope, app.prefs.bool("working_section"), System.currentTimeMillis())
265+ return lists.ordered.filter { entry ->
266+ val pr = entry.detail?.pullRequest?.let { "#${it.number}" }.orEmpty()
267+ entry.summary.title.lowercase().contains(needle) || (pr.isNotEmpty() && pr.contains(needle))
268+ }
269+ }
270+
271+ @Composable
272+ private fun SearchResults(app: AppState, nav: Nav, scope: ProjectKey?, query: String, modifier: Modifier, onMenu: (Entry) -> Unit) {
273+ val p = palette
274+ val now by rememberNow(30_000)
275+ val hits = searchHits(app, scope, query)
276+ Column(
277+ modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = 8.dp),
278+ verticalArrangement = Arrangement.spacedBy(1.dp),
279+ ) {
280+ if (hits.isEmpty()) {
281+ Txt(
282+ "No threads found",
283+ Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 24.dp),
284+ color = p.ink3,
285+ size = 12.sp,
286+ style = TextStyle(textAlign = TextAlign.Center),
287+ )
288+ }
289+ hits.forEachIndexed { ix, entry ->
290+ SearchRow(entry, highlighted = ix == 0, now = now, onOpen = { nav.openThread(entry.ref) }, onMenu = { onMenu(entry) })
291+ }
292+ }
293+ }
294+
295+ /** The inbox: pinned and active cards, then the shelves at the bottom. */
296+ @Composable
297+ private fun InboxList(
298+ app: AppState,
299+ nav: Nav,
300+ scope: ProjectKey?,
301+ hasProjects: Boolean,
302+ modifier: Modifier,
303+ onMenu: (Entry) -> Unit,
304+ onUnpin: (Entry) -> Unit,
305+ onAddProject: () -> Unit,
306+ ) {
307+ val now by rememberNow(30_000)
308+ var workingOpen by remember { mutableStateOf(false) }
309+ var snoozedOpen by remember { mutableStateOf(false) }
310+ var settledOpen by remember { mutableStateOf(false) }
311+ var settledShown by remember { mutableIntStateOf(SETTLED_PAGE) }
312+ val lists = inbox(app, scope, app.prefs.bool("working_section"), now)
313+ val compact = app.prefs.bool("compact_threads")
314+ val activeRef = (nav.screen as? Screen.Thread)?.ref
315+ val showHost = app.hosts.size > 1
316+ val open = { entry: Entry -> nav.openThread(entry.ref) }
317+ val isActive = { entry: Entry -> entry.ref == activeRef }
318+
319+ @Composable
320+ fun card(entry: Entry) {
321+ if (compact) {
322+ SlimRow(entry, SlimKind.Card, isActive(entry), now, { open(entry) }, { onMenu(entry) })
323+ } else {
324+ CardRow(entry, isActive(entry), now, showHost, { open(entry) }, { onMenu(entry) }, onUnpin = { onUnpin(entry) })
325+ }
326+ }
327+
328+ BoxWithConstraints(modifier.fillMaxWidth()) {
329+ val viewport = maxHeight
330+ Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {
331+ Column(
332+ Modifier.fillMaxWidth().heightIn(min = viewport).padding(start = 8.dp, end = 8.dp, bottom = 8.dp),
333+ verticalArrangement = Arrangement.spacedBy(1.dp),
334+ ) {
335+ if (lists.isEmpty) InboxEmpty(app, nav, scope, hasProjects, onAddProject)
336+ for (entry in lists.pinned + lists.active) card(entry)
337+ // The shelves sit at the bottom, under whatever room is left.
338+ Spacer(Modifier.weight(1f))
339+ Shelf("Working", lists.working, workingOpen, info = false, always = false, activeRef, onToggle = { workingOpen = !workingOpen }) {
340+ card(it)
341+ }
342+ Shelf("Snoozed", lists.snoozed, snoozedOpen, info = true, always = false, activeRef, onToggle = { snoozedOpen = !snoozedOpen }) { entry ->
343+ SlimRow(entry, SlimKind.Snoozed, isActive(entry), now, { open(entry) }, { onMenu(entry) })
344+ }
345+ val hidden = Shelf(
346+ "Settled",
347+ lists.settled,
348+ settledOpen,
349+ info = false,
350+ always = !lists.isEmpty,
351+ activeRef = activeRef,
352+ limit = settledShown,
353+ onToggle = {
354+ settledOpen = !settledOpen
355+ if (!settledOpen) settledShown = SETTLED_PAGE
356+ },
357+ ) { entry ->
358+ SlimRow(entry, SlimKind.Settled, isActive(entry), now, { open(entry) }, { onMenu(entry) })
359+ }
360+ if (hidden > 0) ShowMoreRow(minOf(hidden, SETTLED_MORE)) { settledShown += SETTLED_MORE }
361+ }
362+ }
363+ }
364+ }
365+
366+ /**
367+ * One shelf: its header and, while it's open, its threads (the open thread
368+ * shows even when it's closed). Returns how many threads the limit hid.
369+ */
370+ @Composable
371+ private fun ColumnScope.Shelf(
372+ label: String,
373+ threads: List<Entry>,
374+ open: Boolean,
375+ info: Boolean,
376+ always: Boolean,
377+ activeRef: ThreadRef?,
378+ limit: Int = Int.MAX_VALUE,
379+ onToggle: () -> Unit,
380+ row: @Composable (Entry) -> Unit,
381+ ): Int {
382+ if (threads.isEmpty() && !always) return 0
383+ ShelfHeader(if (open) label else "$label (${threads.size})", open, info, onToggle)
384+ var hidden = 0
385+ threads.forEachIndexed { ix, entry ->
386+ if ((open && ix < limit) || entry.ref == activeRef) row(entry) else if (open) hidden++
387+ }
388+ return hidden
389+ }
390+
391+ /** What the inbox says when there's nothing in it. */
392+ @Composable
393+ private fun InboxEmpty(app: AppState, nav: Nav, scope: ProjectKey?, hasProjects: Boolean, onAddProject: () -> Unit) {
394+ val p = palette
395+ val message = when {
396+ app.hosts.isEmpty() -> "Not paired with a desktop yet"
397+ !hasProjects -> "No projects yet"
398+ scope != null -> "No threads in ${app.host(scope.host)?.let { projectName(it, scope.path) } ?: "this project"} yet"
399+ else -> "No threads yet"
400+ }
401+ Column(
402+ Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 24.dp),
403+ horizontalAlignment = Alignment.CenterHorizontally,
404+ verticalArrangement = Arrangement.spacedBy(10.dp),
405+ ) {
406+ Txt(message, color = p.ink3, size = 12.sp)
407+ when {
408+ app.hosts.isEmpty() -> Cap(label = "Pair a desktop", icon = Lucide.Plus, size = CapSize.Compact) { nav.go(Screen.Pair) }
409+ !hasProjects && app.hosts.any { it.connected } -> Cap(label = "Add project", icon = Lucide.Plus, size = CapSize.Compact, onClick = onAddProject)
410+ }
411+ }
412+ }
413+
414+ /** The foot: Settings, and each host with its connection light. */
415+ @Composable
416+ private fun Footer(app: AppState, nav: Nav) {
417+ val p = palette
418+ Row(
419+ Modifier.fillMaxWidth().height(48.dp).padding(horizontal = 8.dp, vertical = 4.dp),
420+ horizontalArrangement = Arrangement.spacedBy(2.dp),
421+ verticalAlignment = Alignment.CenterVertically,
422+ ) {
423+ Cap(icon = Lucide.Settings, kind = CapKind.Ghost) { nav.go(Screen.Settings()) }
424+ Row(
425+ Modifier.weight(1f).horizontalScroll(rememberScrollState()),
426+ horizontalArrangement = Arrangement.spacedBy(2.dp),
427+ verticalAlignment = Alignment.CenterVertically,
428+ ) {
429+ for (host in app.hosts) {
430+ Row(
431+ Modifier
432+ .height(32.dp)
433+ .pressTint(16.dp) { nav.go(Screen.Settings("connections")) }
434+ .padding(horizontal = 10.dp),
435+ horizontalArrangement = Arrangement.spacedBy(6.dp),
436+ verticalAlignment = Alignment.CenterVertically,
437+ ) {
438+ StatusLed(hostLed(host))
439+ Txt(host.name, color = p.ink2, size = 12.5.sp, maxLines = 1)
440+ }
441+ }
442+ }
443+ }
444+ }
445+
446+ /** A host's light: lit while connected, amber while connecting, red when it's offline or turned us away. */
447+ fun hostLed(host: HostState): Led = when {
448+ !host.saved.enabled -> Led.Off
449+ host.status is ConnectionStatus.Connected -> Led.Live
450+ host.status is ConnectionStatus.Connecting -> Led.Waiting
451+ else -> Led.Failed
452+ }
453+
454+ /** The project filter: All projects, then every host's projects, with a search field. */
455+ @Composable
456+ private fun ScopePicker(app: AppState, open: Boolean, scope: ProjectKey?, onDismiss: () -> Unit, onPick: (ProjectKey?) -> Unit) {
457+ val p = palette
458+ var query by remember(open) { mutableStateOf("") }
459+ val needle = query.trim().lowercase()
460+ val showHost = app.hosts.size > 1
461+ BottomSheet(open, onDismiss) {
462+ Row(
463+ Modifier
464+ .fillMaxWidth()
465+ .padding(bottom = 4.dp)
466+ .height(36.dp)
467+ .well(p, RoundedCornerShape(10.dp))
468+ .padding(horizontal = 8.dp),
469+ horizontalArrangement = Arrangement.spacedBy(8.dp),
470+ verticalAlignment = Alignment.CenterVertically,
471+ ) {
472+ LucideIcon(Lucide.Search, size = 14.dp, tint = p.fieldInk)
473+ Box(Modifier.weight(1f), contentAlignment = Alignment.CenterStart) {
474+ if (query.isEmpty()) Txt("Search projects...", color = p.fieldInk, size = 13.sp, maxLines = 1)
475+ BasicTextField(
476+ value = query,
477+ onValueChange = { query = it },
478+ singleLine = true,
479+ textStyle = TextStyle(color = p.ink, fontSize = 13.sp, fontFamily = Sans),
480+ cursorBrush = SolidColor(p.ink),
481+ modifier = Modifier.fillMaxWidth(),
482+ )
483+ }
484+ }
485+ val projects = app.hosts.flatMap { host -> host.projects.map { host to it } }
486+ .filter { (_, project) -> needle.isEmpty() || project.name.lowercase().contains(needle) }
487+ if (needle.isEmpty()) {
488+ MenuItem(selected = scope == null, onClick = { onPick(null) }) {
489+ LucideIcon(Lucide.Folder, size = 15.dp, tint = p.icon)
490+ Txt("All projects", Modifier.weight(1f), size = 13.5.sp, maxLines = 1)
491+ }
492+ }
493+ if (projects.isEmpty() && needle.isNotEmpty()) {
494+ Txt("No matching projects.", Modifier.padding(8.dp), color = p.ink3, size = 13.sp)
495+ }
496+ for ((host, project) in projects) {
497+ val key = ProjectKey(host.saved.id, project.path)
498+ val name = project.name.ifBlank { projectName(host, project.path) }
499+ MenuItem(selected = scope == key, onClick = { onPick(key) }) {
500+ Badge(initials(name), tintFor(name))
501+ Txt(name, Modifier.weight(1f), size = 13.5.sp, maxLines = 1)
502+ if (showHost) Tag(host.name)
503+ }
504+ }
505+ }
19506 }
+170−0android/app/src/main/java/cc/vectrum/code/ui/sidebar/ThreadActions.ktaddedView File
@@ -0,0 +1,170 @@
1+ package cc.vectrum.code.ui.sidebar
2+
3+ import android.content.ClipData
4+ import android.content.ClipboardManager
5+ import android.content.Context
6+ import android.text.format.DateFormat
7+ import cc.vectrum.code.data.AppState
8+ import cc.vectrum.code.net.ThreadAction
9+ import cc.vectrum.code.ui.Nav
10+ import cc.vectrum.code.ui.Screen
11+ import java.time.DayOfWeek
12+ import java.time.Instant
13+ import java.time.LocalDate
14+ import java.time.LocalTime
15+ import java.time.ZoneId
16+ import java.time.ZonedDateTime
17+ import java.time.format.DateTimeFormatter
18+ import java.time.format.TextStyle
19+ import java.util.Locale
20+
21+ // T3 Code's thread actions as the desktop has them (src/ui/workspace/actions.rs):
22+ // snooze presets, the "Settled 1 thread" notice with its undo, leaving a
23+ // thread that leaves the inbox, and copying.
24+
25+ private const val HOUR = 3_600_000L
26+ private const val DAY = 86_400_000L
27+
28+ /** One of the snooze menu's presets: `In 1 hour`, waking at `3:42 PM`. */
29+ class SnoozePreset(val label: String, val `when`: String, val until: Long)
30+
31+ /** `14:32` or `2:32 PM`, as the time format preference says. */
32+ fun clock(context: Context, app: AppState, millis: Long): String {
33+ val twelve = when (app.prefs.string("time_format")) {
34+ "hour12" -> true
35+ "hour24" -> false
36+ else -> !DateFormat.is24HourFormat(context)
37+ }
38+ val time = Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault())
39+ return time.format(DateTimeFormatter.ofPattern(if (twelve) "h:mm a" else "HH:mm", Locale.US))
40+ }
41+
42+ /** The next time the clock shows `hour`:00, `days` days from today. */
43+ private fun atHour(days: Long, hour: Int): Long =
44+ ZonedDateTime.of(LocalDate.now().plusDays(days), LocalTime.of(hour, 0), ZoneId.systemDefault()).toInstant().toEpochMilli()
45+
46+ /**
47+ * T3's snooze presets as of now: in 1 and 3 hours, this evening (while it's
48+ * more than an hour off), tomorrow morning, and next Monday morning (unless
49+ * that's tomorrow).
50+ */
51+ fun snoozePresets(context: Context, app: AppState): List<SnoozePreset> {
52+ val now = System.currentTimeMillis()
53+ val presets = mutableListOf(
54+ SnoozePreset("In 1 hour", clock(context, app, now + HOUR), now + HOUR),
55+ SnoozePreset("In 3 hours", clock(context, app, now + 3 * HOUR), now + 3 * HOUR),
56+ )
57+ val evening = atHour(0, 18)
58+ if (evening - now > HOUR) presets.add(SnoozePreset("This evening", clock(context, app, evening), evening))
59+ val tomorrow = atHour(1, 9)
60+ presets.add(SnoozePreset("Tomorrow", clock(context, app, tomorrow), tomorrow))
61+ val weekday = LocalDate.now().dayOfWeek.value % 7 // Sunday is 0, as the desktop counts.
62+ val daysUntilMonday = ((1 - weekday + 7) % 7).let { if (it == 0) 7 else it }
63+ val nextWeek = atHour(daysUntilMonday.toLong(), 9)
64+ if (nextWeek != tomorrow) {
65+ val day = DayOfWeek.MONDAY.getDisplayName(TextStyle.SHORT, Locale.US)
66+ presets.add(SnoozePreset("Next week", "$day ${clock(context, app, nextWeek)}", nextWeek))
67+ }
68+ return presets
69+ }
70+
71+ /** The countdown a snoozed row shows: `45m`, `2h`, `3d`, or `now` once due. */
72+ fun wakeCountdown(until: Long, now: Long): String {
73+ val left = until - now
74+ return when {
75+ left <= 0 -> "now"
76+ left < HOUR -> "${((left + 59_999) / 60_000).coerceAtLeast(1)}m"
77+ left < DAY -> "${(left + HOUR - 1) / HOUR}h"
78+ else -> "${(left + DAY - 1) / DAY}d"
79+ }
80+ }
81+
82+ /** The long relative time T3 uses in the command palette: `just now`, `5m ago`. */
83+ fun relativeTimeLong(millis: Long): String = when (val short = cc.vectrum.code.util.relativeTime(millis)) {
84+ "now" -> "just now"
85+ else -> "$short ago"
86+ }
87+
88+ /** What the undo notice says was done. */
89+ private enum class UndoKind(val label: String) { Settled("Settled"), Snoozed("Snoozed"), Unpinned("Unpinned") }
90+
91+ private class UndoEntry(val kind: UndoKind, val entry: Entry, val at: Long)
92+
93+ /** The desktop's undo notice: the latest action and those like it right before, undone together. */
94+ private object Undo {
95+ private val entries = mutableListOf<UndoEntry>()
96+
97+ fun push(app: AppState, kind: UndoKind, entry: Entry) {
98+ val now = System.currentTimeMillis()
99+ // The notice stays 5 seconds after the latest action.
100+ if (entries.lastOrNull()?.let { now - it.at > 5_000 } == true) entries.clear()
101+ entries.add(UndoEntry(kind, entry, now))
102+ val group = entries.takeLastWhile { it.kind == kind }
103+ val count = group.size
104+ app.showNotice("${kind.label} $count thread${if (count == 1) "" else "s"}", undo = { undo(group) })
105+ }
106+
107+ private fun undo(group: List<UndoEntry>) {
108+ entries.removeAll(group)
109+ for (undo in group) {
110+ val action = when (undo.kind) {
111+ UndoKind.Settled -> ThreadAction.Unsettle
112+ UndoKind.Snoozed -> ThreadAction.Snooze(null)
113+ UndoKind.Unpinned -> ThreadAction.Pin(true)
114+ }
115+ undo.entry.host.act(undo.entry.id, action)
116+ }
117+ }
118+ }
119+
120+ /**
121+ * Moves off a thread that's leaving the inbox: to the next active card, or a
122+ * new thread in the same project when none is left.
123+ */
124+ fun leaveThread(app: AppState, nav: Nav, entry: Entry) {
125+ val screen = nav.screen
126+ if (screen !is Screen.Thread || screen.ref != entry.ref) return
127+ val lists = inbox(app, null, app.prefs.bool("working_section"), System.currentTimeMillis())
128+ val cards = lists.pinned + lists.active
129+ val ix = cards.indexOfFirst { it.key == entry.key }
130+ val next = if (ix >= 0) cards.getOrNull(ix + 1) ?: cards.getOrNull(ix - 1) else cards.firstOrNull()
131+ if (next != null) nav.openThread(next.ref) else nav.go(Screen.NewThread(entry.host.saved.id, entry.summary.project))
132+ }
133+
134+ fun settle(app: AppState, nav: Nav, entry: Entry) {
135+ if (entry.summary.settled) return
136+ leaveThread(app, nav, entry)
137+ entry.host.act(entry.id, ThreadAction.Settle)
138+ Undo.push(app, UndoKind.Settled, entry)
139+ }
140+
141+ fun unsettle(entry: Entry) = entry.host.act(entry.id, ThreadAction.Unsettle)
142+
143+ fun snooze(app: AppState, nav: Nav, entry: Entry, until: Long) {
144+ if (!entry.canSnooze) return
145+ leaveThread(app, nav, entry)
146+ entry.host.act(entry.id, ThreadAction.Snooze(until))
147+ Undo.push(app, UndoKind.Snoozed, entry)
148+ }
149+
150+ fun wake(entry: Entry) = entry.host.act(entry.id, ThreadAction.Snooze(null))
151+
152+ fun pin(entry: Entry) = entry.host.act(entry.id, ThreadAction.Pin(true))
153+
154+ /** Unpins with the undo notice; asking first, when Unpin confirmation is on, is the caller's. */
155+ fun unpin(app: AppState, entry: Entry) {
156+ entry.host.act(entry.id, ThreadAction.Pin(false))
157+ Undo.push(app, UndoKind.Unpinned, entry)
158+ }
159+
160+ fun delete(app: AppState, nav: Nav, entry: Entry) {
161+ leaveThread(app, nav, entry)
162+ entry.host.act(entry.id, ThreadAction.Delete)
163+ }
164+
165+ /** Copies `text` and says so: "Path copied". */
166+ fun copy(context: Context, app: AppState, text: String, title: String) {
167+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
168+ clipboard.setPrimaryClip(ClipData.newPlainText(title, text))
169+ app.showNotice(title)
170+ }
+167−0android/app/src/main/java/cc/vectrum/code/ui/sidebar/ThreadMenu.ktaddedView File
@@ -0,0 +1,167 @@
1+ package cc.vectrum.code.ui.sidebar
2+
3+ import androidx.compose.foundation.layout.padding
4+ import androidx.compose.runtime.Composable
5+ import androidx.compose.runtime.getValue
6+ import androidx.compose.runtime.mutableStateOf
7+ import androidx.compose.runtime.remember
8+ import androidx.compose.runtime.setValue
9+ import androidx.compose.ui.Modifier
10+ import androidx.compose.ui.platform.LocalContext
11+ import androidx.compose.ui.unit.dp
12+ import androidx.compose.ui.unit.sp
13+ import cc.vectrum.code.data.AppState
14+ import cc.vectrum.code.data.ThreadRef
15+ import cc.vectrum.code.net.ThreadAction
16+ import cc.vectrum.code.theme.Txt
17+ import cc.vectrum.code.theme.palette
18+ import cc.vectrum.code.ui.BottomSheet
19+ import cc.vectrum.code.ui.ConfirmDialog
20+ import cc.vectrum.code.ui.Lucide
21+ import cc.vectrum.code.ui.LucideIcon
22+ import cc.vectrum.code.ui.MenuRow
23+ import cc.vectrum.code.ui.MenuSeparator
24+ import cc.vectrum.code.ui.Nav
25+ import cc.vectrum.code.ui.PromptDialog
26+ import cc.vectrum.code.ui.Screen
27+
28+ private sealed interface MenuDialog {
29+ val entry: Entry
30+
31+ class Rename(override val entry: Entry) : MenuDialog
32+
33+ class Delete(override val entry: Entry) : MenuDialog
34+
35+ class Unpin(override val entry: Entry) : MenuDialog
36+ }
37+
38+ /**
39+ * The thread action menu, item for item as the desktop builds it, as a
40+ * sheet: what the protocol can do remotely (no archive, title
41+ * regeneration or auto-settle, which live on the host's side only).
42+ * [onScope] adds the sidebar's "Filter by {project}".
43+ */
44+ @Composable
45+ fun ThreadMenu(
46+ app: AppState,
47+ nav: Nav,
48+ ref: ThreadRef?,
49+ onDismiss: () -> Unit,
50+ scope: ProjectKey? = null,
51+ onScope: ((ProjectKey?) -> Unit)? = null,
52+ ) {
53+ val p = palette
54+ val context = LocalContext.current
55+ var dialog by remember { mutableStateOf<MenuDialog?>(null) }
56+ var snoozing by remember(ref) { mutableStateOf(false) }
57+ var copying by remember(ref) { mutableStateOf(false) }
58+ val current = ref?.let { r -> app.host(r.host)?.let { host -> host.threads[r.thread]?.let { Entry(host, it) } } }
59+ var last by remember { mutableStateOf<Entry?>(null) }
60+ if (current != null) last = current
61+ val entry = current ?: last
62+
63+ if (entry != null) {
64+ val summary = entry.summary
65+ val now = System.currentTimeMillis()
66+ BottomSheet(open = current != null, onDismiss = onDismiss, title = summary.title.ifBlank { "Thread" }) {
67+ fun run(action: () -> Unit) {
68+ onDismiss()
69+ action()
70+ }
71+ val branch = entry.branch
72+ if (branch != null) {
73+ MenuRow("New thread on $branch", icon = Lucide.MessageSquarePlus) {
74+ run { nav.go(Screen.NewThread(entry.host.saved.id, summary.project)) }
75+ }
76+ }
77+ if (summary.pinned) {
78+ MenuRow("Unpin thread", icon = Lucide.PinOff) {
79+ run { if (app.prefs.bool("confirm_unpin")) dialog = MenuDialog.Unpin(entry) else unpin(app, entry) }
80+ }
81+ } else {
82+ MenuRow("Pin thread", icon = Lucide.Pin) { run { pin(entry) } }
83+ }
84+ if (summary.settled) {
85+ MenuRow("Un-settle thread", icon = Lucide.CircleCheck) { run { unsettle(entry) } }
86+ } else {
87+ MenuRow("Settle thread", icon = Lucide.CircleCheck) { run { settle(app, nav, entry) } }
88+ }
89+ if (entry.snoozed(now)) {
90+ MenuRow("Wake thread", icon = Lucide.Clock) { run { wake(entry) } }
91+ } else {
92+ MenuRow(
93+ "Snooze",
94+ icon = Lucide.Clock,
95+ enabled = entry.canSnooze,
96+ trailing = { LucideIcon(if (snoozing) Lucide.ChevronDown else Lucide.ChevronRight, size = 14.dp, tint = p.ink3) },
97+ ) { snoozing = !snoozing }
98+ if (snoozing) {
99+ for (preset in remember { snoozePresets(context, app) }) {
100+ MenuRow(
101+ preset.label,
102+ trailing = {
103+ Txt(preset.`when`, Modifier.padding(start = 16.dp), color = p.ink3, size = 12.sp, maxLines = 1)
104+ },
105+ ) { run { snooze(app, nav, entry, preset.until) } }
106+ }
107+ }
108+ }
109+ MenuSeparator()
110+ MenuRow("Rename thread", icon = Lucide.Pencil) { run { dialog = MenuDialog.Rename(entry) } }
111+ MenuRow("Mark unread", icon = Lucide.MailOpen) { run { Activity.markUnread(entry) } }
112+ if (onScope != null) {
113+ val filtered = scope == entry.project
114+ MenuRow(if (filtered) "Show all projects" else "Filter by ${entry.projectName}", icon = Lucide.FolderTree) {
115+ run { onScope(if (filtered) null else entry.project) }
116+ }
117+ }
118+ MenuSeparator()
119+ MenuRow(
120+ "Copy",
121+ icon = Lucide.Copy,
122+ trailing = { LucideIcon(if (copying) Lucide.ChevronDown else Lucide.ChevronRight, size = 14.dp, tint = p.ink3) },
123+ ) { copying = !copying }
124+ if (copying) {
125+ MenuRow("Path", icon = Lucide.Folder) {
126+ run {
127+ val path = entry.path
128+ if (path != null) copy(context, app, path, "Path copied")
129+ else app.showNotice("This thread does not have a workspace path to copy.", error = true)
130+ }
131+ }
132+ if (branch != null) {
133+ MenuRow("Branch", icon = Lucide.GitBranch) { run { copy(context, app, branch, "Branch copied") } }
134+ }
135+ MenuRow("Thread ID", icon = Lucide.Hash) { run { copy(context, app, summary.id, "Thread ID copied") } }
136+ }
137+ MenuSeparator()
138+ MenuRow("Delete", icon = Lucide.Trash, danger = true) {
139+ run { if (app.prefs.bool("confirm_delete")) dialog = MenuDialog.Delete(entry) else delete(app, nav, entry) }
140+ }
141+ }
142+ }
143+
144+ when (val shown = dialog) {
145+ is MenuDialog.Rename -> PromptDialog(
146+ title = "Rename thread",
147+ initial = shown.entry.summary.title,
148+ confirm = "Rename",
149+ placeholder = "Thread title",
150+ onDismiss = { dialog = null },
151+ ) { title -> shown.entry.host.act(shown.entry.id, ThreadAction.Rename(title)) }
152+ is MenuDialog.Delete -> ConfirmDialog(
153+ title = "Delete thread \"${shown.entry.summary.title}\"?",
154+ message = "This permanently clears conversation history for this thread.",
155+ confirm = "Confirm",
156+ danger = true,
157+ onDismiss = { dialog = null },
158+ ) { delete(app, nav, shown.entry) }
159+ is MenuDialog.Unpin -> ConfirmDialog(
160+ title = "Unpin thread \"${shown.entry.summary.title}\"?",
161+ message = "This will move the thread out of your pinned section.",
162+ confirm = "Confirm",
163+ onDismiss = { dialog = null },
164+ ) { unpin(app, shown.entry) }
165+ null -> {}
166+ }
167+ }
+372−0android/app/src/main/java/cc/vectrum/code/ui/sidebar/ThreadRows.ktaddedView File
@@ -0,0 +1,372 @@
1+ package cc.vectrum.code.ui.sidebar
2+
3+ import androidx.compose.animation.core.RepeatMode
4+ import androidx.compose.animation.core.animateFloat
5+ import androidx.compose.animation.core.infiniteRepeatable
6+ import androidx.compose.animation.core.rememberInfiniteTransition
7+ import androidx.compose.animation.core.tween
8+ import androidx.compose.foundation.background
9+ import androidx.compose.foundation.clickable
10+ import androidx.compose.foundation.combinedClickable
11+ import androidx.compose.foundation.interaction.MutableInteractionSource
12+ import androidx.compose.foundation.interaction.collectIsPressedAsState
13+ import androidx.compose.foundation.layout.Arrangement
14+ import androidx.compose.foundation.layout.Box
15+ import androidx.compose.foundation.layout.Column
16+ import androidx.compose.foundation.layout.Row
17+ import androidx.compose.foundation.layout.Spacer
18+ import androidx.compose.foundation.layout.defaultMinSize
19+ import androidx.compose.foundation.layout.fillMaxWidth
20+ import androidx.compose.foundation.layout.height
21+ import androidx.compose.foundation.layout.padding
22+ import androidx.compose.foundation.shape.RoundedCornerShape
23+ import androidx.compose.runtime.Composable
24+ import androidx.compose.runtime.getValue
25+ import androidx.compose.runtime.remember
26+ import androidx.compose.ui.Alignment
27+ import androidx.compose.ui.Modifier
28+ import androidx.compose.ui.draw.alpha
29+ import androidx.compose.ui.graphics.Color
30+ import androidx.compose.ui.text.font.FontWeight
31+ import androidx.compose.ui.unit.Dp
32+ import androidx.compose.ui.unit.dp
33+ import androidx.compose.ui.unit.sp
34+ import cc.vectrum.code.net.PullRequest
35+ import cc.vectrum.code.theme.Badge
36+ import cc.vectrum.code.theme.Cap
37+ import cc.vectrum.code.theme.CapKind
38+ import cc.vectrum.code.theme.CapSize
39+ import cc.vectrum.code.theme.Led
40+ import cc.vectrum.code.theme.Mono
41+ import cc.vectrum.code.theme.Palette
42+ import cc.vectrum.code.theme.StatusLed
43+ import cc.vectrum.code.theme.Txt
44+ import cc.vectrum.code.theme.palette
45+ import cc.vectrum.code.theme.raised
46+ import cc.vectrum.code.ui.Lucide
47+ import cc.vectrum.code.ui.LucideIcon
48+ import cc.vectrum.code.util.relativeTime
49+
50+ // The sidebar's rows, as the desktop draws them: three-line cards for pinned,
51+ // active and working threads, slim rows for snoozed and settled ones, and
52+ // the shelf headers between.
53+
54+ /** The card rows' height, T3's `h-[4.875rem]`. */
55+ private val CardHeight = 78.dp
56+ private val SlimHeight = 36.dp
57+
58+ /** Claude's orange, for its mark. */
59+ private val ClaudeOrange = Color(0xffd97757)
60+
61+ /** Which slim row: a snoozed or settled thread, or a card in compact mode. */
62+ enum class SlimKind { Card, Snoozed, Settled }
63+
64+ /** Whether a row steps back: an idle thread with nothing new, or one that's busy on its own. */
65+ private fun recedes(active: Boolean, status: TopStatus?, unread: Boolean): Boolean =
66+ !active && status != TopStatus.Input &&
67+ (status == TopStatus.Working || status == TopStatus.Waiting || ((status == null || status == TopStatus.Approval) && !unread))
68+
69+ /** A row's surface: raised when it's the open thread, tinted while pressed. */
70+ @Composable
71+ private fun Modifier.rowSurface(active: Boolean, radius: Dp, onClick: () -> Unit, onLongClick: () -> Unit): Modifier {
72+ val p = palette
73+ val shape = RoundedCornerShape(radius)
74+ val interaction = remember { MutableInteractionSource() }
75+ val pressed by interaction.collectIsPressedAsState()
76+ return this
77+ .then(
78+ when {
79+ active -> Modifier.raised(p, shape)
80+ pressed -> Modifier.background(p.rowHover, shape)
81+ else -> Modifier
82+ },
83+ )
84+ .combinedClickable(interactionSource = interaction, indication = null, onLongClick = onLongClick, onClick = onClick)
85+ }
86+
87+ /** A pinned, active or working thread: three lines, with the status (or how long ago) in the corner. */
88+ @Composable
89+ fun CardRow(
90+ entry: Entry,
91+ active: Boolean,
92+ now: Long,
93+ showHost: Boolean,
94+ onOpen: () -> Unit,
95+ onMenu: () -> Unit,
96+ onUnpin: () -> Unit,
97+ ) {
98+ val p = palette
99+ val summary = entry.summary
100+ val unread = Activity.isUnread(entry)
101+ val status = topStatus(entry, unread, now)
102+ val recedes = recedes(active, status, unread)
103+ val titleInk = when {
104+ recedes -> p.ink2
105+ unread || status == TopStatus.Input -> p.ink
106+ else -> p.ink.copy(alpha = 0.9f)
107+ }
108+ val weight = if (recedes) FontWeight.Normal else FontWeight.Medium
109+ val workingDim = recedes && status == TopStatus.Working
110+ val detail = entry.detail
111+ val tasks = detail?.backgroundTasks?.size ?: 0
112+ val branch = entry.branch
113+ Box(Modifier.padding(vertical = 1.dp)) {
114+ Column(
115+ Modifier
116+ .fillMaxWidth()
117+ .height(CardHeight)
118+ .rowSurface(active, 14.dp, onOpen, onMenu)
119+ .alpha(if (workingDim) 0.7f else 1f)
120+ .padding(horizontal = 10.dp, vertical = 8.dp),
121+ ) {
122+ Row(Modifier.height(20.dp), verticalAlignment = Alignment.CenterVertically) {
123+ Row(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
124+ Badge(entry.initials, entry.tint)
125+ Txt(
126+ entry.projectName,
127+ Modifier.weight(1f, fill = false),
128+ color = p.ink2,
129+ size = 12.sp,
130+ weight = weight,
131+ maxLines = 1,
132+ )
133+ if (summary.pinned) {
134+ Box(Modifier.clickable(interactionSource = null, indication = null, onClick = onUnpin)) {
135+ LucideIcon(Lucide.Pin, size = 12.dp, tint = p.ink3)
136+ }
137+ }
138+ }
139+ Box(Modifier.padding(start = 6.dp).defaultMinSize(minWidth = 32.dp), contentAlignment = Alignment.CenterEnd) {
140+ if (status != null) StatusLabel(entry, status)
141+ else Txt(relativeTime(maxOf(summary.lastMessage, summary.created), now), color = p.ink3, size = 11.sp, family = Mono, maxLines = 1)
142+ }
143+ Cap(
144+ Modifier.padding(start = 2.dp),
145+ icon = Lucide.Ellipsis,
146+ kind = CapKind.Ghost,
147+ size = CapSize.Mini,
148+ iconColor = p.ink3,
149+ onClick = onMenu,
150+ )
151+ }
152+ Box(Modifier.padding(top = 4.dp).height(20.dp), contentAlignment = Alignment.CenterStart) {
153+ Txt(summary.title, color = titleInk, size = 14.sp, weight = weight, maxLines = 1)
154+ }
155+ Row(
156+ Modifier.padding(top = 2.dp).height(16.dp),
157+ horizontalArrangement = Arrangement.spacedBy(6.dp),
158+ verticalAlignment = Alignment.CenterVertically,
159+ ) {
160+ if (branch != null) {
161+ if (entry.inWorktree) LucideIcon(Lucide.FolderGit2, size = 12.dp, tint = p.ink3.copy(alpha = 0.7f))
162+ Txt(middleTruncate(branch, 32), Modifier.weight(1f, fill = false), color = p.ink3, size = 11.sp, family = Mono, maxLines = 1)
163+ } else {
164+ LucideIcon(Lucide.Server, size = 12.dp, tint = p.ink3)
165+ Txt(entry.host.name, Modifier.weight(1f, fill = false), color = p.ink3, size = 12.sp, maxLines = 1)
166+ }
167+ if (tasks > 0) TerminalMark()
168+ detail?.pullRequest?.let { PrBadge(it) }
169+ Spacer(Modifier.weight(1f))
170+ if (branch != null) {
171+ LucideIcon(Lucide.Server, size = 13.dp, tint = p.ink3)
172+ // The desktop names the host in a hover card; a phone with several hosts names it here.
173+ if (showHost) Txt(entry.host.name, Modifier.weight(1f, fill = false), color = p.ink3, size = 12.sp, maxLines = 1)
174+ }
175+ Box(Modifier.alpha(0.6f)) { ProviderIcon(summary.provider, 13.dp) }
176+ }
177+ }
178+ }
179+ }
180+
181+ /** A snoozed or settled thread (or any card, in compact mode): one line with the countdown or the time at the end. */
182+ @Composable
183+ fun SlimRow(entry: Entry, kind: SlimKind, active: Boolean, now: Long, onOpen: () -> Unit, onMenu: () -> Unit) {
184+ val p = palette
185+ val summary = entry.summary
186+ val unread = Activity.isUnread(entry)
187+ val status = topStatus(entry, unread, now)
188+ val recedes = recedes(active, status, unread)
189+ val titleInk = when {
190+ active || status == TopStatus.Input -> p.ink
191+ unread && !recedes -> p.ink3
192+ else -> p.ink2.copy(alpha = 0.7f)
193+ }
194+ val detail = entry.detail
195+ val tasks = detail?.backgroundTasks?.size ?: 0
196+ Row(
197+ Modifier
198+ .fillMaxWidth()
199+ .height(SlimHeight)
200+ .rowSurface(active, 12.dp, onOpen, onMenu)
201+ .padding(horizontal = 10.dp),
202+ horizontalArrangement = Arrangement.spacedBy(10.dp),
203+ verticalAlignment = Alignment.CenterVertically,
204+ ) {
205+ Box(Modifier.alpha(if (active) 1f else 0.4f)) { Badge(entry.initials, entry.tint) }
206+ Txt(
207+ summary.title,
208+ Modifier.weight(1f).alpha(if (kind == SlimKind.Settled && !active) 0.85f else 1f),
209+ color = titleInk,
210+ size = 13.5.sp,
211+ maxLines = 1,
212+ )
213+ if (summary.pinned) LucideIcon(Lucide.Pin, size = 12.dp, tint = p.ink3)
214+ if (tasks > 0) TerminalMark()
215+ detail?.pullRequest?.let { PrBadge(it) }
216+ Box(Modifier.height(24.dp).defaultMinSize(minWidth = 32.dp), contentAlignment = Alignment.CenterEnd) {
217+ when {
218+ kind == SlimKind.Snoozed -> Txt(
219+ summary.snoozedUntil?.let { wakeCountdown(it, now) } ?: "now",
220+ color = p.blueInk,
221+ size = 12.sp,
222+ maxLines = 1,
223+ )
224+ kind == SlimKind.Settled -> Txt(relativeTime(summary.updated, now), color = p.ink3, size = 11.sp, family = Mono, maxLines = 1)
225+ status != null -> StatusLabel(entry, status)
226+ else -> Txt(
227+ relativeTime(maxOf(summary.lastMessage, summary.created), now),
228+ color = p.ink3,
229+ size = 11.sp,
230+ family = Mono,
231+ maxLines = 1,
232+ )
233+ }
234+ }
235+ }
236+ }
237+
238+ /** The status label in a row's corner, mapped onto the status lights. */
239+ @Composable
240+ fun StatusLabel(entry: Entry, status: TopStatus) {
241+ val p = palette
242+ val (light, pulse, ink) = when (status) {
243+ TopStatus.Working -> Triple(Led.Live, true, p.blueInk)
244+ TopStatus.Waiting -> Triple(null, false, p.ink3)
245+ TopStatus.Approval -> Triple(Led.Waiting, false, p.amberInk)
246+ TopStatus.Input -> Triple(Led.Waiting, true, p.ink)
247+ TopStatus.Limited -> Triple(Led.Waiting, false, p.amber)
248+ TopStatus.Failed -> Triple(Led.Failed, false, p.red)
249+ TopStatus.Done -> Triple(Led.Live, false, p.greenInk)
250+ }
251+ val label = when (status) {
252+ TopStatus.Working -> {
253+ val since = Activity.workingSince[entry.key]
254+ if (since == null) "Working" else {
255+ val now by rememberNow(1_000)
256+ "Working ${workingFor(since, now)}"
257+ }
258+ }
259+ else -> status.name
260+ }
261+ Row(horizontalArrangement = Arrangement.spacedBy(5.dp), verticalAlignment = Alignment.CenterVertically) {
262+ if (light != null) StatusLed(light, pulse = pulse)
263+ Txt(label, color = ink, size = 12.sp, weight = FontWeight.Medium, maxLines = 1)
264+ }
265+ }
266+
267+ /** A shelf header: the label, a rule across, and a chevron. Snoozed's is blue. */
268+ @Composable
269+ fun ShelfHeader(label: String, open: Boolean, info: Boolean, onClick: () -> Unit) {
270+ val p = palette
271+ val ink = if (info) p.blueInk else p.ink3
272+ val rule = if (info) p.blueInk.copy(alpha = 0.25f) else p.rule
273+ Row(
274+ Modifier
275+ .fillMaxWidth()
276+ .height(32.dp)
277+ .pressTint(10.dp, onClick)
278+ .padding(horizontal = 8.dp),
279+ horizontalArrangement = Arrangement.spacedBy(8.dp),
280+ verticalAlignment = Alignment.CenterVertically,
281+ ) {
282+ Txt(label, color = ink, size = 12.sp, weight = FontWeight.Medium, maxLines = 1)
283+ Spacer(Modifier.weight(1f).height(1.dp).background(rule))
284+ LucideIcon(if (open) Lucide.ChevronUp else Lucide.ChevronDown, size = 12.dp, tint = ink)
285+ }
286+ }
287+
288+ /** A row that tints while pressed, as the desktop's rows do on hover. */
289+ @Composable
290+ fun Modifier.pressTint(radius: Dp, onClick: () -> Unit): Modifier {
291+ val p = palette
292+ val interaction = remember { MutableInteractionSource() }
293+ val pressed by interaction.collectIsPressedAsState()
294+ val shape = RoundedCornerShape(radius)
295+ return this
296+ .then(if (pressed) Modifier.background(p.rowHover, shape) else Modifier)
297+ .clickable(interactionSource = interaction, indication = null, onClick = onClick)
298+ }
299+
300+ /** The provider's mark: Claude's orange asterisk, Pi, jcode's feather. */
301+ @Composable
302+ fun ProviderIcon(provider: String, size: Dp) {
303+ val p = palette
304+ when (provider) {
305+ "pi" -> LucideIcon(Lucide.Pi, size = size, tint = p.ink)
306+ "jcode" -> LucideIcon(Lucide.Feather, size = size, tint = p.ink)
307+ else -> LucideIcon(Lucide.Asterisk, size = size, tint = ClaudeOrange)
308+ }
309+ }
310+
311+ /** A thread's pull request: the state's icon and color, and its number. */
312+ @Composable
313+ fun PrBadge(pr: PullRequest) {
314+ val p = palette
315+ val (icon, ink) = prLook(pr.state, p)
316+ Row(horizontalArrangement = Arrangement.spacedBy(3.dp), verticalAlignment = Alignment.CenterVertically) {
317+ LucideIcon(icon, size = 12.dp, tint = ink)
318+ Txt("#${pr.number}", color = ink, size = 12.sp, maxLines = 1)
319+ }
320+ }
321+
322+ private fun prLook(state: String, p: Palette): Pair<Lucide, Color> = when (state) {
323+ "MERGED" -> Lucide.GitMerge to p.blueInk
324+ "CLOSED" -> Lucide.GitPullRequestClosed to p.red
325+ "DRAFT" -> Lucide.GitPullRequestDraft to p.ink3
326+ else -> Lucide.GitPullRequestArrow to p.greenInk
327+ }
328+
329+ /** The pulsing terminal mark for threads with processes still running. */
330+ @Composable
331+ fun TerminalMark() {
332+ val transition = rememberInfiniteTransition(label = "terminal")
333+ val alpha by transition.animateFloat(0.4f, 1f, infiniteRepeatable(tween(800), RepeatMode.Reverse), label = "pulse")
334+ Box(Modifier.alpha(alpha)) { LucideIcon(Lucide.Terminal, size = 14.dp, tint = palette.greenInk) }
335+ }
336+
337+ /** A "Show 25 more" row under the settled threads. */
338+ @Composable
339+ fun ShowMoreRow(count: Int, onClick: () -> Unit) {
340+ val p = palette
341+ Row(
342+ Modifier
343+ .fillMaxWidth()
344+ .height(SlimHeight)
345+ .pressTint(12.dp, onClick)
346+ .padding(horizontal = 10.dp),
347+ horizontalArrangement = Arrangement.spacedBy(10.dp),
348+ verticalAlignment = Alignment.CenterVertically,
349+ ) {
350+ LucideIcon(Lucide.Plus, size = 16.dp, tint = p.ink3)
351+ Txt("Show $count more", color = p.ink3, size = 13.5.sp, maxLines = 1)
352+ }
353+ }
354+
355+ /** A search result: the project's badge, the title and how long ago. */
356+ @Composable
357+ fun SearchRow(entry: Entry, highlighted: Boolean, now: Long, onOpen: () -> Unit, onMenu: () -> Unit) {
358+ val p = palette
359+ Row(
360+ Modifier
361+ .fillMaxWidth()
362+ .defaultMinSize(minHeight = 36.dp)
363+ .rowSurface(highlighted, 12.dp, onOpen, onMenu)
364+ .padding(horizontal = 10.dp, vertical = 4.dp),
365+ horizontalArrangement = Arrangement.spacedBy(10.dp),
366+ verticalAlignment = Alignment.CenterVertically,
367+ ) {
368+ Badge(entry.initials, entry.tint)
369+ Txt(entry.summary.title, Modifier.weight(1f), color = if (highlighted) p.ink else p.ink2, size = 14.sp, maxLines = 1)
370+ Txt(relativeTime(entry.summary.updated, now), color = p.ink3, size = 11.sp, family = Mono, maxLines = 1)
371+ }
372+ }
+9−0android/app/src/main/res/drawable/lucide_folder_tree.xmladdedView File
@@ -0,0 +1,9 @@
1+ <?xml version="1.0" encoding="utf-8"?>
2+ <vector xmlns:android="http://schemas.android.com/apk/res/android"
3+ android:width="24dp" android:height="24dp"
4+ android:viewportWidth="24" android:viewportHeight="24">
5+ <path android:pathData="M20 10a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1h-2.5a1 1 0 0 1-.8-.4l-.9-1.2A1 1 0 0 0 15 3h-2a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1Z" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
6+ <path android:pathData="M20 21a1 1 0 0 0 1-1v-3a1 1 0 0 0-1-1h-2.9a1 1 0 0 1-.88-.55l-.42-.85a1 1 0 0 0-.92-.6H13a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1Z" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
7+ <path android:pathData="M3 5a2 2 0 0 0 2 2h3" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
8+ <path android:pathData="M3 3v13a2 2 0 0 0 2 2h3" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
9+ </vector>
+8−0android/app/src/main/res/drawable/lucide_git_merge.xmladdedView File
@@ -0,0 +1,8 @@
1+ <?xml version="1.0" encoding="utf-8"?>
2+ <vector xmlns:android="http://schemas.android.com/apk/res/android"
3+ android:width="24dp" android:height="24dp"
4+ android:viewportWidth="24" android:viewportHeight="24">
5+ <path android:pathData="M15,18a3,3 0 1 0 6,0a3,3 0 1 0 -6,0Z" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
6+ <path android:pathData="M3,6a3,3 0 1 0 6,0a3,3 0 1 0 -6,0Z" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
7+ <path android:pathData="M6 21V9a9 9 0 0 0 9 9" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
8+ </vector>
+10−0android/app/src/main/res/drawable/lucide_git_pull_request_arrow.xmladdedView File
@@ -0,0 +1,10 @@
1+ <?xml version="1.0" encoding="utf-8"?>
2+ <vector xmlns:android="http://schemas.android.com/apk/res/android"
3+ android:width="24dp" android:height="24dp"
4+ android:viewportWidth="24" android:viewportHeight="24">
5+ <path android:pathData="M2,6a3,3 0 1 0 6,0a3,3 0 1 0 -6,0Z" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
6+ <path android:pathData="M5 9v12" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
7+ <path android:pathData="M16,18a3,3 0 1 0 6,0a3,3 0 1 0 -6,0Z" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
8+ <path android:pathData="m15 9-3-3 3-3" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
9+ <path android:pathData="M12 6h5a2 2 0 0 1 2 2v7" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
10+ </vector>
+11−0android/app/src/main/res/drawable/lucide_git_pull_request_closed.xmladdedView File
@@ -0,0 +1,11 @@
1+ <?xml version="1.0" encoding="utf-8"?>
2+ <vector xmlns:android="http://schemas.android.com/apk/res/android"
3+ android:width="24dp" android:height="24dp"
4+ android:viewportWidth="24" android:viewportHeight="24">
5+ <path android:pathData="m15.5 3.5 5 5" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
6+ <path android:pathData="m15.5 8.5 5-5" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
7+ <path android:pathData="M18 11.62V15" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
8+ <path android:pathData="M6 9v12" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
9+ <path android:pathData="M15,18a3,3 0 1 0 6,0a3,3 0 1 0 -6,0Z" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
10+ <path android:pathData="M3,6a3,3 0 1 0 6,0a3,3 0 1 0 -6,0Z" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
11+ </vector>
+10−0android/app/src/main/res/drawable/lucide_git_pull_request_draft.xmladdedView File
@@ -0,0 +1,10 @@
1+ <?xml version="1.0" encoding="utf-8"?>
2+ <vector xmlns:android="http://schemas.android.com/apk/res/android"
3+ android:width="24dp" android:height="24dp"
4+ android:viewportWidth="24" android:viewportHeight="24">
5+ <path android:pathData="M15,18a3,3 0 1 0 6,0a3,3 0 1 0 -6,0Z" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
6+ <path android:pathData="M3,6a3,3 0 1 0 6,0a3,3 0 1 0 -6,0Z" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
7+ <path android:pathData="M18 6V5" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
8+ <path android:pathData="M18 11v-1" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
9+ <path android:pathData="M6,9 L6,21" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
10+ </vector>
+7−0android/app/src/main/res/drawable/lucide_link.xmladdedView File
@@ -0,0 +1,7 @@
1+ <?xml version="1.0" encoding="utf-8"?>
2+ <vector xmlns:android="http://schemas.android.com/apk/res/android"
3+ android:width="24dp" android:height="24dp"
4+ android:viewportWidth="24" android:viewportHeight="24">
5+ <path android:pathData="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
6+ <path android:pathData="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
7+ </vector>
+7−0android/app/src/main/res/drawable/lucide_mail_open.xmladdedView File
@@ -0,0 +1,7 @@
1+ <?xml version="1.0" encoding="utf-8"?>
2+ <vector xmlns:android="http://schemas.android.com/apk/res/android"
3+ android:width="24dp" android:height="24dp"
4+ android:viewportWidth="24" android:viewportHeight="24">
5+ <path android:pathData="M21.2 8.4c.5.38.8.97.8 1.6v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V10a2 2 0 0 1 .8-1.6l8-6a2 2 0 0 1 2.4 0l8 6Z" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
6+ <path android:pathData="m22 10-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 10" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
7+ </vector>
+8−0android/app/src/main/res/drawable/lucide_message_square_plus.xmladdedView File
@@ -0,0 +1,8 @@
1+ <?xml version="1.0" encoding="utf-8"?>
2+ <vector xmlns:android="http://schemas.android.com/apk/res/android"
3+ android:width="24dp" android:height="24dp"
4+ android:viewportWidth="24" android:viewportHeight="24">
5+ <path android:pathData="M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
6+ <path android:pathData="M12 8v6" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
7+ <path android:pathData="M9 11h6" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
8+ </vector>
+8−0android/app/src/main/res/drawable/lucide_monitor.xmladdedView File
@@ -0,0 +1,8 @@
1+ <?xml version="1.0" encoding="utf-8"?>
2+ <vector xmlns:android="http://schemas.android.com/apk/res/android"
3+ android:width="24dp" android:height="24dp"
4+ android:viewportWidth="24" android:viewportHeight="24">
5+ <path android:pathData="M4,3h16a2,2 0 0 1 2,2v10a2,2 0 0 1 -2,2h-16a2,2 0 0 1 -2,-2v-10a2,2 0 0 1 2,-2Z" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
6+ <path android:pathData="M8,21 L16,21" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
7+ <path android:pathData="M12,17 L12,21" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
8+ </vector>
+44−0android/app/src/test/java/cc/vectrum/code/ui/sidebar/InboxTest.ktaddedView File
@@ -0,0 +1,44 @@
1+ package cc.vectrum.code.ui.sidebar
2+
3+ import cc.vectrum.code.ui.panels.formatTokens
4+ import org.junit.Assert.assertEquals
5+ import org.junit.Test
6+
7+ class InboxTest {
8+ @Test
9+ fun snoozeCountdowns() {
10+ val now = 1_000_000_000L
11+ assertEquals("now", wakeCountdown(now - 5_000, now))
12+ assertEquals("45m", wakeCountdown(now + (45 * 60 - 5) * 1_000L, now))
13+ assertEquals("2h", wakeCountdown(now + 90 * 60 * 1_000L, now))
14+ assertEquals("3d", wakeCountdown(now + 50 * 3_600 * 1_000L, now))
15+ }
16+
17+ @Test
18+ fun tokenCounts() {
19+ assertEquals("812 tokens", formatTokens(812))
20+ assertEquals("23.4k tokens", formatTokens(23_412))
21+ assertEquals("40k tokens", formatTokens(40_000))
22+ assertEquals("1.3M tokens", formatTokens(1_340_000))
23+ }
24+
25+ @Test
26+ fun workingTimes() {
27+ assertEquals("12s", workingFor(0, 12_000))
28+ assertEquals("4m", workingFor(0, 4 * 60_000 + 5_000))
29+ assertEquals("1h 5m", workingFor(0, 65 * 60_000))
30+ }
31+
32+ @Test
33+ fun branchesShortenInTheMiddle() {
34+ assertEquals("main", middleTruncate("main", 32))
35+ assertEquals("featu…idebar", middleTruncate("feature/very-long/sidebar", 12))
36+ }
37+
38+ @Test
39+ fun homeFoldersShowAsTilde() {
40+ assertEquals("~/Work/app", displayPath("/home/joni/Work/app"))
41+ assertEquals("~", displayPath("/Users/jo"))
42+ assertEquals("/srv/app", displayPath("/srv/app"))
43+ }
44+ }
+1−0android/scripts/icons.txtView File
@@ -6,3 +6,4 @@ circle-alert triangle-alert info loader-circle play rotate-ccw rotate-cw refresh
66 clock zap sparkles message-circle-question-mark corner-down-right users workflow hard-drive network external-link house undo-2 power camera wrench plus minus hash at-sign dollar-sign
77 wand-sparkles feather code list send-horizontal circle circle-dot square-dashed bell git-fork book-open bug hammer mic blocks arrow-down-to-line square-stop circle-stop
88 moon-star type contrast map-pin navigation shield move-left move-right inbox mail message-square list-checks chart-no-axes-column plug-zap cpu memory-stick
9+ folder-tree git-merge git-pull-request-arrow git-pull-request-closed git-pull-request-draft link mail-open message-square-plus monitor