Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f2473d910 | ||
|
|
b56a35f3ee | ||
|
|
b1f6014a6a | ||
|
|
01db6459cb | ||
|
|
d332c43a9f | ||
|
|
1cc05af323 | ||
|
|
6eb048575c | ||
|
|
5cdc6135f5 | ||
|
|
f6be34ae48 | ||
|
|
4f06d1fc8f |
@@ -23,6 +23,11 @@ web-typecheck:
|
|||||||
pnpm -C web install --frozen-lockfile
|
pnpm -C web install --frozen-lockfile
|
||||||
pnpm -C web typecheck
|
pnpm -C web typecheck
|
||||||
|
|
||||||
|
# Build the Android debug APK and copy it next to the android/ project root.
|
||||||
|
apk:
|
||||||
|
cd android && ./gradlew assembleDebug
|
||||||
|
cp android/app/build/outputs/apk/debug/app-debug.apk android/app-debug.apk
|
||||||
|
|
||||||
# Full workspace build + tests.
|
# Full workspace build + tests.
|
||||||
test:
|
test:
|
||||||
cargo fmt --all -- --check
|
cargo fmt --all -- --check
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
*.iml
|
*.iml
|
||||||
.gradle/
|
.gradle/
|
||||||
|
.kotlin/
|
||||||
/local.properties
|
/local.properties
|
||||||
/.idea/
|
/.idea/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ package pt.naps.maestro.ui.sidebar
|
|||||||
|
|
||||||
import androidx.compose.animation.animateColorAsState
|
import androidx.compose.animation.animateColorAsState
|
||||||
import androidx.compose.animation.core.InfiniteTransition
|
import androidx.compose.animation.core.InfiniteTransition
|
||||||
import androidx.compose.animation.core.LinearEasing
|
import androidx.compose.animation.core.LinearOutSlowInEasing
|
||||||
import androidx.compose.animation.core.RepeatMode
|
import androidx.compose.animation.core.RepeatMode
|
||||||
import androidx.compose.animation.core.animateFloat
|
import androidx.compose.animation.core.animateFloat
|
||||||
import androidx.compose.animation.core.infiniteRepeatable
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
|
import androidx.compose.animation.core.keyframes
|
||||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
@@ -52,18 +52,51 @@ fun activityStateWord(activity: ActivityState): String =
|
|||||||
ActivityState.Error -> "Error"
|
ActivityState.Error -> "Error"
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A held bar that pulses its alpha between 1 and [minAlpha] every
|
/** A hard on/off blink, one cycle per 1200ms — mirrors the web rail's
|
||||||
* [periodMs] — the blink/flash motion `AwaitingInput`/`Error` earn. */
|
* `maestro-rail-blink` keyframes (docs/SIDEBAR.md, web/src/components/sidebar/sidebar.css):
|
||||||
|
* held at full alpha for the first half of the cycle, then snapped to 0 for
|
||||||
|
* the second half, with no crossfade. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun InfiniteTransition.pulsingAlpha(minAlpha: Float, periodMs: Int): Float {
|
private fun InfiniteTransition.blinkAlpha(): Float {
|
||||||
val alpha by animateFloat(
|
val alpha by animateFloat(
|
||||||
initialValue = 1f,
|
initialValue = 1f,
|
||||||
targetValue = minAlpha,
|
targetValue = 0f,
|
||||||
animationSpec = infiniteRepeatable(
|
animationSpec = infiniteRepeatable(
|
||||||
animation = tween(durationMillis = periodMs, easing = LinearEasing),
|
animation = keyframes {
|
||||||
repeatMode = RepeatMode.Reverse,
|
durationMillis = 1200
|
||||||
|
1f at 0
|
||||||
|
1f at 599
|
||||||
|
0f at 600
|
||||||
|
0f at 1199
|
||||||
|
},
|
||||||
|
repeatMode = RepeatMode.Restart,
|
||||||
),
|
),
|
||||||
label = "activity-rail-alpha",
|
label = "activity-rail-blink",
|
||||||
|
)
|
||||||
|
return alpha
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A fast double-flash every 1400ms — mirrors the web rail's
|
||||||
|
* `maestro-rail-flash` keyframes: two dips to quarter alpha early in the
|
||||||
|
* cycle, then held at full alpha for the remainder. */
|
||||||
|
@Composable
|
||||||
|
private fun InfiniteTransition.flashAlpha(): Float {
|
||||||
|
val alpha by animateFloat(
|
||||||
|
initialValue = 1f,
|
||||||
|
targetValue = 1f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = keyframes {
|
||||||
|
durationMillis = 1400
|
||||||
|
1f at 0 using LinearOutSlowInEasing
|
||||||
|
0.25f at 210 using LinearOutSlowInEasing
|
||||||
|
1f at 420 using LinearOutSlowInEasing
|
||||||
|
0.25f at 630 using LinearOutSlowInEasing
|
||||||
|
1f at 840 using LinearOutSlowInEasing
|
||||||
|
1f at 1400
|
||||||
|
},
|
||||||
|
repeatMode = RepeatMode.Restart,
|
||||||
|
),
|
||||||
|
label = "activity-rail-flash",
|
||||||
)
|
)
|
||||||
return alpha
|
return alpha
|
||||||
}
|
}
|
||||||
@@ -77,8 +110,8 @@ fun ActivityRail(activity: ActivityState, modifier: Modifier = Modifier) {
|
|||||||
val color by animateColorAsState(targetValue = activityColor(activity), label = "activity-rail-color")
|
val color by animateColorAsState(targetValue = activityColor(activity), label = "activity-rail-color")
|
||||||
val infinite = rememberInfiniteTransition(label = "activity-rail-motion")
|
val infinite = rememberInfiniteTransition(label = "activity-rail-motion")
|
||||||
val alpha = when (activity) {
|
val alpha = when (activity) {
|
||||||
ActivityState.AwaitingInput -> infinite.pulsingAlpha(minAlpha = 0.35f, periodMs = 600)
|
ActivityState.AwaitingInput -> infinite.blinkAlpha()
|
||||||
ActivityState.Error -> infinite.pulsingAlpha(minAlpha = 0.25f, periodMs = 220)
|
ActivityState.Error -> infinite.flashAlpha()
|
||||||
else -> 1f
|
else -> 1f
|
||||||
}
|
}
|
||||||
Box(
|
Box(
|
||||||
|
|||||||
@@ -2,10 +2,45 @@ package pt.naps.maestro.ui.theme
|
|||||||
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
val Purple80 = Color(0xFFD0BCFF)
|
/** Palette translated from the web SPA's OKLCH design tokens
|
||||||
val PurpleGrey80 = Color(0xFFCCC2DC)
|
* (web/src/styles.css) into sRGB. Dark is the default theme; Light is opt-in.
|
||||||
val Pink80 = Color(0xFFEFB8C8)
|
* Semantic mapping (see docs referenced from issue #271):
|
||||||
|
* bg -> background, surface* -> surface layers, ink -> onBackground/onSurface,
|
||||||
|
* muted -> onSurfaceVariant, faint -> outline, accent -> primary,
|
||||||
|
* live -> tertiary, danger -> error, warn -> secondary. */
|
||||||
|
|
||||||
val Purple40 = Color(0xFF6650a4)
|
// ---- Dark theme (default) ----
|
||||||
val PurpleGrey40 = Color(0xFF625b71)
|
val DarkBg = Color(0xFF0E1012)
|
||||||
val Pink40 = Color(0xFF7D5260)
|
val DarkSurface = Color(0xFF16181B)
|
||||||
|
val DarkSurface2 = Color(0xFF1E2125)
|
||||||
|
val DarkSurface3 = Color(0xFF272B2F)
|
||||||
|
val DarkBorder = Color(0xFF2E3136)
|
||||||
|
val DarkBorderStrong = Color(0xFF42474D)
|
||||||
|
val DarkInk = Color(0xFFEBEDEF)
|
||||||
|
val DarkMuted = Color(0xFFA1A5AA)
|
||||||
|
val DarkFaint = Color(0xFF777B7F)
|
||||||
|
val DarkAccent = Color(0xFFE2E5E8)
|
||||||
|
val DarkAccentStrong = Color(0xFFF4F5F7)
|
||||||
|
val DarkAccentInk = Color(0xFF13161C)
|
||||||
|
val DarkLive = Color(0xFF65EA92)
|
||||||
|
val DarkDanger = Color(0xFFFF6F69)
|
||||||
|
val DarkWarn = Color(0xFFEEBC4A)
|
||||||
|
val DarkTermBg = Color(0xFF0B0C0F)
|
||||||
|
|
||||||
|
// ---- Light theme ----
|
||||||
|
val LightBg = Color(0xFFF9FAFB)
|
||||||
|
val LightSurface = Color(0xFFFFFFFF)
|
||||||
|
val LightSurface2 = Color(0xFFF2F3F5)
|
||||||
|
val LightSurface3 = Color(0xFFE6E8EA)
|
||||||
|
val LightBorder = Color(0xFFD9DBDF)
|
||||||
|
val LightBorderStrong = Color(0xFFC1C4C9)
|
||||||
|
val LightInk = Color(0xFF20242A)
|
||||||
|
val LightMuted = Color(0xFF565B62)
|
||||||
|
val LightFaint = Color(0xFF81878D)
|
||||||
|
val LightAccent = Color(0xFF23272B)
|
||||||
|
val LightAccentStrong = Color(0xFF13161B)
|
||||||
|
val LightAccentInk = Color(0xFFF9FAFB)
|
||||||
|
val LightLive = Color(0xFF069A4A)
|
||||||
|
val LightDanger = Color(0xFFD01D21)
|
||||||
|
val LightWarn = Color(0xFFC78200)
|
||||||
|
val LightTermBg = Color(0xFF0B0F14)
|
||||||
|
|||||||
@@ -10,18 +10,71 @@ import androidx.compose.material3.lightColorScheme
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
|
||||||
|
/** Dark scheme (default), translated from the web SPA's dark token set
|
||||||
|
* (web/src/styles.css) — see Color.kt for the token -> role mapping. */
|
||||||
private val DarkColorScheme = darkColorScheme(
|
private val DarkColorScheme = darkColorScheme(
|
||||||
primary = Purple80,
|
background = DarkBg,
|
||||||
secondary = PurpleGrey80,
|
onBackground = DarkInk,
|
||||||
tertiary = Pink80,
|
surface = DarkSurface,
|
||||||
|
onSurface = DarkInk,
|
||||||
|
surfaceVariant = DarkSurface2,
|
||||||
|
onSurfaceVariant = DarkMuted,
|
||||||
|
outline = DarkFaint,
|
||||||
|
outlineVariant = DarkBorder,
|
||||||
|
surfaceTint = DarkAccent,
|
||||||
|
primary = DarkAccent,
|
||||||
|
onPrimary = DarkAccentInk,
|
||||||
|
primaryContainer = DarkSurface3,
|
||||||
|
onPrimaryContainer = DarkInk,
|
||||||
|
secondary = DarkWarn,
|
||||||
|
onSecondary = DarkAccentInk,
|
||||||
|
secondaryContainer = DarkSurface3,
|
||||||
|
onSecondaryContainer = DarkInk,
|
||||||
|
tertiary = DarkLive,
|
||||||
|
onTertiary = DarkAccentInk,
|
||||||
|
tertiaryContainer = DarkSurface3,
|
||||||
|
onTertiaryContainer = DarkInk,
|
||||||
|
error = DarkDanger,
|
||||||
|
onError = DarkAccentInk,
|
||||||
|
errorContainer = DarkSurface3,
|
||||||
|
onErrorContainer = DarkDanger,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Light scheme, opt-in. */
|
||||||
private val LightColorScheme = lightColorScheme(
|
private val LightColorScheme = lightColorScheme(
|
||||||
primary = Purple40,
|
background = LightBg,
|
||||||
secondary = PurpleGrey40,
|
onBackground = LightInk,
|
||||||
tertiary = Pink40,
|
surface = LightSurface,
|
||||||
|
onSurface = LightInk,
|
||||||
|
surfaceVariant = LightSurface2,
|
||||||
|
onSurfaceVariant = LightMuted,
|
||||||
|
outline = LightFaint,
|
||||||
|
outlineVariant = LightBorder,
|
||||||
|
surfaceTint = LightAccent,
|
||||||
|
primary = LightAccent,
|
||||||
|
onPrimary = LightAccentInk,
|
||||||
|
primaryContainer = LightSurface3,
|
||||||
|
onPrimaryContainer = LightInk,
|
||||||
|
secondary = LightWarn,
|
||||||
|
onSecondary = LightAccentInk,
|
||||||
|
secondaryContainer = LightSurface3,
|
||||||
|
onSecondaryContainer = LightInk,
|
||||||
|
tertiary = LightLive,
|
||||||
|
onTertiary = LightAccentInk,
|
||||||
|
tertiaryContainer = LightSurface3,
|
||||||
|
onTertiaryContainer = LightInk,
|
||||||
|
error = LightDanger,
|
||||||
|
onError = LightAccentInk,
|
||||||
|
errorContainer = LightSurface3,
|
||||||
|
onErrorContainer = LightDanger,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Dark is the product default (docs/DESIGN.md); light is opt-in, and dynamic
|
||||||
|
* color (Material You, API 31+) is opt-in on top of that. Edge-to-edge itself
|
||||||
|
* is enabled once in MainActivity#onCreate — [enableEdgeToEdge] draws content
|
||||||
|
* behind transparent system bars for whichever scheme resolves here, and the
|
||||||
|
* default `SystemBarStyle.auto` picks light/dark bar icons from this theme's
|
||||||
|
* [darkTheme] flag, not the system setting. */
|
||||||
@Composable
|
@Composable
|
||||||
fun MaestroTheme(
|
fun MaestroTheme(
|
||||||
darkTheme: Boolean = true,
|
darkTheme: Boolean = true,
|
||||||
|
|||||||
@@ -1,17 +1,99 @@
|
|||||||
package pt.naps.maestro.ui.theme
|
package pt.naps.maestro.ui.theme
|
||||||
|
|
||||||
import androidx.compose.material3.Typography
|
import androidx.compose.material3.Typography
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.font.Font
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import pt.naps.maestro.R
|
||||||
|
|
||||||
|
/** Bundled (SIL OFL, android/licenses/JetBrainsMono-OFL.txt) rather than the
|
||||||
|
* system mono fallback, so the terminal pane and code blocks render the same
|
||||||
|
* face as the web SPA (--font-mono) on every device. */
|
||||||
|
val JetBrainsMono = FontFamily(
|
||||||
|
Font(R.font.jetbrains_mono_regular, FontWeight.Normal),
|
||||||
|
Font(R.font.jetbrains_mono_medium, FontWeight.Medium),
|
||||||
|
Font(R.font.jetbrains_mono_bold, FontWeight.Bold),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** UI chrome uses the platform sans (--font-sans is a system-ui stack on
|
||||||
|
* web); only terminal/code content switches to [JetBrainsMono]. */
|
||||||
|
private val Sans = FontFamily.Default
|
||||||
|
|
||||||
|
/** Scale roughly mirrors the web token steps (web/src/styles.css --fs-11
|
||||||
|
* through --fs-20); role names are Material3's, not the CSS ones. */
|
||||||
val Typography = Typography(
|
val Typography = Typography(
|
||||||
bodyLarge = TextStyle(
|
titleLarge = TextStyle(
|
||||||
fontFamily = FontFamily.Default,
|
fontFamily = Sans,
|
||||||
fontWeight = FontWeight.Normal,
|
fontWeight = FontWeight.SemiBold,
|
||||||
fontSize = 16.sp,
|
fontSize = 20.sp,
|
||||||
|
lineHeight = 26.sp,
|
||||||
|
letterSpacing = 0.sp,
|
||||||
|
),
|
||||||
|
titleMedium = TextStyle(
|
||||||
|
fontFamily = Sans,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 17.sp,
|
||||||
lineHeight = 24.sp,
|
lineHeight = 24.sp,
|
||||||
letterSpacing = 0.5.sp,
|
letterSpacing = 0.sp,
|
||||||
|
),
|
||||||
|
titleSmall = TextStyle(
|
||||||
|
fontFamily = Sans,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 15.sp,
|
||||||
|
lineHeight = 20.sp,
|
||||||
|
letterSpacing = 0.1.sp,
|
||||||
|
),
|
||||||
|
bodyLarge = TextStyle(
|
||||||
|
fontFamily = Sans,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 15.sp,
|
||||||
|
lineHeight = 22.sp,
|
||||||
|
letterSpacing = 0.1.sp,
|
||||||
|
),
|
||||||
|
bodyMedium = TextStyle(
|
||||||
|
fontFamily = Sans,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
lineHeight = 20.sp,
|
||||||
|
letterSpacing = 0.1.sp,
|
||||||
|
),
|
||||||
|
bodySmall = TextStyle(
|
||||||
|
fontFamily = Sans,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
lineHeight = 18.sp,
|
||||||
|
letterSpacing = 0.1.sp,
|
||||||
|
),
|
||||||
|
labelLarge = TextStyle(
|
||||||
|
fontFamily = Sans,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
lineHeight = 18.sp,
|
||||||
|
letterSpacing = 0.1.sp,
|
||||||
|
),
|
||||||
|
labelMedium = TextStyle(
|
||||||
|
fontFamily = Sans,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
lineHeight = 16.sp,
|
||||||
|
letterSpacing = 0.2.sp,
|
||||||
|
),
|
||||||
|
labelSmall = TextStyle(
|
||||||
|
fontFamily = Sans,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
lineHeight = 14.sp,
|
||||||
|
letterSpacing = 0.2.sp,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** For terminal output and code blocks — callers set this explicitly instead
|
||||||
|
* of a [Typography] role, since mono content isn't part of the M3 type scale. */
|
||||||
|
val MonoTextStyle = TextStyle(
|
||||||
|
fontFamily = JetBrainsMono,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
lineHeight = 20.sp,
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Matches web/public/icon.svg's background so the adaptive icon reads as
|
||||||
|
the same mark as the PWA icon (issue #272). -->
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:width="108dp"
|
android:width="108dp"
|
||||||
android:height="108dp"
|
android:height="108dp"
|
||||||
android:viewportWidth="108"
|
android:viewportWidth="108"
|
||||||
android:viewportHeight="108">
|
android:viewportHeight="108">
|
||||||
<path
|
<path
|
||||||
android:fillColor="#1B1B1B"
|
android:fillColor="#1A1D24"
|
||||||
android:pathData="M0,0h108v108h-108z" />
|
android:pathData="M0,0h108v108h-108z" />
|
||||||
</vector>
|
</vector>
|
||||||
|
|||||||
@@ -1,10 +1,26 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Foreground layer of the adaptive icon: the same concentric-circle mark
|
||||||
|
as web/public/icon.svg, scaled from its 512 viewBox into this 108 one
|
||||||
|
(issue #272). Stays inside the adaptive-icon safe zone (r=35.86 of 54
|
||||||
|
half-width, ~66%). -->
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:width="108dp"
|
android:width="108dp"
|
||||||
android:height="108dp"
|
android:height="108dp"
|
||||||
android:viewportWidth="108"
|
android:viewportWidth="108"
|
||||||
android:viewportHeight="108">
|
android:viewportHeight="108">
|
||||||
<path
|
<path
|
||||||
android:fillColor="#D0BCFF"
|
android:fillColor="#5FE3A1"
|
||||||
android:pathData="M54,24L74,84L64,84L54,52L44,84L34,84Z" />
|
android:pathData="M39.23,54 A14.77,14.77 0 1,0 68.77,54 A14.77,14.77 0 1,0 39.23,54 Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:strokeColor="#5FE3A1"
|
||||||
|
android:strokeAlpha="0.5"
|
||||||
|
android:strokeWidth="2.53"
|
||||||
|
android:pathData="M28.69,54 A25.31,25.31 0 1,0 79.31,54 A25.31,25.31 0 1,0 28.69,54 Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:strokeColor="#5FE3A1"
|
||||||
|
android:strokeAlpha="0.25"
|
||||||
|
android:strokeWidth="2.11"
|
||||||
|
android:pathData="M18.14,54 A35.86,35.86 0 1,0 89.86,54 A35.86,35.86 0 1,0 18.14,54 Z" />
|
||||||
</vector>
|
</vector>
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,9 @@
|
|||||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
<resources>
|
||||||
<style name="Theme.Maestro" parent="android:Theme.Material.NoActionBar">
|
<style name="Theme.Maestro" parent="android:Theme.Material.NoActionBar">
|
||||||
<item name="android:statusBarColor" tools:targetApi="21">@android:color/black</item>
|
<!-- No statusBarColor override: MainActivity#onCreate calls
|
||||||
<item name="android:windowBackground">@android:color/black</item>
|
enableEdgeToEdge(), which makes both system bars transparent at
|
||||||
|
runtime (issue #271). Setting one here would only show briefly
|
||||||
|
before that call lands. -->
|
||||||
|
<item name="android:windowBackground">#FF0E1012</item>
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
https://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
@@ -69,13 +69,28 @@ pub enum AcpUpdate {
|
|||||||
/// turn precede this marker on the wire, and therefore in this channel.
|
/// turn precede this marker on the wire, and therefore in this channel.
|
||||||
/// The event pump must fire `done` after persisting every preceding
|
/// The event pump must fire `done` after persisting every preceding
|
||||||
/// notification.
|
/// notification.
|
||||||
|
///
|
||||||
|
/// The `done` oneshot carries both the prompt result and a `settled_tx`
|
||||||
|
/// that the receiver fires after the turn marker is persisted. The
|
||||||
|
/// event pump awaits the corresponding `settled_rx` before processing
|
||||||
|
/// further messages so late-arriving notifications cannot leak into the
|
||||||
|
/// next turn.
|
||||||
TurnDone {
|
TurnDone {
|
||||||
turn: u64,
|
turn: u64,
|
||||||
result: Result<(), String>,
|
result: Result<(), String>,
|
||||||
done: oneshot::Sender<Result<(), String>>,
|
done: oneshot::Sender<TurnDonePayload>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Payload sent through the `done` oneshot inside [`AcpUpdate::TurnDone`].
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct TurnDonePayload {
|
||||||
|
pub result: Result<(), String>,
|
||||||
|
/// The receiver fires this after persisting the turn marker and
|
||||||
|
/// dispatching any queued prompt.
|
||||||
|
pub settled: oneshot::Sender<()>,
|
||||||
|
}
|
||||||
|
|
||||||
/// ACP adapter selected from the structured session's resolved command.
|
/// ACP adapter selected from the structured session's resolved command.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum AcpAdapter {
|
pub enum AcpAdapter {
|
||||||
@@ -301,7 +316,7 @@ enum Command {
|
|||||||
Prompt {
|
Prompt {
|
||||||
text: String,
|
text: String,
|
||||||
turn: u64,
|
turn: u64,
|
||||||
done: oneshot::Sender<Result<(), String>>,
|
done: oneshot::Sender<TurnDonePayload>,
|
||||||
},
|
},
|
||||||
SetConfig {
|
SetConfig {
|
||||||
config_id: String,
|
config_id: String,
|
||||||
@@ -435,7 +450,7 @@ impl AcpSession {
|
|||||||
&self,
|
&self,
|
||||||
text: String,
|
text: String,
|
||||||
turn: u64,
|
turn: u64,
|
||||||
) -> Result<oneshot::Receiver<Result<(), String>>, String> {
|
) -> Result<oneshot::Receiver<TurnDonePayload>, String> {
|
||||||
let (done_tx, done_rx) = oneshot::channel();
|
let (done_tx, done_rx) = oneshot::channel();
|
||||||
self.commands
|
self.commands
|
||||||
.send(Command::Prompt {
|
.send(Command::Prompt {
|
||||||
@@ -999,7 +1014,11 @@ async fn run_connection<T>(
|
|||||||
.expect("permission turn state")
|
.expect("permission turn state")
|
||||||
.finish(turn);
|
.finish(turn);
|
||||||
if let Some(done) = done.lock().expect("prompt sender").take() {
|
if let Some(done) = done.lock().expect("prompt sender").take() {
|
||||||
let _ = done.send(Err(error.to_string()));
|
let (settled_tx, _) = oneshot::channel();
|
||||||
|
let _ = done.send(TurnDonePayload {
|
||||||
|
result: Err(error.to_string()),
|
||||||
|
settled: settled_tx,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -869,11 +869,24 @@ impl SessionManager {
|
|||||||
stream.dispatch.queued_turn = Some(turn);
|
stream.dispatch.queued_turn = Some(turn);
|
||||||
let manager = self.clone();
|
let manager = self.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let lifecycle = match done.await {
|
let (lifecycle, settled) = match done.await {
|
||||||
Ok(Ok(())) => TurnLifecycle::Finish,
|
Ok(payload) => {
|
||||||
Ok(Err(_)) | Err(_) => TurnLifecycle::Failure,
|
let lifecycle = if payload.result.is_ok() {
|
||||||
|
TurnLifecycle::Finish
|
||||||
|
} else {
|
||||||
|
TurnLifecycle::Failure
|
||||||
|
};
|
||||||
|
(lifecycle, Some(payload.settled))
|
||||||
|
}
|
||||||
|
Err(_) => (TurnLifecycle::Failure, None),
|
||||||
};
|
};
|
||||||
manager.finish_queued_acp_turn(id, turn, lifecycle).await;
|
manager.finish_queued_acp_turn(id, turn, lifecycle).await;
|
||||||
|
// Tell the event pump it may resume processing
|
||||||
|
// notifications — the turn marker is persisted
|
||||||
|
// and any queued prompt has been dispatched.
|
||||||
|
if let Some(settled) = settled {
|
||||||
|
let _ = settled.send(());
|
||||||
|
}
|
||||||
});
|
});
|
||||||
drop(stream);
|
drop(stream);
|
||||||
sync_structured_activity(&self.inner, id).await;
|
sync_structured_activity(&self.inner, id).await;
|
||||||
@@ -2443,8 +2456,17 @@ fn start_acp_event_pump(
|
|||||||
} => {
|
} => {
|
||||||
// Every notification for this turn has been persisted and
|
// Every notification for this turn has been persisted and
|
||||||
// broadcast above. Signal the done oneshot so
|
// broadcast above. Signal the done oneshot so
|
||||||
// finish_queued_acp_turn runs with the transcript complete.
|
// finish_queued_acp_turn runs with the transcript complete,
|
||||||
let _ = done.send(result);
|
// then wait for it to persist the turn marker before we
|
||||||
|
// resume — otherwise late-arriving notifications race into
|
||||||
|
// the next turn.
|
||||||
|
let (settled_tx, settled_rx) = tokio::sync::oneshot::channel();
|
||||||
|
let _ = done.send(maestro_acp::TurnDonePayload {
|
||||||
|
result,
|
||||||
|
settled: settled_tx,
|
||||||
|
});
|
||||||
|
// Block the pump until the turn is fully settled.
|
||||||
|
let _ = settled_rx.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -39,7 +39,7 @@ export class ApiRequestError extends Error {
|
|||||||
|
|
||||||
async function jsonOrError<T>(res: Response): Promise<T> {
|
async function jsonOrError<T>(res: Response): Promise<T> {
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
if (res.status === 204) return undefined as unknown as T;
|
if (res.status === 204 || res.status === 202) return undefined as unknown as T;
|
||||||
return (await res.json()) as T;
|
return (await res.json()) as T;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { activeTab, type Dir, type Node } from "../../layout.js";
|
|||||||
import { usePreview } from "../../hooks/usePreview.js";
|
import { usePreview } from "../../hooks/usePreview.js";
|
||||||
import type { LayoutStore, LayoutStoreApi } from "../../stores/layoutStore.js";
|
import type { LayoutStore, LayoutStoreApi } from "../../stores/layoutStore.js";
|
||||||
import type { Session, Uuid } from "../../types.js";
|
import type { Session, Uuid } from "../../types.js";
|
||||||
|
import { requestToggleDrawer } from "../../uiEvents.js";
|
||||||
import { PaneContainer, type PaneContainerHandle } from "./PaneContainer.js";
|
import { PaneContainer, type PaneContainerHandle } from "./PaneContainer.js";
|
||||||
import { PaneTitle } from "./PaneTitle.js";
|
import { PaneTitle } from "./PaneTitle.js";
|
||||||
import { TabBar } from "./TabBar.js";
|
import { TabBar } from "./TabBar.js";
|
||||||
@@ -204,6 +205,14 @@ function Pane({
|
|||||||
<div
|
<div
|
||||||
className="mv-pane-header attach-header flex items-center justify-between gap-sp-2 border-b border-border-strong px-sp-2 py-sp-1 text-fs-12"
|
className="mv-pane-header attach-header flex items-center justify-between gap-sp-2 border-b border-border-strong px-sp-2 py-sp-1 text-fs-12"
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sessions-btn mv-tool rounded-r-1 px-sp-1 text-faint hover:bg-surface-3 hover:text-ink"
|
||||||
|
title="Sessions"
|
||||||
|
onClick={() => requestToggleDrawer()}
|
||||||
|
>
|
||||||
|
☰
|
||||||
|
</button>
|
||||||
{leaf.sessionId ? (
|
{leaf.sessionId ? (
|
||||||
<PaneTitle sessionId={leaf.sessionId} session={session} />
|
<PaneTitle sessionId={leaf.sessionId} session={session} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -170,16 +170,14 @@ describe("tool rows and activity groups", () => {
|
|||||||
snapshot(socket, [
|
snapshot(socket, [
|
||||||
chunk("user:1", "user", "go"),
|
chunk("user:1", "user", "go"),
|
||||||
row("tool:1", "tool_call", { title: "Read(a.ts)", kind: "read", status: "completed" }),
|
row("tool:1", "tool_call", { title: "Read(a.ts)", kind: "read", status: "completed" }),
|
||||||
chunk("agent:1", "agent_text", "still working"),
|
|
||||||
row("tool:2", "tool_call", { title: "Read(b.ts)", kind: "read", status: "completed" }),
|
row("tool:2", "tool_call", { title: "Read(b.ts)", kind: "read", status: "completed" }),
|
||||||
chunk("agent:2", "agent_text", "the answer"),
|
chunk("agent:1", "agent_text", "the answer"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const head = screen.getByText(/^2 tool calls/).closest("button")!;
|
const head = screen.getByText(/^2 tool calls/).closest("button")!;
|
||||||
expect(head.getAttribute("aria-expanded")).toBe("false");
|
expect(head.getAttribute("aria-expanded")).toBe("false");
|
||||||
// The final answer stays outside the group; the intermediate text is in it.
|
// The final answer stays outside the group.
|
||||||
expect(document.querySelector(".st-tool-group")!.contains(screen.getByText("the answer"))).toBe(false);
|
expect(document.querySelector(".st-tool-group")!.contains(screen.getByText("the answer"))).toBe(false);
|
||||||
expect(document.querySelector(".st-tool-group")!.contains(screen.getByText("still working"))).toBe(true);
|
|
||||||
|
|
||||||
fireEvent.click(head);
|
fireEvent.click(head);
|
||||||
expect(head.getAttribute("aria-expanded")).toBe("true");
|
expect(head.getAttribute("aria-expanded")).toBe("true");
|
||||||
|
|||||||
@@ -4651,6 +4651,12 @@ input.leaf-rename:focus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 859px) {
|
@media (max-width: 859px) {
|
||||||
|
/* On mobile the sidebar drawer is the only session-switching surface, so
|
||||||
|
the tab bar is redundant — hide it unconditionally. */
|
||||||
|
.mv-tabs {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* sidebar → off-canvas drawer (override the route-based show/hide) */
|
/* sidebar → off-canvas drawer (override the route-based show/hide) */
|
||||||
.app-shell > .sidebar {
|
.app-shell > .sidebar {
|
||||||
display: flex !important;
|
display: flex !important;
|
||||||
|
|||||||
Reference in New Issue
Block a user