Vectrum
Sign In

Android: phone layout like T3 Code's mobile app

On a phone the thread list is home, each screen has a top bar over an
edge-to-edge sheet, and Settings opens on a hub of grouped pages. Threads
show as two-line rows on raised cards with the computer they run on, and
the paired computers' connections sit at the top of home. Tablets keep the
desktop layout.

Also: re-pairing with a corrected key reconnects at once, and the tablet
sidebar's cards keep their host and agent marks at the end of the row.

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

Showing 16 changed files with 1044 additions and 225 deletions.

+7−2android/README.mdView File
@@ -12,11 +12,16 @@ the phone, scan the QR code shown under **Pairing code**, or paste the code
1212 key). A `vectrum://…` link pairs too. The phone tries the addresses fastest first (the LAN, then
1313 Tailscale, then another VPN), keeps the ones the computer reports as it moves between
1414 networks, and falls back on the next one when an address doesn't answer. Several computers can be paired;
15- their threads share the sidebar, tagged with each one's name. The connection isn't encrypted,
15+ their threads share one list, tagged with each one's name. The connection isn't encrypted,
1616 so pair on networks you trust or over a VPN such as Tailscale or WireGuard.
1717
1818 ## What it has
1919
20+ On a phone the app is laid out as phone apps are: the thread list is home, and each screen has
21+ a top bar (back, its title, its actions) over one raised sheet that runs to the screen's edges.
22+ Settings open on a hub of grouped pages. A tablet keeps the desktop's layout: the sidebar beside
23+ an inset sheet.
24+
2025 - The sidebar's inbox: pinned and active threads, the Working, Snoozed and Settled shelves,
2126 search, the project filter, and the thread menu on a long press (pin, settle, snooze, wake,
2227 rename, copy, delete) with the desktop's undo notices.
@@ -56,7 +61,7 @@ JDK 21 and the Android SDK (platform 37) are needed; point `local.properties` at
5661 (`cargo test export_android_themes -- --ignored`), so every theme matches it exactly;
5762 `Metal.kt` has the surfaces (raised, well, plate, with the desktop's layered shadows), caps,
5863 switches, LEDs, keycaps and engraved labels.
59- - `ui/`: `sidebar/`, `chat/` (header, transcript, Markdown), `composer/` (composer, pickers,
64+ - `ui/`: `Phone.kt` (the phone's bar and sheet), `sidebar/` (also the phone's home), `chat/` (header, transcript, Markdown), `composer/` (composer, pickers,
6065 dock, start screen), `diff/`, `settings/`, `pair/` (pairing, adding projects), `panels/`
6166 (right panel, subagents, command palette), `terminal/` and `device/`.
6267 - `term/`: the terminal emulator the terminal panel draws.
+3−1android/app/src/main/java/cc/vectrum/code/data/AppState.ktView File
@@ -127,8 +127,10 @@ class AppState(context: Context) {
127127 val address = wanted.joinToString(",")
128128 val existing = hosts.find { host -> host.saved.addresses.any { it in wanted } }
129129 if (existing != null) {
130+ val rekeyed = existing.saved.key != key
130131 existing.update(existing.saved.copy(address = address, key = key, enabled = true))
131- existing.retry()
132+ // A connection keeps the key it was made with; a new key needs a new one.
133+ if (rekeyed) existing.connect(deviceName) else existing.retry()
132134 return existing
133135 }
134136 val host = HostState(SavedHost(name = name, address = address, key = key), this)
+3−0android/app/src/main/java/cc/vectrum/code/theme/Metal.ktView File
@@ -325,6 +325,9 @@ enum class CapKind {
325325 }
326326
327327 enum class CapSize(val height: Dp, val pad: Dp, val font: TextUnit, val glyph: Dp) {
328+ /** 44dp, a phone's touch size: its top bar and the screen's main action. */
329+ Large(44.dp, 18.dp, 15.sp, 21.dp),
330+
328331 /** 32px, MetalUI's default button. */
329332 Regular(32.dp, 15.dp, 13.sp, 16.dp),
330333
+142−0android/app/src/main/java/cc/vectrum/code/ui/Phone.ktaddedView File
@@ -0,0 +1,142 @@
1+ package cc.vectrum.code.ui
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.ColumnScope
7+ import androidx.compose.foundation.layout.Row
8+ import androidx.compose.foundation.layout.RowScope
9+ import androidx.compose.foundation.layout.Spacer
10+ import androidx.compose.foundation.layout.WindowInsets
11+ import androidx.compose.foundation.layout.WindowInsetsSides
12+ import androidx.compose.foundation.layout.fillMaxSize
13+ import androidx.compose.foundation.layout.fillMaxWidth
14+ import androidx.compose.foundation.layout.height
15+ import androidx.compose.foundation.layout.only
16+ import androidx.compose.foundation.layout.padding
17+ import androidx.compose.foundation.layout.safeDrawing
18+ import androidx.compose.foundation.layout.size
19+ import androidx.compose.foundation.layout.widthIn
20+ import androidx.compose.foundation.layout.windowInsetsPadding
21+ import androidx.compose.foundation.shape.RoundedCornerShape
22+ import androidx.compose.runtime.Composable
23+ import androidx.compose.runtime.staticCompositionLocalOf
24+ import androidx.compose.ui.Alignment
25+ import androidx.compose.ui.Modifier
26+ import androidx.compose.ui.draw.clip
27+ import androidx.compose.ui.text.TextStyle
28+ import androidx.compose.ui.text.font.FontWeight
29+ import androidx.compose.ui.text.style.TextAlign
30+ import androidx.compose.ui.unit.dp
31+ import androidx.compose.ui.unit.sp
32+ import cc.vectrum.code.theme.Cap
33+ import cc.vectrum.code.theme.CapKind
34+ import cc.vectrum.code.theme.CapSize
35+ import cc.vectrum.code.theme.Txt
36+ import cc.vectrum.code.theme.palette
37+ import cc.vectrum.code.theme.plate
38+
39+ // The phone's layout, as phone apps lay theirs out: a top bar on the page
40+ // with the way back, the screen's title and its actions, over one raised
41+ // sheet that runs to the screen's edges and bottom. A tablet keeps the
42+ // desktop's layout instead: the sidebar beside an inset sheet.
43+
44+ /** Whether the app has a phone's layout: narrower than a tablet's 840dp. */
45+ val LocalPhone = staticCompositionLocalOf { false }
46+
47+ /** The phone sheet's top corners. */
48+ val PhoneSheet = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp)
49+
50+ /** A phone screen: [bar] on the page, then [content] on the sheet. */
51+ @Composable
52+ fun PhoneScaffold(
53+ bar: @Composable RowScope.() -> Unit,
54+ modifier: Modifier = Modifier,
55+ content: @Composable ColumnScope.() -> Unit,
56+ ) {
57+ val p = palette
58+ Column(modifier.fillMaxSize()) {
59+ Row(
60+ Modifier.fillMaxWidth().height(64.dp).padding(start = 8.dp, end = 10.dp),
61+ horizontalArrangement = Arrangement.spacedBy(4.dp),
62+ verticalAlignment = Alignment.CenterVertically,
63+ content = bar,
64+ )
65+ Column(
66+ Modifier
67+ .weight(1f)
68+ .fillMaxWidth()
69+ .plate(p, PhoneSheet)
70+ .clip(PhoneSheet)
71+ // The sheet runs under the navigation bar; what's on it stays above it and the keyboard.
72+ .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)),
73+ content = content,
74+ )
75+ }
76+ }
77+
78+ /** The bar's way back. */
79+ @Composable
80+ fun BarBack(onClick: () -> Unit) {
81+ Cap(icon = Lucide.ArrowLeft, kind = CapKind.Ghost, size = CapSize.Large, iconColor = palette.ink, onClick = onClick)
82+ }
83+
84+ /** A bar's action. */
85+ @Composable
86+ fun BarAction(icon: Lucide, enabled: Boolean = true, selected: Boolean = false, onClick: () -> Unit) {
87+ Cap(
88+ icon = icon,
89+ kind = CapKind.Ghost,
90+ size = CapSize.Large,
91+ iconColor = palette.ink,
92+ enabled = enabled,
93+ selected = selected,
94+ onClick = onClick,
95+ )
96+ }
97+
98+ /** A screen's title in the bar. */
99+ @Composable
100+ fun RowScope.BarTitle(title: String) {
101+ Txt(
102+ title,
103+ Modifier.weight(1f).padding(start = 6.dp),
104+ color = palette.ink,
105+ size = 22.sp,
106+ weight = FontWeight.SemiBold,
107+ maxLines = 1,
108+ )
109+ }
110+
111+ /** A screen with nothing on it yet: a title, what to do about it, and the action that does it. */
112+ @Composable
113+ fun EmptyState(title: String, message: String, modifier: Modifier = Modifier, action: (@Composable () -> Unit)? = null) {
114+ val p = palette
115+ Box(modifier.fillMaxSize().padding(horizontal = 32.dp), contentAlignment = Alignment.Center) {
116+ Column(
117+ Modifier.widthIn(max = 360.dp).padding(bottom = 48.dp),
118+ horizontalAlignment = Alignment.CenterHorizontally,
119+ ) {
120+ Txt(
121+ title,
122+ color = p.ink,
123+ size = 22.sp,
124+ weight = FontWeight.SemiBold,
125+ lineHeight = 28.sp,
126+ style = TextStyle(textAlign = TextAlign.Center),
127+ )
128+ Spacer(Modifier.size(8.dp))
129+ Txt(
130+ message,
131+ color = p.ink3,
132+ size = 15.sp,
133+ lineHeight = 22.sp,
134+ style = TextStyle(textAlign = TextAlign.Center),
135+ )
136+ if (action != null) {
137+ Spacer(Modifier.size(24.dp))
138+ action()
139+ }
140+ }
141+ }
142+ }
+54−73android/app/src/main/java/cc/vectrum/code/ui/Root.ktView File
@@ -2,15 +2,12 @@ package cc.vectrum.code.ui
22
33 import androidx.activity.compose.BackHandler
44 import androidx.compose.animation.AnimatedVisibility
5- import androidx.compose.animation.core.animateDpAsState
6- import androidx.compose.animation.core.animateFloatAsState
75 import androidx.compose.animation.fadeIn
86 import androidx.compose.animation.fadeOut
97 import androidx.compose.animation.slideInVertically
108 import androidx.compose.animation.slideOutVertically
119 import androidx.compose.foundation.background
1210 import androidx.compose.foundation.clickable
13- import androidx.compose.foundation.gestures.detectHorizontalDragGestures
1411 import androidx.compose.foundation.layout.Arrangement
1512 import androidx.compose.foundation.layout.Box
1613 import androidx.compose.foundation.layout.BoxWithConstraints
@@ -20,21 +17,20 @@ import androidx.compose.foundation.layout.fillMaxHeight
2017 import androidx.compose.foundation.layout.fillMaxSize
2118 import androidx.compose.foundation.layout.fillMaxWidth
2219 import androidx.compose.foundation.layout.navigationBarsPadding
23- import androidx.compose.foundation.layout.offset
2420 import androidx.compose.foundation.layout.padding
21+ import androidx.compose.foundation.layout.WindowInsetsSides
22+ import androidx.compose.foundation.layout.only
2523 import androidx.compose.foundation.layout.safeDrawing
2624 import androidx.compose.foundation.layout.width
2725 import androidx.compose.foundation.layout.windowInsetsPadding
2826 import androidx.compose.foundation.shape.RoundedCornerShape
2927 import androidx.compose.runtime.Composable
28+ import androidx.compose.runtime.CompositionLocalProvider
3029 import androidx.compose.runtime.LaunchedEffect
31- import androidx.compose.runtime.getValue
3230 import androidx.compose.ui.Alignment
3331 import androidx.compose.ui.Modifier
34- import androidx.compose.ui.draw.alpha
3532 import androidx.compose.ui.draw.clip
36- import androidx.compose.ui.graphics.Color
37- import androidx.compose.ui.input.pointer.pointerInput
33+ import androidx.compose.ui.graphics.Shape
3834 import androidx.compose.ui.unit.dp
3935 import androidx.compose.ui.unit.sp
4036 import cc.vectrum.code.data.AppState
@@ -52,6 +48,7 @@ import cc.vectrum.code.ui.pair.PairScreen
5248 import cc.vectrum.code.ui.panels.CommandPalette
5349 import cc.vectrum.code.ui.panels.RightPanel
5450 import cc.vectrum.code.ui.settings.SettingsScreen
51+ import cc.vectrum.code.ui.sidebar.HomeScreen
5552 import cc.vectrum.code.ui.sidebar.Sidebar
5653 import kotlinx.coroutines.delay
5754
@@ -61,9 +58,10 @@ val SheetRadius = 24.dp
6158 val SidebarWidth = 280.dp
6259
6360 /**
64- * The window, as the desktop lays it out: the sidebar on the page, and the
65- * conversation on a raised sheet beside it. On a phone the sidebar is a
66- * drawer that slides over the sheet; on a tablet it sits beside it.
61+ * The window. On a tablet, as the desktop lays it out: the sidebar on the
62+ * page, and the conversation on a raised sheet beside it. On a phone, as
63+ * phone apps do: the thread list is home, and each screen has a top bar
64+ * over a sheet that runs to the screen's edges (see [PhoneScaffold]).
6765 */
6866 @Composable
6967 fun Root(app: AppState, nav: Nav) {
@@ -73,80 +71,63 @@ fun Root(app: AppState, nav: Nav) {
7371 Modifier
7472 .fillMaxSize()
7573 .background(p.page)
76- .windowInsetsPadding(WindowInsets.safeDrawing),
74+ .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)),
7775 ) {
7876 val wide = maxWidth >= 840.dp
79- val drawerWidth = minOf(SidebarWidth + 24.dp, maxWidth - 48.dp)
80- if (wide) {
81- Row(Modifier.fillMaxSize()) {
82- Sidebar(app, nav, Modifier.width(SidebarWidth).fillMaxHeight())
83- Sheet(app, nav, Modifier.weight(1f).padding(top = SheetInset, end = SheetInset, bottom = SheetInset))
84- }
85- } else {
86- val shift by animateDpAsState(if (nav.sidebarOpen) drawerWidth else 0.dp, label = "drawer")
87- val scrim by animateFloatAsState(if (nav.sidebarOpen) 1f else 0f, label = "scrim")
88- Box(
89- Modifier
90- .fillMaxSize()
91- .pointerInput(Unit) {
92- // A swipe from the left edge opens the sidebar, as on most phone apps.
93- var start = 0f
94- var total = 0f
95- detectHorizontalDragGestures(
96- onDragStart = { start = it.x; total = 0f },
97- onHorizontalDrag = { _, amount -> total += amount },
98- onDragEnd = {
99- if (start < 32.dp.toPx() && total > 60.dp.toPx()) nav.sidebarOpen = true
100- if (nav.sidebarOpen && total < -60.dp.toPx()) nav.sidebarOpen = false
101- },
102- )
103- },
104- ) {
105- Sidebar(app, nav, Modifier.width(drawerWidth).fillMaxHeight().offset(x = shift - drawerWidth))
106- Box(Modifier.fillMaxSize().offset(x = shift)) {
107- Sheet(app, nav, Modifier.fillMaxSize().padding(SheetInset))
108- if (scrim > 0f) {
109- Box(
110- Modifier
111- .fillMaxSize()
112- .alpha(scrim)
113- .background(Color.Black.copy(alpha = if (p.dark) 0.3f else 0.08f))
114- .clickable(interactionSource = null, indication = null) { nav.sidebarOpen = false },
115- )
77+ CompositionLocalProvider(LocalPhone provides !wide) {
78+ if (wide) {
79+ Row(Modifier.fillMaxSize().windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom))) {
80+ Sidebar(app, nav, Modifier.width(SidebarWidth).fillMaxHeight())
81+ val shape = RoundedCornerShape(SheetRadius)
82+ Box(Modifier.weight(1f).padding(top = SheetInset, end = SheetInset, bottom = SheetInset).plate(p, shape).clip(shape)) {
83+ Screens(app, nav)
84+ Panel(app, nav, Modifier.fillMaxSize())
11685 }
11786 }
87+ } else {
88+ Screens(app, nav)
89+ // The panel covers the sheet, under the thread's bar.
90+ Panel(app, nav, Modifier.fillMaxSize().padding(top = 64.dp), PhoneSheet)
91+ }
92+ Box(Modifier.fillMaxSize().windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom))) {
93+ if (nav.palette) CommandPalette(app, nav)
94+ NoticeBar(app, Modifier.align(Alignment.BottomCenter))
11895 }
11996 }
120- if (nav.palette) CommandPalette(app, nav)
121- NoticeBar(app, Modifier.align(Alignment.BottomCenter))
12297 }
12398 }
12499
125- /** The raised sheet and what's on it. */
100+ /** What's on the sheet. */
126101 @Composable
127- private fun Sheet(app: AppState, nav: Nav, modifier: Modifier) {
128- val p = palette
129- val shape = RoundedCornerShape(SheetRadius)
130- Box(modifier.plate(p, shape).clip(shape)) {
131- when (val screen = nav.screen) {
132- Screen.Home -> if (app.hosts.isEmpty()) PairScreen(app, nav) else NewThreadScreen(app, nav, Screen.NewThread())
133- is Screen.Thread -> {
134- val host = app.host(screen.ref.host)
135- if (host == null) {
136- LaunchedEffect(screen) { nav.home() }
137- } else {
138- ThreadScreen(app, nav, host, screen.ref.thread)
139- }
140- }
141- is Screen.NewThread -> NewThreadScreen(app, nav, screen)
142- is Screen.Settings -> SettingsScreen(app, nav, screen.page)
143- Screen.Pair -> PairScreen(app, nav)
144- is Screen.Diff -> DiffScreen(app, nav, screen)
102+ private fun Screens(app: AppState, nav: Nav) {
103+ val phone = LocalPhone.current
104+ when (val screen = nav.screen) {
105+ Screen.Home -> when {
106+ phone -> HomeScreen(app, nav)
107+ app.hosts.isEmpty() -> PairScreen(app, nav)
108+ else -> NewThreadScreen(app, nav, Screen.NewThread())
145109 }
146- if (nav.surface != null || nav.surfacePicker) {
147- val screen = nav.screen
148- if (screen is Screen.Thread) RightPanel(app, nav, screen.ref)
110+ is Screen.Thread -> {
111+ val host = app.host(screen.ref.host)
112+ if (host == null) {
113+ LaunchedEffect(screen) { nav.home() }
114+ } else {
115+ ThreadScreen(app, nav, host, screen.ref.thread)
116+ }
149117 }
118+ is Screen.NewThread -> NewThreadScreen(app, nav, screen)
119+ is Screen.Settings -> SettingsScreen(app, nav, screen.page)
120+ Screen.Pair -> PairScreen(app, nav)
121+ is Screen.Diff -> DiffScreen(app, nav, screen)
122+ }
123+ }
124+
125+ /** The right panel over a thread, while one's open. */
126+ @Composable
127+ private fun Panel(app: AppState, nav: Nav, modifier: Modifier, shape: Shape = RoundedCornerShape(SheetRadius)) {
128+ val screen = nav.screen
129+ if ((nav.surface != null || nav.surfacePicker) && screen is Screen.Thread) {
130+ Box(modifier) { RightPanel(app, nav, screen.ref, shape) }
150131 }
151132 }
152133
+69−7android/app/src/main/java/cc/vectrum/code/ui/chat/Header.ktView File
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Arrangement
66 import androidx.compose.foundation.layout.Box
77 import androidx.compose.foundation.layout.Column
88 import androidx.compose.foundation.layout.Row
9+ import androidx.compose.foundation.layout.RowScope
910 import androidx.compose.foundation.layout.fillMaxWidth
1011 import androidx.compose.foundation.layout.height
1112 import androidx.compose.foundation.layout.padding
@@ -20,7 +21,6 @@ import androidx.compose.runtime.setValue
2021 import androidx.compose.ui.Alignment
2122 import androidx.compose.ui.Modifier
2223 import androidx.compose.ui.draw.clip
23- import androidx.compose.ui.platform.LocalConfiguration
2424 import androidx.compose.ui.platform.LocalContext
2525 import androidx.compose.ui.text.font.FontWeight
2626 import androidx.compose.ui.unit.dp
@@ -38,6 +38,8 @@ import cc.vectrum.code.theme.StatusLed
3838 import cc.vectrum.code.theme.Tag
3939 import cc.vectrum.code.theme.Txt
4040 import cc.vectrum.code.theme.palette
41+ import cc.vectrum.code.ui.BarAction
42+ import cc.vectrum.code.ui.BarBack
4143 import cc.vectrum.code.ui.Lucide
4244 import cc.vectrum.code.ui.LucideIcon
4345 import cc.vectrum.code.ui.Nav
@@ -56,8 +58,6 @@ import cc.vectrum.code.util.tintFor
5658 fun ChatHeader(app: AppState, nav: Nav, thread: OpenThread) {
5759 val p = palette
5860 val context = LocalContext.current
59- // On a tablet the sidebar sits beside the sheet, as Root lays it out.
60- val drawer = LocalConfiguration.current.screenWidthDp < 840
6161 val summary = thread.summary
6262 val host = thread.host
6363 val projectName = thread.project?.name ?: summary.project.trimEnd('/').substringAfterLast('/')
@@ -70,11 +70,10 @@ fun ChatHeader(app: AppState, nav: Nav, thread: OpenThread) {
7070 Modifier
7171 .fillMaxWidth()
7272 .height(52.dp)
73- .padding(start = if (drawer) 8.dp else 20.dp, end = 8.dp),
73+ .padding(start = 20.dp, end = 8.dp),
7474 horizontalArrangement = Arrangement.spacedBy(8.dp),
7575 verticalAlignment = Alignment.CenterVertically,
7676 ) {
77- if (drawer) Cap(icon = Lucide.PanelLeftOpen, kind = CapKind.Ghost) { nav.sidebarOpen = true }
7877 if (projectName.isNotEmpty()) {
7978 // "New thread in {project}".
8079 Row(
@@ -90,7 +89,7 @@ fun ChatHeader(app: AppState, nav: Nav, thread: OpenThread) {
9089 if (parent == null) {
9190 Txt(
9291 projectName,
93- Modifier.widthIn(max = if (drawer) 96.dp else 160.dp),
92+ Modifier.widthIn(max = 160.dp),
9493 color = p.ink3,
9594 size = 14.sp,
9695 weight = FontWeight.Medium,
@@ -110,7 +109,7 @@ fun ChatHeader(app: AppState, nav: Nav, thread: OpenThread) {
110109 horizontalArrangement = Arrangement.spacedBy(6.dp),
111110 verticalAlignment = Alignment.CenterVertically,
112111 ) {
113- Txt(parent.title, Modifier.widthIn(max = if (drawer) 88.dp else 200.dp), color = p.ink3, size = 14.sp, maxLines = 1)
112+ Txt(parent.title, Modifier.widthIn(max = 200.dp), color = p.ink3, size = 14.sp, maxLines = 1)
114113 Txt("/", color = p.icon, size = 14.sp)
115114 LucideIcon(Lucide.Bot, size = 14.dp, tint = p.ink3)
116115 }
@@ -156,6 +155,69 @@ fun ChatHeader(app: AppState, nav: Nav, thread: OpenThread) {
156155 ThreadMenu(app, nav, thread, menu) { menu = false }
157156 }
158157
158+ /**
159+ * The chat header as a phone's bar: the way back to the threads, the title
160+ * over its project (or the thread that delegated it), which opens the
161+ * thread menu, and the right panel's toggle.
162+ */
163+ @Composable
164+ fun RowScope.PhoneChatBar(app: AppState, nav: Nav, thread: OpenThread) {
165+ val p = palette
166+ val context = LocalContext.current
167+ val summary = thread.summary
168+ val host = thread.host
169+ val projectName = thread.project?.name ?: summary.project.trimEnd('/').substringAfterLast('/')
170+ val parent = summary.parent?.let { host.threads[it] }
171+ val subagentsWorking by remember(thread) { derivedStateOf { workingSubagents(thread.items) } }
172+ var menu by remember { mutableStateOf(false) }
173+
174+ // A subagent's thread goes back to its parent; any other to the list.
175+ BarBack {
176+ if (parent != null) nav.openThread(ThreadRef(host.saved.id, parent.id)) else if (!nav.back()) nav.home()
177+ }
178+ Column(
179+ Modifier
180+ .weight(1f)
181+ .clip(RoundedCornerShape(12.dp))
182+ .clickable { menu = true }
183+ .padding(horizontal = 6.dp, vertical = 4.dp),
184+ verticalArrangement = Arrangement.spacedBy(2.dp),
185+ ) {
186+ Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) {
187+ Txt(
188+ summary.title.ifBlank { "New thread" },
189+ Modifier.weight(1f, fill = false),
190+ color = p.ink,
191+ size = 16.sp,
192+ weight = FontWeight.SemiBold,
193+ maxLines = 1,
194+ )
195+ LucideIcon(Lucide.ChevronDown, size = 14.dp, tint = p.ink3)
196+ }
197+ Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
198+ if (projectName.isNotEmpty()) Badge(initials(projectName), tintFor(projectName))
199+ if (parent != null) LucideIcon(Lucide.Bot, size = 12.dp, tint = p.ink3)
200+ val where = listOfNotNull(
201+ parent?.title ?: projectName.ifEmpty { null },
202+ host.name,
203+ ).joinToString(" · ")
204+ Txt(where, Modifier.weight(1f, fill = false), color = p.ink3, size = 12.5.sp, maxLines = 1)
205+ }
206+ }
207+ thread.detail.pullRequest?.let { pr ->
208+ PullRequestBadge(pr) {
209+ runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, android.net.Uri.parse(pr.url))) }
210+ }
211+ }
212+ Box {
213+ BarAction(Lucide.PanelRightOpen, selected = nav.surface != null) { nav.surfacePicker = true }
214+ if (subagentsWorking > 0 && nav.surface != Surface.Subagents) {
215+ StatusLed(Led.Live, Modifier.align(Alignment.TopEnd).padding(top = 8.dp, end = 8.dp), pulse = true)
216+ }
217+ }
218+ ThreadMenu(app, nav, thread, menu) { menu = false }
219+ }
220+
159221 /** A pull request's state and number, as the sidebar marks threads with one. */
160222 @Composable
161223 fun PullRequestBadge(pr: PullRequest, onClick: () -> Unit) {
+17−6android/app/src/main/java/cc/vectrum/code/ui/chat/ThreadScreen.ktView File
@@ -9,7 +9,9 @@ import androidx.compose.runtime.DisposableEffect
99 import androidx.compose.ui.Modifier
1010 import cc.vectrum.code.data.AppState
1111 import cc.vectrum.code.data.HostState
12+ import cc.vectrum.code.ui.LocalPhone
1213 import cc.vectrum.code.ui.Nav
14+ import cc.vectrum.code.ui.PhoneScaffold
1315 import cc.vectrum.code.ui.composer.Composer
1416 import cc.vectrum.code.ui.composer.Dock
1517
@@ -23,12 +25,21 @@ fun ThreadScreen(app: AppState, nav: Nav, host: HostState, id: String) {
2325 DisposableEffect(host, id) {
2426 onDispose { host.close(id) }
2527 }
26- Column(Modifier.fillMaxSize()) {
27- ChatHeader(app, nav, thread)
28- Box(Modifier.weight(1f).fillMaxWidth()) {
29- Timeline(app, nav, thread, Modifier.fillMaxSize())
28+ val body = @Composable { modifier: Modifier ->
29+ Column(modifier) {
30+ Box(Modifier.weight(1f).fillMaxWidth()) {
31+ Timeline(app, nav, thread, Modifier.fillMaxSize())
32+ }
33+ Dock(app, nav, thread)
34+ Composer(app, nav, thread)
35+ }
36+ }
37+ if (LocalPhone.current) {
38+ PhoneScaffold(bar = { PhoneChatBar(app, nav, thread) }) { body(Modifier.weight(1f).fillMaxWidth()) }
39+ } else {
40+ Column(Modifier.fillMaxSize()) {
41+ ChatHeader(app, nav, thread)
42+ body(Modifier.weight(1f).fillMaxWidth())
3043 }
31- Dock(app, nav, thread)
32- Composer(app, nav, thread)
3344 }
3445 }
+16−12android/app/src/main/java/cc/vectrum/code/ui/composer/NewThreadScreen.ktView File
@@ -54,7 +54,11 @@ import cc.vectrum.code.ui.MenuItem
5454 import cc.vectrum.code.ui.MenuLabel
5555 import cc.vectrum.code.ui.MenuRow
5656 import cc.vectrum.code.ui.MenuSeparator
57+ import cc.vectrum.code.ui.BarBack
58+ import cc.vectrum.code.ui.BarTitle
59+ import cc.vectrum.code.ui.LocalPhone
5760 import cc.vectrum.code.ui.Nav
61+ import cc.vectrum.code.ui.PhoneScaffold
5862 import cc.vectrum.code.ui.Screen
5963 import cc.vectrum.code.ui.pair.AddProjectSheet
6064 import cc.vectrum.code.util.initials
@@ -88,9 +92,8 @@ fun NewThreadScreen(app: AppState, nav: Nav, screen: Screen.NewThread) {
8892 val project = host?.projects?.find { it.path == pick.project }
8993 var addProject by remember { mutableStateOf(false) }
9094
91- Column(Modifier.fillMaxSize()) {
92- TopRow(nav)
93- BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {
95+ val start = @Composable { modifier: Modifier ->
96+ BoxWithConstraints(modifier) {
9497 Column(
9598 Modifier
9699 .fillMaxWidth()
@@ -107,16 +110,17 @@ fun NewThreadScreen(app: AppState, nav: Nav, screen: Screen.NewThread) {
107110 }
108111 }
109112 }
110- AddProjectSheet(app, host ?: connected.firstOrNull(), addProject) { addProject = false }
111- }
112-
113- /** The sidebar's button, on a phone where the sidebar is a drawer. */
114- @Composable
115- private fun TopRow(nav: Nav) {
116- if (LocalConfiguration.current.screenWidthDp >= 840) return
117- Row(Modifier.fillMaxWidth().padding(8.dp), verticalAlignment = Alignment.CenterVertically) {
118- Cap(icon = Lucide.PanelLeftOpen, kind = CapKind.Ghost, size = CapSize.Regular) { nav.sidebarOpen = true }
113+ if (LocalPhone.current) {
114+ PhoneScaffold(
115+ bar = {
116+ BarBack { if (!nav.back()) nav.home() }
117+ BarTitle("New thread")
118+ },
119+ ) { start(Modifier.weight(1f).fillMaxWidth()) }
120+ } else {
121+ start(Modifier.fillMaxSize())
119122 }
123+ AddProjectSheet(app, host ?: connected.firstOrNull(), addProject) { addProject = false }
120124 }
121125
122126 /** No host to start a thread on, or one without projects. */
+41−17android/app/src/main/java/cc/vectrum/code/ui/diff/DiffScreen.ktView File
@@ -57,7 +57,11 @@ import cc.vectrum.code.theme.palette
5757 import cc.vectrum.code.theme.well
5858 import cc.vectrum.code.ui.Lucide
5959 import cc.vectrum.code.ui.LucideIcon
60+ import cc.vectrum.code.ui.BarAction
61+ import cc.vectrum.code.ui.BarBack
62+ import cc.vectrum.code.ui.LocalPhone
6063 import cc.vectrum.code.ui.Nav
64+ import cc.vectrum.code.ui.PhoneScaffold
6165 import cc.vectrum.code.ui.Screen
6266
6367 // The diff view a turn's changes open (src/ui/changes.rs): every file the
@@ -104,9 +108,43 @@ fun DiffScreen(app: AppState, nav: Nav, screen: Screen.Diff) {
104108 val anyOpen = files.any { collapsed[it.path] != true }
105109 val ink = diffInk(p, prefs.string("diff_colors"))
106110
111+ val counts = @Composable {
112+ if (files.isNotEmpty()) {
113+ Counts(
114+ files.sumOf { file -> file.lines.count { it.kind == "added" }.toLong() },
115+ files.sumOf { file -> file.lines.count { it.kind == "removed" }.toLong() },
116+ ink,
117+ )
118+ }
119+ }
120+ val toggleAll = { if (anyOpen) files.forEach { collapsed[it.path] = true } else collapsed.clear() }
121+ val body = @Composable { wide: Boolean, split: Boolean ->
122+ val current = reply
123+ when {
124+ current == null -> Message("Reading the diff…", p.ink3)
125+ current.error != null -> Message("Couldn't read the diff: ${current.error}", p.red)
126+ files.isEmpty() -> Message(if (ignoreWhitespace) "Only whitespace changed." else "Nothing changed.", p.ink3)
127+ else -> DiffBody(files, collapsed, split, scrolls, ink, if (wide) 16.dp else 12.dp)
128+ }
129+ }
130+
107131 BoxWithConstraints(Modifier.fillMaxSize()) {
108132 val wide = maxWidth >= 700.dp
109133 val split = wide && sideBySide
134+ if (LocalPhone.current) {
135+ PhoneScaffold(
136+ bar = {
137+ BarBack { nav.back() }
138+ Column(Modifier.weight(1f).padding(start = 6.dp)) {
139+ Txt(title, color = p.ink, size = 16.sp, weight = FontWeight.SemiBold, maxLines = 1)
140+ counts()
141+ }
142+ if (files.isNotEmpty()) BarAction(if (anyOpen) Lucide.ChevronsDownUp else Lucide.ChevronsUpDown, onClick = toggleAll)
143+ BarAction(Lucide.Space, selected = ignoreWhitespace) { ignoreWhitespace = !ignoreWhitespace }
144+ },
145+ ) { body(wide, split) }
146+ return@BoxWithConstraints
147+ }
110148 Column(Modifier.fillMaxSize()) {
111149 Row(
112150 Modifier.fillMaxWidth().height(52.dp).padding(start = 8.dp, end = 8.dp),
@@ -117,20 +155,12 @@ fun DiffScreen(app: AppState, nav: Nav, screen: Screen.Diff) {
117155 LucideIcon(Lucide.FileDiff, size = 15.dp, tint = p.ink2)
118156 Column(Modifier.weight(1f)) {
119157 Txt(title, color = p.ink, size = 13.5.sp, weight = FontWeight.SemiBold, maxLines = 1)
120- if (files.isNotEmpty()) {
121- Counts(
122- files.sumOf { file -> file.lines.count { it.kind == "added" }.toLong() },
123- files.sumOf { file -> file.lines.count { it.kind == "removed" }.toLong() },
124- ink,
125- )
126- }
158+ counts()
127159 }
128160 if (files.isNotEmpty()) {
129161 val icon = if (anyOpen) Lucide.ChevronsDownUp else Lucide.ChevronsUpDown
130162 val label = if (anyOpen) "Collapse all" else "Expand all"
131- Cap(label = label.takeIf { wide }, icon = icon, kind = CapKind.Ghost, size = CapSize.Compact) {
132- if (anyOpen) files.forEach { collapsed[it.path] = true } else collapsed.clear()
133- }
163+ Cap(label = label.takeIf { wide }, icon = icon, kind = CapKind.Ghost, size = CapSize.Compact, onClick = toggleAll)
134164 }
135165 Cap(icon = Lucide.Space, kind = CapKind.Ghost, size = CapSize.Compact, selected = ignoreWhitespace) {
136166 ignoreWhitespace = !ignoreWhitespace
@@ -138,13 +168,7 @@ fun DiffScreen(app: AppState, nav: Nav, screen: Screen.Diff) {
138168 if (wide) Segmented(listOf(false to "Stacked", true to "Side by side"), sideBySide) { sideBySide = it }
139169 }
140170 Rule()
141- val current = reply
142- when {
143- current == null -> Message("Reading the diff…", p.ink3)
144- current.error != null -> Message("Couldn't read the diff: ${current.error}", p.red)
145- files.isEmpty() -> Message(if (ignoreWhitespace) "Only whitespace changed." else "Nothing changed.", p.ink3)
146- else -> DiffBody(files, collapsed, split, scrolls, ink, if (wide) 16.dp else 12.dp)
147- }
171+ body(wide, split)
148172 }
149173 }
150174 }
+36−19android/app/src/main/java/cc/vectrum/code/ui/pair/PairScreen.ktView File
@@ -26,7 +26,6 @@ import androidx.compose.runtime.setValue
2626 import androidx.compose.ui.Alignment
2727 import androidx.compose.ui.Modifier
2828 import androidx.compose.ui.graphics.ColorFilter
29- import androidx.compose.ui.platform.LocalConfiguration
3029 import androidx.compose.ui.platform.LocalContext
3130 import androidx.compose.ui.res.painterResource
3231 import androidx.compose.ui.text.font.FontWeight
@@ -53,7 +52,11 @@ import cc.vectrum.code.theme.palette
5352 import cc.vectrum.code.theme.raised
5453 import cc.vectrum.code.ui.Field
5554 import cc.vectrum.code.ui.Lucide
55+ import cc.vectrum.code.ui.BarBack
56+ import cc.vectrum.code.ui.BarTitle
57+ import cc.vectrum.code.ui.LocalPhone
5658 import cc.vectrum.code.ui.Nav
59+ import cc.vectrum.code.ui.PhoneScaffold
5760 import cc.vectrum.code.ui.Screen
5861 import kotlinx.coroutines.delay
5962
@@ -108,24 +111,28 @@ fun PairScreen(app: AppState, nav: Nav) {
108111 }
109112 }
110113
111- Column(Modifier.fillMaxSize()) {
112- TopBar(nav, onPairScreen)
114+ val phone = LocalPhone.current
115+ val big = if (phone) CapSize.Large else CapSize.Regular
116+ val form = @Composable { modifier: Modifier ->
113117 Box(
114- Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).imePadding(),
118+ modifier.verticalScroll(rememberScrollState()).imePadding(),
115119 contentAlignment = Alignment.TopCenter,
116120 ) {
117121 Column(
118- Modifier.widthIn(max = 440.dp).fillMaxWidth().padding(start = 20.dp, end = 20.dp, top = 16.dp, bottom = 40.dp),
122+ Modifier.widthIn(max = 440.dp).fillMaxWidth().padding(start = 20.dp, end = 20.dp, top = if (phone) 28.dp else 16.dp, bottom = 40.dp),
119123 horizontalAlignment = Alignment.CenterHorizontally,
120124 verticalArrangement = Arrangement.spacedBy(14.dp),
121125 ) {
122- Image(
123- painterResource(R.drawable.vectrum_logo),
124- contentDescription = null,
125- modifier = Modifier.size(32.dp),
126- colorFilter = ColorFilter.tint(p.ink),
127- )
128- Engraved("Pair with a computer")
126+ // On a phone the bar says it.
127+ if (!phone) {
128+ Image(
129+ painterResource(R.drawable.vectrum_logo),
130+ contentDescription = null,
131+ modifier = Modifier.size(32.dp),
132+ colorFilter = ColorFilter.tint(p.ink),
133+ )
134+ Engraved("Pair with a computer")
135+ }
129136 Txt(
130137 "Work on your computer's threads from here.",
131138 color = p.ink,
@@ -168,7 +175,7 @@ fun PairScreen(app: AppState, nav: Nav) {
168175 Txt(error!!, Modifier.fillMaxWidth().padding(horizontal = 4.dp), color = p.red, size = 12.5.sp, lineHeight = 18.sp)
169176 }
170177 Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
171- Cap(Modifier.weight(1f), label = "Scan QR code", icon = Lucide.ScanLine) {
178+ Cap(Modifier.weight(1f), label = "Scan QR code", icon = Lucide.ScanLine, size = big) {
172179 scanQrCode(
173180 context,
174181 onCode = { text ->
@@ -184,6 +191,7 @@ fun PairScreen(app: AppState, nav: Nav) {
184191 label = "Connect",
185192 icon = Lucide.Plug,
186193 kind = CapKind.Primary,
194+ size = big,
187195 enabled = addresses.isNotEmpty() && (!needsKey || key.text.isNotBlank()),
188196 ) { connect() }
189197 }
@@ -199,20 +207,29 @@ fun PairScreen(app: AppState, nav: Nav) {
199207 }
200208 }
201209 }
210+ if (phone) {
211+ PhoneScaffold(
212+ bar = {
213+ BarBack { if (!nav.back()) nav.home() }
214+ BarTitle("Pair a computer")
215+ },
216+ ) { form(Modifier.weight(1f).fillMaxWidth()) }
217+ } else {
218+ Column(Modifier.fillMaxSize()) {
219+ TopBar(nav, onPairScreen)
220+ form(Modifier.weight(1f).fillMaxWidth())
221+ }
222+ }
202223 }
203224
204- /** Back to where the Pair screen was opened from, or the sidebar on Home. */
225+ /** Back to where the Pair screen was opened from, on a tablet; Home has nothing to go back to. */
205226 @Composable
206227 private fun TopBar(nav: Nav, onPairScreen: Boolean) {
207- val drawer = LocalConfiguration.current.screenWidthDp < 840
208228 Row(
209229 Modifier.fillMaxWidth().height(52.dp).padding(horizontal = 8.dp),
210230 verticalAlignment = Alignment.CenterVertically,
211231 ) {
212- when {
213- onPairScreen -> Cap(icon = Lucide.ArrowLeft, kind = CapKind.Ghost, size = CapSize.Compact) { if (!nav.back()) nav.home() }
214- drawer -> Cap(icon = Lucide.PanelLeftOpen, kind = CapKind.Ghost, size = CapSize.Compact) { nav.sidebarOpen = true }
215- }
232+ if (onPairScreen) Cap(icon = Lucide.ArrowLeft, kind = CapKind.Ghost, size = CapSize.Compact) { if (!nav.back()) nav.home() }
216233 }
217234 }
218235
+10−3android/app/src/main/java/cc/vectrum/code/ui/panels/RightPanel.ktView File
@@ -9,7 +9,12 @@ import androidx.compose.foundation.layout.Arrangement
99 import androidx.compose.foundation.layout.Box
1010 import androidx.compose.foundation.layout.Column
1111 import androidx.compose.foundation.layout.Row
12+ import androidx.compose.foundation.layout.WindowInsets
13+ import androidx.compose.foundation.layout.WindowInsetsSides
1214 import androidx.compose.foundation.layout.fillMaxSize
15+ import androidx.compose.foundation.layout.only
16+ import androidx.compose.foundation.layout.safeDrawing
17+ import androidx.compose.foundation.layout.windowInsetsPadding
1318 import androidx.compose.foundation.layout.fillMaxWidth
1419 import androidx.compose.foundation.layout.height
1520 import androidx.compose.foundation.layout.padding
@@ -19,6 +24,7 @@ import androidx.compose.runtime.Composable
1924 import androidx.compose.runtime.remember
2025 import androidx.compose.ui.Alignment
2126 import androidx.compose.ui.Modifier
27+ import androidx.compose.ui.graphics.Shape
2228 import androidx.compose.ui.draw.clip
2329 import androidx.compose.ui.text.font.FontWeight
2430 import androidx.compose.ui.text.style.TextAlign
@@ -58,11 +64,10 @@ private val SURFACES = listOf(
5864
5965 /** The right panel over a thread: "Open a surface", or the surface picked. */
6066 @Composable
61- fun RightPanel(app: AppState, nav: Nav, ref: ThreadRef) {
67+ fun RightPanel(app: AppState, nav: Nav, ref: ThreadRef, shape: Shape = RoundedCornerShape(SheetRadius)) {
6268 val p = palette
6369 val shown = remember { MutableTransitionState(false) }
6470 shown.targetState = true
65- val shape = RoundedCornerShape(SheetRadius)
6671 AnimatedVisibility(
6772 visibleState = shown,
6873 enter = slideInHorizontally { it / 3 } + fadeIn(),
@@ -73,7 +78,9 @@ fun RightPanel(app: AppState, nav: Nav, ref: ThreadRef) {
7378 .plate(p, shape)
7479 .clip(shape)
7580 // The thread underneath doesn't take touches meant for the panel.
76- .clickable(interactionSource = null, indication = null) {},
81+ .clickable(interactionSource = null, indication = null) {}
82+ // On a phone the panel runs under the navigation bar, as the sheet does.
83+ .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)),
7784 ) {
7885 val surface = nav.surface
7986 when {
+238−56android/app/src/main/java/cc/vectrum/code/ui/settings/SettingsScreen.ktView File
@@ -38,7 +38,6 @@ import androidx.compose.ui.Modifier
3838 import androidx.compose.ui.draw.clip
3939 import androidx.compose.ui.graphics.ColorFilter
4040 import androidx.compose.ui.graphics.SolidColor
41- import androidx.compose.ui.platform.LocalConfiguration
4241 import androidx.compose.ui.platform.LocalContext
4342 import androidx.compose.ui.res.painterResource
4443 import androidx.compose.ui.text.TextStyle
@@ -49,6 +48,7 @@ import androidx.compose.ui.unit.dp
4948 import androidx.compose.ui.unit.sp
5049 import cc.vectrum.code.R
5150 import cc.vectrum.code.data.AppState
51+ import cc.vectrum.code.data.DevicePrefs
5252 import cc.vectrum.code.theme.Cap
5353 import cc.vectrum.code.theme.CapKind
5454 import cc.vectrum.code.theme.CapSize
@@ -57,12 +57,17 @@ import cc.vectrum.code.theme.Mono
5757 import cc.vectrum.code.theme.Rule
5858 import cc.vectrum.code.theme.Sans
5959 import cc.vectrum.code.theme.Tag
60+ import cc.vectrum.code.theme.Themes
6061 import cc.vectrum.code.theme.Txt
6162 import cc.vectrum.code.theme.VRule
6263 import cc.vectrum.code.theme.palette
6364 import cc.vectrum.code.theme.raised
6465 import cc.vectrum.code.theme.well
66+ import cc.vectrum.code.ui.BarBack
67+ import cc.vectrum.code.ui.BarTitle
6568 import cc.vectrum.code.ui.ConfirmDialog
69+ import cc.vectrum.code.ui.LocalPhone
70+ import cc.vectrum.code.ui.PhoneScaffold
6671 import cc.vectrum.code.ui.Lucide
6772 import cc.vectrum.code.ui.LucideIcon
6873 import cc.vectrum.code.ui.Nav
@@ -74,7 +79,8 @@ import kotlinx.coroutines.delay
7479 // breadcrumb. On a phone the page list comes first and each page fills the
7580 // sheet; on a tablet they sit side by side, as on the desktop. Every change
7681 // is saved at once: this phone's preferences here, environment settings on
77- // the hosts.
82+ // the hosts. On a phone the list is a hub of grouped rows, as phone apps
83+ // lay out their settings, and each page has the phone's bar.
7884
7985 /** The widest a settings page grows (T3 Code's "readable" width). */
8086 private val ReadableWidth = 896.dp
@@ -101,8 +107,9 @@ fun SettingsScreen(app: AppState, nav: Nav, page: String) {
101107 }
102108 }
103109
110+ val phone = LocalPhone.current
104111 BoxWithConstraints(Modifier.fillMaxSize()) {
105- val wide = maxWidth >= 720.dp
112+ val wide = !phone && maxWidth >= 720.dp
106113 val open = { next: SettingsPage? ->
107114 target = null
108115 nav.replace(Screen.Settings(next?.key ?: ""))
@@ -129,6 +136,8 @@ fun SettingsScreen(app: AppState, nav: Nav, page: String) {
129136 Spacer(Modifier.width(1.dp).fillMaxHeight().background(palette.rule))
130137 PageView(app, nav, current ?: SettingsPage.General, scope, target, back = null, open = open, modifier = Modifier.weight(1f))
131138 }
139+ } else if (current == null && phone) {
140+ Hub(app, nav, query, { query = it }, open, jump)
132141 } else if (current == null) {
133142 list(Modifier.fillMaxSize())
134143 } else {
@@ -151,16 +160,14 @@ private fun PageList(
151160 ) {
152161 val p = palette
153162 val context = LocalContext.current
154- val drawer = LocalConfiguration.current.screenWidthDp < 840
155163 val searching = query.text.isNotBlank()
156164 val results = searchSettings(query.text)
157165 Column(modifier) {
158166 Row(
159- Modifier.fillMaxWidth().height(52.dp).padding(start = if (drawer) 8.dp else 16.dp, end = 16.dp),
167+ Modifier.fillMaxWidth().height(52.dp).padding(start = 16.dp, end = 16.dp),
160168 horizontalArrangement = Arrangement.spacedBy(8.dp),
161169 verticalAlignment = Alignment.CenterVertically,
162170 ) {
163- if (drawer) Cap(icon = Lucide.PanelLeftOpen, kind = CapKind.Ghost, size = CapSize.Compact) { nav.sidebarOpen = true }
164171 Image(
165172 painterResource(R.drawable.vectrum_logo),
166173 contentDescription = null,
@@ -204,10 +211,14 @@ private fun PageList(
204211 }
205212
206213 @Composable
207- private fun SearchField(query: TextFieldValue, onQuery: (TextFieldValue) -> Unit) {
214+ private fun SearchField(query: TextFieldValue, onQuery: (TextFieldValue) -> Unit, phone: Boolean = false) {
208215 val p = palette
209216 Row(
210- Modifier.fillMaxWidth().height(36.dp).well(p, RoundedCornerShape(12.dp)).padding(start = 10.dp, end = 4.dp),
217+ Modifier
218+ .fillMaxWidth()
219+ .height(if (phone) 44.dp else 36.dp)
220+ .well(p, RoundedCornerShape(if (phone) 16.dp else 12.dp))
221+ .padding(start = if (phone) 14.dp else 10.dp, end = 4.dp),
211222 horizontalArrangement = Arrangement.spacedBy(8.dp),
212223 verticalAlignment = Alignment.CenterVertically,
213224 ) {
@@ -287,7 +298,19 @@ private fun PageView(
287298 ) {
288299 val p = palette
289300 val prefs = app.prefs
290- var restoring by remember { mutableStateOf(false) }
301+ if (LocalPhone.current && back != null) {
302+ PhoneScaffold(
303+ bar = {
304+ BarBack(back)
305+ BarTitle(page.label)
306+ if (page == SettingsPage.General) RestoreDefaults(prefs, phone = true)
307+ },
308+ modifier = modifier,
309+ ) {
310+ PageBody(app, nav, page, scope, target, narrow = true, Modifier.weight(1f))
311+ }
312+ return
313+ }
291314 BoxWithConstraints(modifier) {
292315 val narrow = maxWidth < 480.dp
293316 Column(Modifier.fillMaxSize()) {
@@ -309,57 +332,216 @@ private fun PageView(
309332 )
310333 Txt("/", color = p.ink3, size = 14.sp)
311334 Txt(page.label, Modifier.weight(1f), color = p.ink, size = 14.sp, weight = FontWeight.SemiBold, maxLines = 1)
312- if (page == SettingsPage.General) {
313- val changed = differingDefaults(prefs)
314- Cap(
315- label = if (narrow) null else "Restore device defaults",
316- icon = Lucide.RotateCcw,
317- kind = CapKind.Ghost,
318- size = CapSize.Compact,
319- enabled = changed.isNotEmpty(),
320- ) { restoring = true }
321- if (restoring) {
322- ConfirmDialog(
323- title = "Restore default settings?",
324- message = "This will reset: ${changed.joinToString(", ")}.",
325- confirm = "Restore defaults",
326- danger = true,
327- onDismiss = { restoring = false },
328- onConfirm = { restoreDefaults(prefs) },
329- )
330- }
331- }
335+ if (page == SettingsPage.General) RestoreDefaults(prefs, phone = false, narrow = narrow)
332336 }
333337 Rule()
334- val scroll = remember(page) { ScrollState(0) }
335- Box(
336- Modifier.weight(1f).fillMaxWidth().verticalScroll(scroll),
337- contentAlignment = Alignment.TopCenter,
338- ) {
339- val width = if (page == SettingsPage.Providers || page == SettingsPage.Connections) WideWidth else ReadableWidth
340- Column(
341- Modifier
342- .widthIn(max = width)
343- .fillMaxWidth()
344- .padding(start = if (narrow) 12.dp else 24.dp, end = if (narrow) 12.dp else 24.dp, top = 24.dp, bottom = 48.dp),
345- verticalArrangement = Arrangement.spacedBy(32.dp),
346- ) {
347- CompositionLocalProvider(LocalTarget provides target) {
348- val shown = shown(app, scope)
349- if (page.scoped) ScopeSentence(app, scope)
350- when (page) {
351- SettingsPage.General -> GeneralPage(app, shown)
352- SettingsPage.Appearance -> AppearancePage(prefs)
353- SettingsPage.Providers -> ProvidersPage(app)
354- SettingsPage.SourceControl -> SourceControlPage(shown)
355- SettingsPage.Storage -> StoragePage(shown)
356- SettingsPage.Connections -> ConnectionsPage(app, nav)
357- SettingsPage.Archive -> ArchivePage(app)
358- SettingsPage.About -> AboutPage(app)
359- }
360- }
338+ PageBody(app, nav, page, scope, target, narrow, Modifier.weight(1f))
339+ }
340+ }
341+ }
342+
343+ /** General's "Restore device defaults", and the confirmation it asks for. */
344+ @Composable
345+ private fun RestoreDefaults(prefs: DevicePrefs, phone: Boolean, narrow: Boolean = true) {
346+ var restoring by remember { mutableStateOf(false) }
347+ val changed = differingDefaults(prefs)
348+ Cap(
349+ label = if (narrow) null else "Restore device defaults",
350+ icon = Lucide.RotateCcw,
351+ kind = CapKind.Ghost,
352+ size = if (phone) CapSize.Large else CapSize.Compact,
353+ iconColor = if (phone && changed.isNotEmpty()) palette.ink else null,
354+ enabled = changed.isNotEmpty(),
355+ ) { restoring = true }
356+ if (restoring) {
357+ ConfirmDialog(
358+ title = "Restore default settings?",
359+ message = "This will reset: ${changed.joinToString(", ")}.",
360+ confirm = "Restore defaults",
361+ danger = true,
362+ onDismiss = { restoring = false },
363+ onConfirm = { restoreDefaults(prefs) },
364+ )
365+ }
366+ }
367+
368+ /** A page's settings, scrolling. */
369+ @Composable
370+ private fun PageBody(
371+ app: AppState,
372+ nav: Nav,
373+ page: SettingsPage,
374+ scope: SettingsScope,
375+ target: Target?,
376+ narrow: Boolean,
377+ modifier: Modifier,
378+ ) {
379+ val scroll = remember(page) { ScrollState(0) }
380+ Box(
381+ modifier.fillMaxWidth().verticalScroll(scroll),
382+ contentAlignment = Alignment.TopCenter,
383+ ) {
384+ val width = if (page == SettingsPage.Providers || page == SettingsPage.Connections) WideWidth else ReadableWidth
385+ Column(
386+ Modifier
387+ .widthIn(max = width)
388+ .fillMaxWidth()
389+ .padding(start = if (narrow) 12.dp else 24.dp, end = if (narrow) 12.dp else 24.dp, top = 24.dp, bottom = 48.dp),
390+ verticalArrangement = Arrangement.spacedBy(32.dp),
391+ ) {
392+ CompositionLocalProvider(LocalTarget provides target) {
393+ val shown = shown(app, scope)
394+ if (page.scoped) ScopeSentence(app, scope)
395+ when (page) {
396+ SettingsPage.General -> GeneralPage(app, shown)
397+ SettingsPage.Appearance -> AppearancePage(app.prefs)
398+ SettingsPage.Providers -> ProvidersPage(app)
399+ SettingsPage.SourceControl -> SourceControlPage(shown)
400+ SettingsPage.Storage -> StoragePage(shown)
401+ SettingsPage.Connections -> ConnectionsPage(app, nav)
402+ SettingsPage.Archive -> ArchivePage(app)
403+ SettingsPage.About -> AboutPage(app)
404+ }
405+ }
406+ }
407+ }
408+ }
409+
410+ // ---- the phone's hub -------------------------------------------------------------
411+
412+ /** The hub's groups, in order: a title and its pages. */
413+ private val HubGroups = listOf(
414+ "Connections" to listOf(SettingsPage.Connections),
415+ "Interface" to listOf(SettingsPage.Appearance),
416+ "Projects & threads" to listOf(SettingsPage.General, SettingsPage.SourceControl, SettingsPage.Storage, SettingsPage.Archive),
417+ "Agents" to listOf(SettingsPage.Providers),
418+ "App" to listOf(SettingsPage.About),
419+ )
420+
421+ /** What a page's row says under its name, when there's something to say. */
422+ private fun hubDetail(app: AppState, page: SettingsPage, dark: Boolean): String? = when (page) {
423+ SettingsPage.Connections -> when (app.hosts.size) {
424+ 0 -> "Pair a computer"
425+ 1 -> app.hosts.first().name
426+ else -> app.hosts.joinToString(", ") { it.name }
427+ }
428+ SettingsPage.Appearance -> {
429+ val prefs = app.prefs
430+ val theme = Themes.theme(if (dark) prefs.darkTheme else prefs.lightTheme)?.name
431+ val mode = when (prefs.themeMode) {
432+ "light" -> "Light"
433+ "dark" -> "Dark"
434+ else -> "System"
435+ }
436+ listOfNotNull(theme, mode).joinToString(" · ")
437+ }
438+ SettingsPage.General -> "Defaults for new threads, behavior"
439+ SettingsPage.SourceControl -> "Branch names, commit and PR text"
440+ SettingsPage.Storage -> "Worktrees and their cleanup"
441+ SettingsPage.Archive -> "Settled threads"
442+ SettingsPage.Providers -> "Agents and their models"
443+ SettingsPage.About -> null
444+ }
445+
446+ /** The phone's settings: search, then the pages as grouped rows on raised cards. */
447+ @Composable
448+ private fun Hub(
449+ app: AppState,
450+ nav: Nav,
451+ query: TextFieldValue,
452+ onQuery: (TextFieldValue) -> Unit,
453+ open: (SettingsPage?) -> Unit,
454+ jump: (SearchItem) -> Unit,
455+ ) {
456+ val p = palette
457+ val context = LocalContext.current
458+ val searching = query.text.isNotBlank()
459+ val results = searchSettings(query.text)
460+ PhoneScaffold(
461+ bar = {
462+ BarBack { if (!nav.back()) nav.home() }
463+ BarTitle("Settings")
464+ },
465+ ) {
466+ Column(
467+ Modifier.weight(1f).verticalScroll(rememberScrollState()).padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 32.dp),
468+ ) {
469+ SearchField(query, onQuery, phone = true)
470+ when {
471+ searching && results.isEmpty() -> Box(Modifier.fillMaxWidth().padding(vertical = 32.dp), contentAlignment = Alignment.Center) {
472+ Txt("No settings found", color = p.ink3, size = 14.sp)
473+ }
474+ searching -> {
475+ Spacer(Modifier.height(16.dp))
476+ HubCard(results.map { item -> { HubRow(item.page.icon, item.title, item.page.label) { jump(item) } } })
477+ }
478+ else -> for ((title, pages) in HubGroups) {
479+ GroupLabel(title)
480+ HubCard(
481+ pages.map { page ->
482+ {
483+ val trailing = when (page) {
484+ SettingsPage.Connections -> app.hosts.size.toString()
485+ SettingsPage.Archive -> settledCount(app).takeIf { it > 0 }?.toString()
486+ SettingsPage.About -> "v${appVersion(context)}"
487+ else -> null
488+ }
489+ HubRow(page.icon, page.label, hubDetail(app, page, p.dark), trailing) { open(page) }
490+ }
491+ },
492+ )
361493 }
362494 }
363495 }
364496 }
365497 }
498+
499+ /** A group's title over its card, in the engraved voice. */
500+ @Composable
501+ private fun GroupLabel(title: String) {
502+ Txt(
503+ title.uppercase(),
504+ Modifier.padding(start = 16.dp, top = 28.dp, bottom = 10.dp),
505+ color = palette.engrave,
506+ size = 11.sp,
507+ family = Mono,
508+ weight = FontWeight.Medium,
509+ maxLines = 1,
510+ )
511+ }
512+
513+ /** Rows on a raised card, with hairlines between them under the text. */
514+ @Composable
515+ private fun HubCard(rows: List<@Composable () -> Unit>) {
516+ val p = palette
517+ val shape = RoundedCornerShape(24.dp)
518+ Column(Modifier.fillMaxWidth().raised(p, shape).clip(shape)) {
519+ rows.forEachIndexed { ix, row ->
520+ row()
521+ if (ix + 1 < rows.size) Box(Modifier.padding(start = 54.dp, end = 16.dp)) { Rule() }
522+ }
523+ }
524+ }
525+
526+ /** A page on the hub: its icon, name and what's set, and the way in. */
527+ @Composable
528+ private fun HubRow(icon: Lucide, title: String, detail: String?, trailing: String? = null, onClick: () -> Unit) {
529+ val p = palette
530+ Row(
531+ Modifier
532+ .fillMaxWidth()
533+ .defaultMinSize(minHeight = 60.dp)
534+ .clickable(onClick = onClick)
535+ .padding(start = 18.dp, end = 14.dp, top = 10.dp, bottom = 10.dp),
536+ horizontalArrangement = Arrangement.spacedBy(14.dp),
537+ verticalAlignment = Alignment.CenterVertically,
538+ ) {
539+ LucideIcon(icon, size = 20.dp, tint = p.ink2)
540+ Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
541+ Txt(title, color = p.ink, size = 16.sp, weight = FontWeight.Medium, maxLines = 1)
542+ if (detail != null) Txt(detail, color = p.ink3, size = 13.sp, maxLines = 1)
543+ }
544+ if (trailing != null) Txt(trailing, color = p.ink3, size = 13.sp, family = Mono, maxLines = 1)
545+ LucideIcon(Lucide.ChevronRight, size = 16.dp, tint = p.ink3.copy(alpha = 0.7f))
546+ }
547+ }
+84−0android/app/src/main/java/cc/vectrum/code/ui/sidebar/Home.ktaddedView File
@@ -0,0 +1,84 @@
1+ package cc.vectrum.code.ui.sidebar
2+
3+ import androidx.compose.foundation.Image
4+ import androidx.compose.foundation.layout.Arrangement
5+ import androidx.compose.foundation.layout.Box
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.padding
10+ import androidx.compose.foundation.layout.size
11+ import androidx.compose.runtime.Composable
12+ import androidx.compose.ui.Alignment
13+ import androidx.compose.ui.Modifier
14+ import androidx.compose.ui.graphics.ColorFilter
15+ import androidx.compose.ui.res.painterResource
16+ import androidx.compose.ui.text.font.FontWeight
17+ import androidx.compose.ui.unit.dp
18+ import androidx.compose.ui.unit.sp
19+ import cc.vectrum.code.R
20+ import cc.vectrum.code.data.AppState
21+ import cc.vectrum.code.theme.Cap
22+ import cc.vectrum.code.theme.CapKind
23+ import cc.vectrum.code.theme.CapSize
24+ import cc.vectrum.code.theme.Tag
25+ import cc.vectrum.code.theme.Txt
26+ import cc.vectrum.code.theme.palette
27+ import cc.vectrum.code.ui.BarAction
28+ import cc.vectrum.code.ui.EmptyState
29+ import cc.vectrum.code.ui.Lucide
30+ import cc.vectrum.code.ui.Nav
31+ import cc.vectrum.code.ui.PhoneScaffold
32+ import cc.vectrum.code.ui.Screen
33+
34+ // A phone's home: the brand, search and settings in the bar, and every
35+ // paired computer's threads on the sheet, as the sidebar lists them, with
36+ // New thread at the foot. Before a computer is paired, the way to pair one.
37+
38+ @Composable
39+ fun HomeScreen(app: AppState, nav: Nav) {
40+ val p = palette
41+ PhoneScaffold(
42+ bar = {
43+ Row(
44+ Modifier.weight(1f).padding(start = 12.dp),
45+ horizontalArrangement = Arrangement.spacedBy(10.dp),
46+ verticalAlignment = Alignment.CenterVertically,
47+ ) {
48+ Image(
49+ painterResource(R.drawable.vectrum_logo),
50+ contentDescription = null,
51+ modifier = Modifier.size(22.dp),
52+ colorFilter = ColorFilter.tint(p.ink),
53+ )
54+ Txt("Vectrum", color = p.ink, size = 22.sp, weight = FontWeight.SemiBold, maxLines = 1)
55+ Tag("Code")
56+ }
57+ // The desktop's Ctrl+K.
58+ BarAction(Lucide.Search) { nav.palette = true }
59+ BarAction(Lucide.Settings) { nav.go(Screen.Settings("")) }
60+ },
61+ ) {
62+ if (app.hosts.isEmpty()) {
63+ EmptyState(
64+ "No computer paired",
65+ "Pair a computer running Vectrum Code to work on its projects and threads from here.",
66+ ) {
67+ Cap(label = "Pair a computer", icon = Lucide.Plus, kind = CapKind.Primary, size = CapSize.Large) { nav.go(Screen.Pair) }
68+ }
69+ } else {
70+ Box(Modifier.weight(1f).fillMaxWidth()) {
71+ Sidebar(app, nav, Modifier.fillMaxSize(), home = true)
72+ if (app.hosts.any { it.projects.isNotEmpty() }) {
73+ Cap(
74+ Modifier.align(Alignment.BottomEnd).padding(20.dp),
75+ label = "New thread",
76+ icon = Lucide.SquarePen,
77+ kind = CapKind.Primary,
78+ size = CapSize.Large,
79+ ) { nav.go(Screen.NewThread()) }
80+ }
81+ }
82+ }
83+ }
84+ }
+250−0android/app/src/main/java/cc/vectrum/code/ui/sidebar/HomeList.ktaddedView File
@@ -0,0 +1,250 @@
1+ package cc.vectrum.code.ui.sidebar
2+
3+ import androidx.compose.foundation.background
4+ import androidx.compose.foundation.combinedClickable
5+ import androidx.compose.foundation.interaction.MutableInteractionSource
6+ import androidx.compose.foundation.interaction.collectIsPressedAsState
7+ import androidx.compose.foundation.layout.Arrangement
8+ import androidx.compose.foundation.layout.Box
9+ import androidx.compose.foundation.layout.Column
10+ import androidx.compose.foundation.layout.Row
11+ import androidx.compose.foundation.layout.defaultMinSize
12+ import androidx.compose.foundation.layout.fillMaxSize
13+ import androidx.compose.foundation.layout.fillMaxWidth
14+ import androidx.compose.foundation.layout.padding
15+ import androidx.compose.foundation.layout.size
16+ import androidx.compose.foundation.layout.widthIn
17+ import androidx.compose.foundation.rememberScrollState
18+ import androidx.compose.foundation.shape.RoundedCornerShape
19+ import androidx.compose.foundation.verticalScroll
20+ import androidx.compose.runtime.Composable
21+ import androidx.compose.runtime.getValue
22+ import androidx.compose.runtime.mutableIntStateOf
23+ import androidx.compose.runtime.mutableStateOf
24+ import androidx.compose.runtime.remember
25+ import androidx.compose.runtime.setValue
26+ import androidx.compose.ui.Alignment
27+ import androidx.compose.ui.Modifier
28+ import androidx.compose.ui.draw.alpha
29+ import androidx.compose.ui.draw.clip
30+ import androidx.compose.ui.text.font.FontWeight
31+ import androidx.compose.ui.unit.dp
32+ import androidx.compose.ui.unit.sp
33+ import cc.vectrum.code.data.AppState
34+ import cc.vectrum.code.theme.Mono
35+ import cc.vectrum.code.theme.Rule
36+ import cc.vectrum.code.theme.Txt
37+ import cc.vectrum.code.theme.palette
38+ import cc.vectrum.code.theme.raised
39+ import cc.vectrum.code.ui.Lucide
40+ import cc.vectrum.code.ui.LucideIcon
41+ import cc.vectrum.code.ui.Nav
42+ import cc.vectrum.code.util.relativeTime
43+
44+ // The inbox on a phone's home, laid out as the settings hub is: the pinned
45+ // and active threads on one raised card, then the Working, Snoozed and
46+ // Settled shelves as engraved labels over cards of their own. A row is two
47+ // lines beside its project's tile: the title with the time or status, then
48+ // its project and branch, and the computer and agent it runs on. A long
49+ // press opens the thread menu.
50+
51+ /** How many settled threads show at first, and how many more each "Show more" adds. */
52+ private const val SETTLED_PAGE = 10
53+ private const val SETTLED_MORE = 25
54+
55+ @Composable
56+ internal fun HomeList(
57+ app: AppState,
58+ nav: Nav,
59+ scope: ProjectKey?,
60+ hasProjects: Boolean,
61+ modifier: Modifier,
62+ onMenu: (Entry) -> Unit,
63+ onAddProject: () -> Unit,
64+ ) {
65+ val now by rememberNow(30_000)
66+ var workingOpen by remember { mutableStateOf(true) }
67+ var snoozedOpen by remember { mutableStateOf(false) }
68+ var settledOpen by remember { mutableStateOf(false) }
69+ var settledShown by remember { mutableIntStateOf(SETTLED_PAGE) }
70+ val lists = inbox(app, scope, app.prefs.bool("working_section"), now)
71+ val compact = app.prefs.bool("compact_threads")
72+
73+ @Composable
74+ fun row(entry: Entry, shelf: HomeShelf?) =
75+ HomeRow(entry, shelf, now, compact, onOpen = { nav.openThread(entry.ref) }, onMenu = { onMenu(entry) })
76+
77+ // Room at the foot for the New thread button.
78+ Column(modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(start = 12.dp, end = 12.dp, top = 4.dp, bottom = 96.dp)) {
79+ HostStrip(app, nav)
80+ if (lists.isEmpty) InboxEmpty(app, nav, scope, hasProjects, onAddProject)
81+ val main = lists.pinned + lists.active
82+ if (main.isNotEmpty()) HomeCard(main.map { entry -> { row(entry, null) } })
83+ Shelf("Working", lists.working, workingOpen, { workingOpen = !workingOpen }) { row(it, HomeShelf.Working) }
84+ Shelf("Snoozed", lists.snoozed, snoozedOpen, { snoozedOpen = !snoozedOpen }) { row(it, HomeShelf.Snoozed) }
85+ Shelf(
86+ "Settled",
87+ lists.settled,
88+ settledOpen,
89+ {
90+ settledOpen = !settledOpen
91+ if (!settledOpen) settledShown = SETTLED_PAGE
92+ },
93+ limit = settledShown,
94+ more = { settledShown += SETTLED_MORE },
95+ ) { row(it, HomeShelf.Settled) }
96+ }
97+ }
98+
99+ /** Which shelf a row is on, for what its corner says. */
100+ internal enum class HomeShelf { Working, Snoozed, Settled }
101+
102+ /** A shelf: its label and count, which opens it, and its threads on a card while it's open. */
103+ @Composable
104+ private fun Shelf(
105+ label: String,
106+ threads: List<Entry>,
107+ open: Boolean,
108+ onToggle: () -> Unit,
109+ limit: Int = Int.MAX_VALUE,
110+ more: (() -> Unit)? = null,
111+ row: @Composable (Entry) -> Unit,
112+ ) {
113+ if (threads.isEmpty()) return
114+ val p = palette
115+ Row(
116+ Modifier
117+ .fillMaxWidth()
118+ .padding(top = 20.dp, bottom = 8.dp)
119+ .pressTint(10.dp, onToggle)
120+ .padding(horizontal = 16.dp, vertical = 8.dp),
121+ horizontalArrangement = Arrangement.spacedBy(8.dp),
122+ verticalAlignment = Alignment.CenterVertically,
123+ ) {
124+ Txt(label.uppercase(), color = p.engrave, size = 11.sp, family = Mono, weight = FontWeight.Medium, maxLines = 1)
125+ Txt("${threads.size}", Modifier.weight(1f), color = p.ink3, size = 11.sp, family = Mono, maxLines = 1)
126+ LucideIcon(if (open) Lucide.ChevronUp else Lucide.ChevronDown, size = 14.dp, tint = p.ink3)
127+ }
128+ if (!open) return
129+ val shown = threads.take(limit)
130+ val hidden = threads.size - shown.size
131+ val rows = shown.map { entry -> @Composable { row(entry) } }
132+ HomeCard(
133+ if (hidden > 0 && more != null) {
134+ rows + @Composable {
135+ Box(
136+ Modifier.fillMaxWidth().pressTint(0.dp, more).padding(vertical = 14.dp),
137+ contentAlignment = Alignment.Center,
138+ ) { Txt("Show ${minOf(hidden, SETTLED_MORE)} more", color = p.ink2, size = 13.5.sp, weight = FontWeight.Medium) }
139+ }
140+ } else {
141+ rows
142+ },
143+ )
144+ }
145+
146+ /** Rows on a raised card, with hairlines between them under the text, as the settings hub's. */
147+ @Composable
148+ private fun HomeCard(rows: List<@Composable () -> Unit>) {
149+ val p = palette
150+ val shape = RoundedCornerShape(24.dp)
151+ Column(Modifier.fillMaxWidth().raised(p, shape).clip(shape)) {
152+ rows.forEachIndexed { ix, row ->
153+ row()
154+ if (ix + 1 < rows.size) Box(Modifier.padding(start = 66.dp, end = 16.dp)) { Rule() }
155+ }
156+ }
157+ }
158+
159+ /** A thread: its project's tile, the title with the time or status, and the project, branch and agent under it. */
160+ @Composable
161+ private fun HomeRow(
162+ entry: Entry,
163+ shelf: HomeShelf?,
164+ now: Long,
165+ compact: Boolean,
166+ onOpen: () -> Unit,
167+ onMenu: () -> Unit,
168+ ) {
169+ val p = palette
170+ val summary = entry.summary
171+ val unread = Activity.isUnread(entry)
172+ val status = topStatus(entry, unread, now)
173+ val settled = shelf == HomeShelf.Settled
174+ val recedes = settled || recedes(false, status, unread)
175+ val detail = entry.detail
176+ val tasks = detail?.backgroundTasks?.size ?: 0
177+ val interaction = remember { MutableInteractionSource() }
178+ val pressed by interaction.collectIsPressedAsState()
179+ Row(
180+ Modifier
181+ .fillMaxWidth()
182+ .defaultMinSize(minHeight = if (compact) 52.dp else 68.dp)
183+ .then(if (pressed) Modifier.background(p.rowHover) else Modifier)
184+ .combinedClickable(interactionSource = interaction, indication = null, onLongClick = onMenu, onClick = onOpen)
185+ .padding(start = 14.dp, end = 16.dp, top = 12.dp, bottom = 12.dp),
186+ horizontalArrangement = Arrangement.spacedBy(14.dp),
187+ verticalAlignment = Alignment.CenterVertically,
188+ ) {
189+ Box(Modifier.alpha(if (settled) 0.55f else 1f)) { ProjectTile(entry.initials, entry.tint) }
190+ Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
191+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
192+ Txt(
193+ summary.title.ifBlank { "New thread" },
194+ Modifier.weight(1f),
195+ // Only settled threads dim; weight tells the ones with news from the rest.
196+ color = if (settled) p.ink2 else p.ink,
197+ size = 15.5.sp,
198+ weight = if (recedes) FontWeight.Normal else FontWeight.Medium,
199+ maxLines = 1,
200+ )
201+ if (summary.pinned) LucideIcon(Lucide.Pin, size = 13.dp, tint = p.ink3)
202+ when {
203+ shelf == HomeShelf.Snoozed -> Txt(
204+ summary.snoozedUntil?.let { wakeCountdown(it, now) } ?: "now",
205+ color = p.blueInk,
206+ size = 12.sp,
207+ maxLines = 1,
208+ )
209+ !settled && status != null -> StatusLabel(entry, status)
210+ else -> Txt(
211+ relativeTime(if (settled) summary.updated else maxOf(summary.lastMessage, summary.created), now),
212+ color = p.ink3,
213+ size = 12.sp,
214+ family = Mono,
215+ maxLines = 1,
216+ )
217+ }
218+ }
219+ if (!compact) {
220+ Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
221+ val branch = entry.branch
222+ val where = listOfNotNull(entry.projectName, branch?.let { middleTruncate(it, 24) }).joinToString(" · ")
223+ if (entry.inWorktree) LucideIcon(Lucide.FolderGit2, size = 13.dp, tint = p.ink3)
224+ Txt(where, Modifier.weight(1f), color = p.ink3, size = 13.sp, maxLines = 1)
225+ if (tasks > 0) TerminalMark()
226+ detail?.pullRequest?.let { PrBadge(it) }
227+ // The computer it runs on, and its agent.
228+ LucideIcon(Lucide.Server, size = 13.dp, tint = p.ink3)
229+ Txt(entry.host.name, Modifier.widthIn(max = 96.dp), color = p.ink3, size = 13.sp, maxLines = 1)
230+ Box(Modifier.alpha(0.7f)) { ProviderIcon(summary.provider, 13.dp) }
231+ }
232+ }
233+ }
234+ }
235+ }
236+
237+ /** A project's initials on a tinted tile, the size of the settings hub's icons' slot. */
238+ @Composable
239+ private fun ProjectTile(initials: String, tint: Int) {
240+ val p = palette
241+ val color = p.tints[Math.floorMod(tint, p.tints.size)]
242+ Box(
243+ Modifier
244+ .size(36.dp)
245+ .background(color.copy(alpha = color.alpha * if (p.dark) 0.2f else 0.13f), RoundedCornerShape(11.dp)),
246+ contentAlignment = Alignment.Center,
247+ ) {
248+ Txt(initials, color = color, size = 12.5.sp, weight = FontWeight.Bold, family = Mono, maxLines = 1)
249+ }
250+ }
+58−16android/app/src/main/java/cc/vectrum/code/ui/sidebar/Sidebar.ktView File
@@ -35,7 +35,6 @@ import androidx.compose.ui.focus.FocusRequester
3535 import androidx.compose.ui.focus.focusRequester
3636 import androidx.compose.ui.graphics.ColorFilter
3737 import androidx.compose.ui.graphics.SolidColor
38- import androidx.compose.ui.platform.LocalConfiguration
3938 import androidx.compose.ui.platform.LocalFocusManager
4039 import androidx.compose.ui.res.painterResource
4140 import androidx.compose.ui.text.TextStyle
@@ -82,9 +81,13 @@ import cc.vectrum.code.util.tintFor
8281 private const val SETTLED_PAGE = 10
8382 private const val SETTLED_MORE = 25
8483
85- /** The sidebar: on a phone, the drawer; on a tablet, beside the sheet. */
84+ /**
85+ * The sidebar beside the sheet, on a tablet. On a phone [home] has it on the
86+ * home screen's sheet instead, under the screen's bar, which stands in for
87+ * its brand row; the hosts' connections show above the threads there.
88+ */
8689 @Composable
87- fun Sidebar(app: AppState, nav: Nav, modifier: Modifier = Modifier) {
90+ fun Sidebar(app: AppState, nav: Nav, modifier: Modifier = Modifier, home: Boolean = false) {
8891 Activity.Track(app, nav)
8992 var query by remember { mutableStateOf("") }
9093 var scope by remember { mutableStateOf<ProjectKey?>(null) }
@@ -97,10 +100,11 @@ fun Sidebar(app: AppState, nav: Nav, modifier: Modifier = Modifier) {
97100 val hasProjects = app.hosts.any { it.projects.isNotEmpty() }
98101
99102 Column(modifier) {
100- Chrome(nav, onBrand = { scope = null })
103+ if (!home) Chrome(nav, onBrand = { scope = null })
101104 Header(
102105 app = app,
103106 nav = nav,
107+ home = home,
104108 query = query,
105109 onQuery = { query = it },
106110 scope = validScope,
@@ -116,6 +120,8 @@ fun Sidebar(app: AppState, nav: Nav, modifier: Modifier = Modifier) {
116120 val onMenu = { entry: Entry -> menuFor = entry.ref }
117121 if (query.isNotBlank()) {
118122 SearchResults(app, nav, validScope, query, Modifier.weight(1f), onMenu)
123+ } else if (home) {
124+ HomeList(app, nav, validScope, hasProjects, Modifier.weight(1f), onMenu, onAddProject = { addingProject = true })
119125 } else {
120126 InboxList(
121127 app,
@@ -128,7 +134,7 @@ fun Sidebar(app: AppState, nav: Nav, modifier: Modifier = Modifier) {
128134 onAddProject = { addingProject = true },
129135 )
130136 }
131- Footer(app, nav)
137+ if (!home) Footer(app, nav)
132138 }
133139
134140 unpinning?.let { entry ->
@@ -149,19 +155,15 @@ fun Sidebar(app: AppState, nav: Nav, modifier: Modifier = Modifier) {
149155 AddProjectSheet(app, addHost, addingProject) { addingProject = false }
150156 }
151157
152- /** The brand row: the drawer's close button on a phone, the mark, "Vectrum" and `CODE`, and the command palette. */
158+ /** The brand row: the mark, "Vectrum" and `CODE`, and the command palette. */
153159 @Composable
154160 private fun Chrome(nav: Nav, onBrand: () -> Unit) {
155161 val p = palette
156- val phone = LocalConfiguration.current.screenWidthDp < 840
157162 Row(
158- Modifier.fillMaxWidth().height(52.dp).padding(start = 10.dp, end = 12.dp),
163+ Modifier.fillMaxWidth().height(52.dp).padding(start = 16.dp, end = 12.dp),
159164 horizontalArrangement = Arrangement.spacedBy(8.dp),
160165 verticalAlignment = Alignment.CenterVertically,
161166 ) {
162- if (phone) {
163- Cap(icon = Lucide.PanelLeftClose, kind = CapKind.Ghost, size = CapSize.Compact) { nav.sidebarOpen = false }
164- }
165167 Row(
166168 Modifier.height(28.dp).clickable(interactionSource = null, indication = null, onClick = onBrand),
167169 horizontalArrangement = Arrangement.spacedBy(8.dp),
@@ -187,6 +189,7 @@ private fun Chrome(nav: Nav, onBrand: () -> Unit) {
187189 private fun Header(
188190 app: AppState,
189191 nav: Nav,
192+ home: Boolean,
190193 query: String,
191194 onQuery: (String) -> Unit,
192195 scope: ProjectKey?,
@@ -200,14 +203,14 @@ private fun Header(
200203 val focus = remember { FocusRequester() }
201204 val focusManager = LocalFocusManager.current
202205 Row(
203- Modifier.fillMaxWidth().padding(start = 8.dp, end = 8.dp, bottom = 8.dp),
206+ Modifier.fillMaxWidth().padding(start = if (home) 12.dp else 8.dp, end = 8.dp, top = if (home) 12.dp else 0.dp, bottom = 8.dp),
204207 horizontalArrangement = Arrangement.spacedBy(4.dp),
205208 verticalAlignment = Alignment.CenterVertically,
206209 ) {
207210 Row(
208211 Modifier
209212 .weight(1f)
210- .height(32.dp)
213+ .height(if (home) 40.dp else 32.dp)
211214 .well(p, RoundedCornerShape(12.dp))
212215 .clickable(interactionSource = null, indication = null) { focus.requestFocus() }
213216 .padding(start = 10.dp, end = 6.dp),
@@ -251,8 +254,11 @@ private fun Header(
251254 }
252255 Cap(icon = Lucide.FolderPlus, kind = CapKind.Ghost, size = CapSize.Compact, onClick = onAddProject)
253256 }
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())
257+ // Home has its New thread button at the foot.
258+ if (!home) {
259+ Cap(icon = Lucide.SquarePen, kind = CapKind.Ghost, size = CapSize.Compact, enabled = hasProjects) {
260+ nav.go(if (scope != null) Screen.NewThread(scope.host, scope.path) else Screen.NewThread())
261+ }
256262 }
257263 }
258264 }
@@ -390,8 +396,10 @@ private fun ColumnScope.Shelf(
390396
391397 /** What the inbox says when there's nothing in it. */
392398 @Composable
393- private fun InboxEmpty(app: AppState, nav: Nav, scope: ProjectKey?, hasProjects: Boolean, onAddProject: () -> Unit) {
399+ internal fun InboxEmpty(app: AppState, nav: Nav, scope: ProjectKey?, hasProjects: Boolean, onAddProject: () -> Unit) {
394400 val p = palette
401+ // Until a computer is connected there's nothing to say about its projects; its light says why.
402+ if (app.hosts.isNotEmpty() && app.hosts.none { it.connected }) return
395403 val message = when {
396404 app.hosts.isEmpty() -> "Not paired with a desktop yet"
397405 !hasProjects -> "No projects yet"
@@ -443,6 +451,40 @@ private fun Footer(app: AppState, nav: Nav) {
443451 }
444452 }
445453
454+ /** On home, the paired computers: each one's light, name and connection, opening Connections. */
455+ @Composable
456+ internal fun HostStrip(app: AppState, nav: Nav) {
457+ val p = palette
458+ Row(
459+ Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(start = 4.dp, end = 4.dp, bottom = 12.dp),
460+ horizontalArrangement = Arrangement.spacedBy(8.dp),
461+ verticalAlignment = Alignment.CenterVertically,
462+ ) {
463+ for (host in app.hosts) {
464+ val status = when (val status = host.status) {
465+ is ConnectionStatus.Connected -> "Connected"
466+ ConnectionStatus.Connecting -> "Connecting…"
467+ is ConnectionStatus.Denied -> status.reason
468+ is ConnectionStatus.Offline -> if (host.saved.enabled) "Offline · retrying" else "Turned off"
469+ }
470+ Row(
471+ Modifier
472+ .height(36.dp)
473+ .well(p, CircleShape)
474+ .pressTint(18.dp) { nav.go(Screen.Settings("connections")) }
475+ .padding(start = 12.dp, end = 14.dp),
476+ horizontalArrangement = Arrangement.spacedBy(8.dp),
477+ verticalAlignment = Alignment.CenterVertically,
478+ ) {
479+ StatusLed(hostLed(host), pulse = host.status == ConnectionStatus.Connecting)
480+ LucideIcon(Lucide.Server, size = 14.dp, tint = p.ink2)
481+ Txt(host.name, color = p.ink, size = 13.5.sp, weight = FontWeight.Medium, maxLines = 1)
482+ Txt(status, color = p.ink3, size = 13.sp, maxLines = 1)
483+ }
484+ }
485+ }
486+ }
487+
446488 /** A host's light: lit while connected, amber while connecting, red when it's offline or turned us away. */
447489 fun hostLed(host: HostState): Led = when {
448490 !host.saved.enabled -> Led.Off
+16−13android/app/src/main/java/cc/vectrum/code/ui/sidebar/ThreadRows.ktView File
@@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.defaultMinSize
1919 import androidx.compose.foundation.layout.fillMaxWidth
2020 import androidx.compose.foundation.layout.height
2121 import androidx.compose.foundation.layout.padding
22+ import androidx.compose.foundation.layout.widthIn
2223 import androidx.compose.foundation.shape.RoundedCornerShape
2324 import androidx.compose.runtime.Composable
2425 import androidx.compose.runtime.getValue
@@ -62,7 +63,7 @@ private val ClaudeOrange = Color(0xffd97757)
6263 enum class SlimKind { Card, Snoozed, Settled }
6364
6465 /** 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+ internal fun recedes(active: Boolean, status: TopStatus?, unread: Boolean): Boolean =
6667 !active && status != TopStatus.Input &&
6768 (status == TopStatus.Working || status == TopStatus.Waiting || ((status == null || status == TopStatus.Approval) && !unread))
6869
@@ -153,24 +154,26 @@ fun CardRow(
153154 Txt(summary.title, color = titleInk, size = 14.sp, weight = weight, maxLines = 1)
154155 }
155156 Row(
156- Modifier.padding(top = 2.dp).height(16.dp),
157+ Modifier.fillMaxWidth().padding(top = 2.dp).height(16.dp),
157158 horizontalArrangement = Arrangement.spacedBy(6.dp),
158159 verticalAlignment = Alignment.CenterVertically,
159160 ) {
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)
161+ // The branch (or host) takes the room left, so the marks after it sit at the end.
162+ Row(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
163+ if (branch != null) {
164+ if (entry.inWorktree) LucideIcon(Lucide.FolderGit2, size = 12.dp, tint = p.ink3.copy(alpha = 0.7f))
165+ Txt(middleTruncate(branch, 32), Modifier.weight(1f, fill = false), color = p.ink3, size = 11.sp, family = Mono, maxLines = 1)
166+ } else {
167+ LucideIcon(Lucide.Server, size = 12.dp, tint = p.ink3)
168+ Txt(entry.host.name, Modifier.weight(1f, fill = false), color = p.ink3, size = 12.sp, maxLines = 1)
169+ }
170+ if (tasks > 0) TerminalMark()
171+ detail?.pullRequest?.let { PrBadge(it) }
166172 }
167- if (tasks > 0) TerminalMark()
168- detail?.pullRequest?.let { PrBadge(it) }
169- Spacer(Modifier.weight(1f))
170173 if (branch != null) {
171174 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)
175+ // The desktop names the host in a hover card; with several hosts it's named here.
176+ if (showHost) Txt(entry.host.name, Modifier.widthIn(max = 96.dp), color = p.ink3, size = 12.sp, maxLines = 1)
174177 }
175178 Box(Modifier.alpha(0.6f)) { ProviderIcon(summary.provider, 13.dp) }
176179 }