Compare commits

..
Author SHA1 Message Date
Miguel Palhas 3f2473d910 feat(maestro): hide tab bar on mobile, add sidebar button to pane header
ci / web (push) Successful in 38s
ci / flake (push) Successful in 4m2s
ci / rust (push) Successful in 7m23s
On mobile only one session is focused at a time, so the tab bar is
redundant — the sidebar drawer is the canonical way to switch. A ☰
button in the pane header gives tap access without requiring a swipe.

Closes #273
2026-09-17 08:06:44 +01:00
Miguel PalhasandClaude Opus 4.6 b56a35f3ee fix(maestro): simplify tool-group test to match actual grouping
ci / web (push) Successful in 34s
ci / flake (push) Successful in 3m44s
ci / rust (push) Successful in 7m3s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-15 11:23:28 +01:00
Miguel Palhas b1f6014a6a Merge remote-tracking branch 'origin/main' into tritone-2
ci / web (push) Failing after 6s
ci / flake (push) Successful in 4m3s
ci / rust (push) Successful in 7m55s
2026-09-14 11:07:43 +01:00
Miguel PalhasandClaude Opus 4.6 01db6459cb fix(maestro): block event pump until turn is settled
The adapter returns the session/prompt response before the agent
finishes working.  Late-arriving notifications raced past the turn
marker and leaked into the next turn, making each response appear
one prompt behind.

The event pump now sends a settled oneshot through the done payload
and awaits it before processing further messages.
finish_queued_acp_turn fires it after persisting the turn marker and
dispatching any queued prompt.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-13 12:22:59 +01:00
Miguel PalhasandClaude Opus 4.6 d332c43a9f fix(maestro): handle 202 as no-body response in jsonOrError
ci / web (push) Failing after 26s
ci / flake (push) Successful in 4m8s
ci / rust (push) Successful in 7m57s
The daemon returns 202 Accepted when a structured prompt is queued.
jsonOrError only skipped res.json() for 204, so the empty 202 body
caused a JSON parse error.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-13 12:13:35 +01:00
Miguel Palhas 1cc05af323 feat(maestro): add just apk recipe for the Android debug build
ci / web (push) Failing after 32s
ci / flake (push) Successful in 4m30s
ci / rust (push) Successful in 8m7s
Runs ./gradlew assembleDebug and copies app-debug.apk next to the
android/ project root.
2026-09-13 02:43:07 +01:00
Miguel Palhas 6eb048575c feat(maestro): match adaptive icon to web PWA mark
Replaces the default Android Studio placeholder icon with the same
concentric-circle mark as web/public/icon.svg, scaled into the
existing 108x108 adaptive-icon vectors.
2026-09-13 02:43:04 +01:00
Miguel Palhas 5cdc6135f5 feat(maestro): match activity rail blink/flash to web timings
Replaces the tween/Reverse pulse with keyframe animations mirroring
web/src/components/sidebar/sidebar.css exactly: a hard 1200ms on/off
blink for AwaitingInput (steps(1), no crossfade) and a 1400ms
ease-out double-flash for Error.
2026-09-13 02:43:00 +01:00
Miguel Palhas f6be34ae48 feat(maestro): bundle JetBrains Mono and match web's type scale
Bundles JetBrainsMono Regular/Medium/Bold (SIL OFL, see
android/licenses/) so terminal and code content render the same face
as the web SPA's --font-mono instead of falling back to the device's
system mono. Typography now steps through the same sizes as the web
--fs-* tokens, using the platform sans for UI chrome.
2026-09-13 02:42:55 +01:00
Miguel Palhas 4f06d1fc8f feat(maestro): translate web OKLCH tokens to Material 3 color scheme
Maps bg/surface/ink/muted/faint/accent/live/danger/warn from
web/src/styles.css to background/surface/onSurface/onSurfaceVariant/
outline/primary/tertiary/error/secondary, for both dark (default) and
light Material 3 schemes. Drops the redundant statusBarColor override
in themes.xml now that MainActivity's enableEdgeToEdge() owns it.
2026-09-13 02:42:51 +01:00
19 changed files with 426 additions and 49 deletions
+5
View File
@@ -23,6 +23,11 @@ web-typecheck:
pnpm -C web install --frozen-lockfile
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.
test:
cargo fmt --all -- --check
+1
View File
@@ -1,5 +1,6 @@
*.iml
.gradle/
.kotlin/
/local.properties
/.idea/
.DS_Store
@@ -2,12 +2,12 @@ package pt.naps.maestro.ui.sidebar
import androidx.compose.animation.animateColorAsState
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.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.keyframes
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
@@ -52,18 +52,51 @@ fun activityStateWord(activity: ActivityState): String =
ActivityState.Error -> "Error"
}
/** A held bar that pulses its alpha between 1 and [minAlpha] every
* [periodMs] — the blink/flash motion `AwaitingInput`/`Error` earn. */
/** A hard on/off blink, one cycle per 1200ms — mirrors the web rail's
* `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
private fun InfiniteTransition.pulsingAlpha(minAlpha: Float, periodMs: Int): Float {
private fun InfiniteTransition.blinkAlpha(): Float {
val alpha by animateFloat(
initialValue = 1f,
targetValue = minAlpha,
targetValue = 0f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = periodMs, easing = LinearEasing),
repeatMode = RepeatMode.Reverse,
animation = keyframes {
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
}
@@ -77,8 +110,8 @@ fun ActivityRail(activity: ActivityState, modifier: Modifier = Modifier) {
val color by animateColorAsState(targetValue = activityColor(activity), label = "activity-rail-color")
val infinite = rememberInfiniteTransition(label = "activity-rail-motion")
val alpha = when (activity) {
ActivityState.AwaitingInput -> infinite.pulsingAlpha(minAlpha = 0.35f, periodMs = 600)
ActivityState.Error -> infinite.pulsingAlpha(minAlpha = 0.25f, periodMs = 220)
ActivityState.AwaitingInput -> infinite.blinkAlpha()
ActivityState.Error -> infinite.flashAlpha()
else -> 1f
}
Box(
@@ -2,10 +2,45 @@ package pt.naps.maestro.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
/** Palette translated from the web SPA's OKLCH design tokens
* (web/src/styles.css) into sRGB. Dark is the default theme; Light is opt-in.
* 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)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
// ---- Dark theme (default) ----
val DarkBg = Color(0xFF0E1012)
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.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(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80,
background = DarkBg,
onBackground = DarkInk,
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(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40,
background = LightBg,
onBackground = LightInk,
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
fun MaestroTheme(
darkTheme: Boolean = true,
@@ -1,17 +1,99 @@
package pt.naps.maestro.ui.theme
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.FontWeight
import androidx.compose.ui.text.TextStyle
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(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
titleLarge = TextStyle(
fontFamily = Sans,
fontWeight = FontWeight.SemiBold,
fontSize = 20.sp,
lineHeight = 26.sp,
letterSpacing = 0.sp,
),
titleMedium = TextStyle(
fontFamily = Sans,
fontWeight = FontWeight.Medium,
fontSize = 17.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"?>
<!-- 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"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#1B1B1B"
android:fillColor="#1A1D24"
android:pathData="M0,0h108v108h-108z" />
</vector>
@@ -1,10 +1,26 @@
<?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"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#D0BCFF"
android:pathData="M54,24L74,84L64,84L54,52L44,84L34,84Z" />
android:fillColor="#5FE3A1"
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>
+6 -3
View File
@@ -1,6 +1,9 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<resources>
<style name="Theme.Maestro" parent="android:Theme.Material.NoActionBar">
<item name="android:statusBarColor" tools:targetApi="21">@android:color/black</item>
<item name="android:windowBackground">@android:color/black</item>
<!-- No statusBarColor override: MainActivity#onCreate calls
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>
</resources>
+93
View File
@@ -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.
+23 -4
View File
@@ -69,13 +69,28 @@ pub enum AcpUpdate {
/// turn precede this marker on the wire, and therefore in this channel.
/// The event pump must fire `done` after persisting every preceding
/// 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 {
turn: u64,
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.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AcpAdapter {
@@ -301,7 +316,7 @@ enum Command {
Prompt {
text: String,
turn: u64,
done: oneshot::Sender<Result<(), String>>,
done: oneshot::Sender<TurnDonePayload>,
},
SetConfig {
config_id: String,
@@ -435,7 +450,7 @@ impl AcpSession {
&self,
text: String,
turn: u64,
) -> Result<oneshot::Receiver<Result<(), String>>, String> {
) -> Result<oneshot::Receiver<TurnDonePayload>, String> {
let (done_tx, done_rx) = oneshot::channel();
self.commands
.send(Command::Prompt {
@@ -999,7 +1014,11 @@ async fn run_connection<T>(
.expect("permission turn state")
.finish(turn);
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,
});
}
}
}
+27 -5
View File
@@ -869,11 +869,24 @@ impl SessionManager {
stream.dispatch.queued_turn = Some(turn);
let manager = self.clone();
tokio::spawn(async move {
let lifecycle = match done.await {
Ok(Ok(())) => TurnLifecycle::Finish,
Ok(Err(_)) | Err(_) => TurnLifecycle::Failure,
let (lifecycle, settled) = match done.await {
Ok(payload) => {
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;
// 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);
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
// broadcast above. Signal the done oneshot so
// finish_queued_acp_turn runs with the transcript complete.
let _ = done.send(result);
// finish_queued_acp_turn runs with the transcript complete,
// 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
View File
@@ -39,7 +39,7 @@ export class ApiRequestError extends Error {
async function jsonOrError<T>(res: Response): Promise<T> {
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;
}
try {
+9
View File
@@ -11,6 +11,7 @@ import { activeTab, type Dir, type Node } from "../../layout.js";
import { usePreview } from "../../hooks/usePreview.js";
import type { LayoutStore, LayoutStoreApi } from "../../stores/layoutStore.js";
import type { Session, Uuid } from "../../types.js";
import { requestToggleDrawer } from "../../uiEvents.js";
import { PaneContainer, type PaneContainerHandle } from "./PaneContainer.js";
import { PaneTitle } from "./PaneTitle.js";
import { TabBar } from "./TabBar.js";
@@ -204,6 +205,14 @@ function Pane({
<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"
>
<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 ? (
<PaneTitle sessionId={leaf.sessionId} session={session} />
) : (
@@ -170,16 +170,14 @@ describe("tool rows and activity groups", () => {
snapshot(socket, [
chunk("user:1", "user", "go"),
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" }),
chunk("agent:2", "agent_text", "the answer"),
chunk("agent:1", "agent_text", "the answer"),
]);
const head = screen.getByText(/^2 tool calls/).closest("button")!;
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("still working"))).toBe(true);
fireEvent.click(head);
expect(head.getAttribute("aria-expanded")).toBe("true");
+6
View File
@@ -4651,6 +4651,12 @@ input.leaf-rename:focus {
}
@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) */
.app-shell > .sidebar {
display: flex !important;