Compare commits
75
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
391efc72d2 | ||
|
|
cf582be306 | ||
|
|
3403224dec | ||
|
|
a9c3cfe6d0 | ||
|
|
3ef1decbdd | ||
|
|
b22539bd5b | ||
|
|
8f0d73d89c | ||
|
|
c3e2faa7ee | ||
|
|
6ccd79baa0 | ||
|
|
4932cbf3b3 | ||
|
|
1ea463f6cf | ||
|
|
7c27a897ac | ||
|
|
a9717d01c8 | ||
|
|
35a6ff9d0f | ||
|
|
474000935a | ||
|
|
5f8cc2a140 | ||
|
|
432967fe64 | ||
|
|
2512b8be42 | ||
|
|
e3556bd847 | ||
|
|
ce0e4e6820 | ||
|
|
fa3d6c82b2 | ||
|
|
ebe25a1a55 | ||
|
|
923911f631 | ||
|
|
8cbe8510ac | ||
|
|
cd5bbcfcb1 | ||
|
|
cbbf526fc4 | ||
|
|
9f5b71d563 | ||
|
|
de603ccd20 | ||
|
|
c6af78e9dd | ||
|
|
7ab503e4f5 | ||
|
|
eaf11a7696 | ||
|
|
4738233580 | ||
|
|
cb25320ea0 | ||
|
|
ff52dd9812 | ||
|
|
f3a5cad789 | ||
|
|
fffd2a55f3 | ||
|
|
563a35d2ec | ||
|
|
9dead5652e | ||
|
|
cd2184b128 | ||
|
|
1b2e5f5f05 | ||
|
|
2c8956e634 | ||
|
|
f2c0882c36 | ||
|
|
c170cd9d60 | ||
|
|
ebf943bf30 | ||
|
|
645aa2dea0 | ||
|
|
3a2513c78d | ||
|
|
7cb52927f2 | ||
|
|
2724447a51 | ||
|
|
142e9a4870 | ||
|
|
e5c17606d0 | ||
|
|
979bfe2fbe | ||
|
|
8f96027878 | ||
|
|
b6f60bfbae | ||
|
|
a8a335eadb | ||
|
|
048e273ff9 | ||
|
|
beef2049ed | ||
|
|
c30592e6b6 | ||
|
|
abae81a0ce | ||
|
|
66a568818a | ||
|
|
f3d6146884 | ||
|
|
35ae01db93 | ||
|
|
8a05ef2eb7 | ||
|
|
ad71147bd9 | ||
|
|
e9c3882cea | ||
|
|
c9562ab5eb | ||
|
|
bc87ddee06 | ||
|
|
554c9ebb31 | ||
|
|
af5ad1a227 | ||
|
|
8304ef9a49 | ||
|
|
cdba6d07e3 | ||
|
|
ea8c42bfb2 | ||
|
|
e0edbc09d2 | ||
|
|
e9e98e0c49 | ||
|
|
02f1844650 | ||
|
|
1b7ec1f54c |
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"Bash(git add:*)",
|
|
||||||
"Bash(git commit:*)",
|
|
||||||
"Bash(git fetch:*)",
|
|
||||||
"Bash(git merge:*)",
|
|
||||||
"Bash(npx tsc:*)",
|
|
||||||
"Bash(npm run build:*)",
|
|
||||||
"Bash(npm install:*)",
|
|
||||||
"Bash(./node_modules/.bin/tsc:*)",
|
|
||||||
"Bash(npm test:*)",
|
|
||||||
"Bash(cargo check:*)",
|
|
||||||
"Bash(cargo clippy:*)",
|
|
||||||
"Bash(cargo test:*)"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,7 @@ on:
|
|||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
cancel-in-progress: true
|
||||||
|
|
||||||
env:
|
env:
|
||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
|
|||||||
@@ -15,3 +15,6 @@ worktrees/
|
|||||||
|
|
||||||
# `nix run .#dev` scratch state (tmux socket)
|
# `nix run .#dev` scratch state (tmux socket)
|
||||||
/.dev/
|
/.dev/
|
||||||
|
|
||||||
|
# Per-machine Claude Code tool permissions
|
||||||
|
.claude/settings.local.json
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
*.iml
|
||||||
|
.gradle/
|
||||||
|
/local.properties
|
||||||
|
/.idea/
|
||||||
|
.DS_Store
|
||||||
|
/build/
|
||||||
|
/captures/
|
||||||
|
.externalNativeBuild/
|
||||||
|
.cxx/
|
||||||
|
*.apk
|
||||||
|
*.aab
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application)
|
||||||
|
alias(libs.plugins.kotlin.android)
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "pt.naps.maestro"
|
||||||
|
compileSdk = 34
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "pt.naps.maestro"
|
||||||
|
minSdk = 26
|
||||||
|
targetSdk = 34
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "0.1.0"
|
||||||
|
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
getByName("debug") {
|
||||||
|
storeFile = file("debug.keystore")
|
||||||
|
storePassword = "android"
|
||||||
|
keyAlias = "androiddebugkey"
|
||||||
|
keyPassword = "android"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
debug {
|
||||||
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(libs.androidx.core.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.process)
|
||||||
|
implementation(libs.androidx.activity.compose)
|
||||||
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
|
implementation(libs.androidx.ui)
|
||||||
|
implementation(libs.androidx.ui.graphics)
|
||||||
|
implementation(libs.androidx.ui.tooling.preview)
|
||||||
|
implementation(libs.androidx.material3)
|
||||||
|
implementation(libs.androidx.navigation.compose)
|
||||||
|
implementation(libs.okhttp)
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
|
|
||||||
|
testImplementation(libs.junit)
|
||||||
|
testImplementation(libs.kotlinx.coroutines.test)
|
||||||
|
testImplementation(libs.okhttp.mockwebserver)
|
||||||
|
testImplementation(libs.turbine)
|
||||||
|
|
||||||
|
androidTestImplementation(libs.androidx.test.ext.junit)
|
||||||
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||||
|
androidTestImplementation(libs.androidx.ui.test.junit4)
|
||||||
|
|
||||||
|
debugImplementation(libs.androidx.ui.tooling)
|
||||||
|
debugImplementation(libs.androidx.ui.test.manifest)
|
||||||
|
}
|
||||||
Binary file not shown.
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
# You can control the set of applied configuration files using the
|
||||||
|
# proguardFiles setting in build.gradle.kts.
|
||||||
|
#
|
||||||
|
# For more details, see
|
||||||
|
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package pt.naps.maestro
|
||||||
|
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class MainActivityTest {
|
||||||
|
@get:Rule
|
||||||
|
val composeTestRule = createAndroidComposeRule<MainActivity>()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun placeholderScreen_showsAppName() {
|
||||||
|
composeTestRule.onNodeWithText("Maestro").assertExists()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:usesCleartextTraffic="true"
|
||||||
|
android:theme="@style/Theme.Maestro">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:theme="@style/Theme.Maestro">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package pt.naps.maestro
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material3.DrawerValue
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalDrawerSheet
|
||||||
|
import androidx.compose.material3.ModalNavigationDrawer
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.rememberDrawerState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import pt.naps.maestro.connection.DaemonLink
|
||||||
|
import pt.naps.maestro.connection.DaemonPrefs
|
||||||
|
import pt.naps.maestro.connection.LinkState
|
||||||
|
import pt.naps.maestro.ui.sidebar.SidebarContent
|
||||||
|
import pt.naps.maestro.ui.theme.MaestroTheme
|
||||||
|
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
private lateinit var daemonLink: DaemonLink
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
enableEdgeToEdge()
|
||||||
|
|
||||||
|
val prefs = DaemonPrefs(applicationContext)
|
||||||
|
intent?.getStringExtra(DaemonPrefs.EXTRA_DAEMON_URL)?.let { prefs.setBaseUrl(it) }
|
||||||
|
daemonLink = DaemonLink(applicationContext, prefs).also { it.start() }
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
MaestroTheme {
|
||||||
|
val linkState by daemonLink.state.collectAsState()
|
||||||
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
ModalNavigationDrawer(
|
||||||
|
drawerState = drawerState,
|
||||||
|
drawerContent = {
|
||||||
|
ModalDrawerSheet(modifier = Modifier.width(320.dp)) {
|
||||||
|
SidebarContent(
|
||||||
|
catalogStore = daemonLink.catalog,
|
||||||
|
onOpenSession = { scope.launch { drawerState.close() } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Maestro") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { scope.launch { drawerState.open() } }) {
|
||||||
|
Text("☰")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
PlaceholderScreen(modifier = Modifier.padding(innerPadding), linkState = linkState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
daemonLink.close()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PlaceholderScreen(modifier: Modifier = Modifier, linkState: LinkState = LinkState.CONNECTING) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(24.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Maestro",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.placeholder_message),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Daemon: ${linkState.name.lowercase()}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun PlaceholderScreenPreview() {
|
||||||
|
MaestroTheme {
|
||||||
|
PlaceholderScreen()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package pt.naps.maestro.api
|
||||||
|
|
||||||
|
import pt.naps.maestro.data.InTheWay
|
||||||
|
|
||||||
|
/** A `/api` failure with the daemon's own error envelope kept intact, so a
|
||||||
|
* caller can branch on [code] and render [unsavedWork] — the kill guard —
|
||||||
|
* rather than re-parsing [detail]. Mirrors the web client's
|
||||||
|
* `ApiRequestError`. */
|
||||||
|
class MaestroApiException(
|
||||||
|
val status: Int,
|
||||||
|
val code: String,
|
||||||
|
val detail: String,
|
||||||
|
val unsavedWork: InTheWay? = null,
|
||||||
|
) : Exception(buildMessage(status, code, detail)) {
|
||||||
|
private companion object {
|
||||||
|
fun buildMessage(status: Int, code: String, detail: String): String {
|
||||||
|
val info = if (code.isNotEmpty()) "$code: $detail" else detail
|
||||||
|
return if (info.isNotEmpty()) "$status — $info" else "$status"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
package pt.naps.maestro.api
|
||||||
|
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import okhttp3.Call
|
||||||
|
import okhttp3.Callback
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import okhttp3.Response
|
||||||
|
import pt.naps.maestro.data.ApiError
|
||||||
|
import pt.naps.maestro.data.BranchInfo
|
||||||
|
import pt.naps.maestro.data.CreateSessionRequest
|
||||||
|
import pt.naps.maestro.data.DaemonInfo
|
||||||
|
import pt.naps.maestro.data.DirListing
|
||||||
|
import pt.naps.maestro.data.GitRoot
|
||||||
|
import pt.naps.maestro.data.NameBatch
|
||||||
|
import pt.naps.maestro.data.Project
|
||||||
|
import pt.naps.maestro.data.RecentDirs
|
||||||
|
import pt.naps.maestro.data.RenameSessionRequest
|
||||||
|
import pt.naps.maestro.data.Session
|
||||||
|
import pt.naps.maestro.data.SetFocusRequest
|
||||||
|
import pt.naps.maestro.data.StructuredAttachmentResponse
|
||||||
|
import pt.naps.maestro.data.StructuredApprovalDecision
|
||||||
|
import pt.naps.maestro.data.StructuredConfigRequest
|
||||||
|
import pt.naps.maestro.data.StructuredPromptRequest
|
||||||
|
import pt.naps.maestro.data.Uuid
|
||||||
|
import java.io.IOException
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
import kotlin.coroutines.resumeWithException
|
||||||
|
|
||||||
|
private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType()
|
||||||
|
private val OCTET_STREAM_MEDIA_TYPE = "application/octet-stream".toMediaType()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typed client for the daemon's `/api` surface (REST half). Mirrors
|
||||||
|
* `maestro_protocol::client::DaemonClient` / `web/src/api.ts`.
|
||||||
|
*
|
||||||
|
* There is no authentication layer: reaching the daemon's bind address is
|
||||||
|
* the authorization, same trust model as tmux itself.
|
||||||
|
*/
|
||||||
|
class MaestroClient(
|
||||||
|
private val baseUrl: String,
|
||||||
|
private val httpClient: OkHttpClient = OkHttpClient(),
|
||||||
|
) {
|
||||||
|
private val json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
isLenient = true
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun listSessions(): List<Session> = get(Routes.SESSIONS)
|
||||||
|
|
||||||
|
suspend fun createSession(request: CreateSessionRequest): Session =
|
||||||
|
post(Routes.SESSIONS, request)
|
||||||
|
|
||||||
|
/** `PATCH /api/sessions/:id`: rename a live session. An empty [name]
|
||||||
|
* clears the custom one, so the card falls back to the command. */
|
||||||
|
suspend fun renameSession(id: Uuid, name: String): Session =
|
||||||
|
patch(Routes.session(id), RenameSessionRequest(name))
|
||||||
|
|
||||||
|
/** `DELETE /api/sessions/:id`. With [force], the daemon overrides the
|
||||||
|
* kill refusal over a worktree holding unsaved work and leaves that
|
||||||
|
* worktree on disk. */
|
||||||
|
suspend fun killSession(id: Uuid, force: Boolean = false) {
|
||||||
|
val query = if (force) listOf(Routes.FORCE_QUERY to "true") else emptyList()
|
||||||
|
executeNoContent(newRequest("DELETE", Routes.session(id), query = query))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `POST /api/sessions/:id/focus`: report whether this client's pane is
|
||||||
|
* the one currently showing [id] — not merely highlighted in a
|
||||||
|
* sidebar. */
|
||||||
|
suspend fun setSessionFocus(id: Uuid, focused: Boolean) {
|
||||||
|
executeNoContent(
|
||||||
|
newRequest(
|
||||||
|
"POST",
|
||||||
|
Routes.sessionFocus(id),
|
||||||
|
body = json.encodeToString(SetFocusRequest(focused)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `GET /api/dirs?path=`: one directory's immediate subdirectories.
|
||||||
|
* [path] may be empty, `~`, relative, or absolute — the daemon resolves
|
||||||
|
* it against its home dir. */
|
||||||
|
suspend fun listDir(path: String): DirListing =
|
||||||
|
get(Routes.DIRS, listOf(Routes.PATH_QUERY to path))
|
||||||
|
|
||||||
|
/** `GET /api/info`: identity + spawn presets of the connected daemon. */
|
||||||
|
suspend fun getDaemonInfo(): DaemonInfo = get(Routes.INFO)
|
||||||
|
|
||||||
|
/** `GET /api/recent-dirs`: recently-used working dirs, newest-first and
|
||||||
|
* deduped by dir. */
|
||||||
|
suspend fun recentDirs(): RecentDirs = get(Routes.RECENT_DIRS)
|
||||||
|
|
||||||
|
/** `GET /api/projects`: every discovered git repository/worktree,
|
||||||
|
* newest-activity-first. */
|
||||||
|
suspend fun listProjects(): List<Project> = get(Routes.PROJECTS)
|
||||||
|
|
||||||
|
/** `GET /api/gitroot?path=`: the enclosing git repo of [path]; absent
|
||||||
|
* when [path] isn't inside a repo. */
|
||||||
|
suspend fun gitRoot(path: String): GitRoot =
|
||||||
|
get(Routes.GITROOT, listOf(Routes.PATH_QUERY to path))
|
||||||
|
|
||||||
|
/** `GET /api/names?count=`: a batch of fresh session handles. */
|
||||||
|
suspend fun fetchNames(count: Int): NameBatch =
|
||||||
|
get(Routes.NAMES, listOf(Routes.COUNT_QUERY to count.toString()))
|
||||||
|
|
||||||
|
/** `GET /api/branches?path=`: one repository's local branches, with
|
||||||
|
* worktree occupancy. */
|
||||||
|
suspend fun listBranches(path: String): List<BranchInfo> =
|
||||||
|
get(Routes.BRANCHES, listOf(Routes.PATH_QUERY to path))
|
||||||
|
|
||||||
|
/** Send a prompt to a structured session's ACP turn. `409
|
||||||
|
* turn_in_flight` when one is already active. */
|
||||||
|
suspend fun sendStructuredPrompt(id: Uuid, prompt: String) {
|
||||||
|
executeNoContent(
|
||||||
|
newRequest(
|
||||||
|
"POST",
|
||||||
|
Routes.structuredPrompt(id),
|
||||||
|
body = json.encodeToString(StructuredPromptRequest(prompt)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upload a file attachment for a structured session's next prompt. */
|
||||||
|
suspend fun uploadStructuredAttachment(
|
||||||
|
id: Uuid,
|
||||||
|
fileName: String,
|
||||||
|
contents: ByteArray,
|
||||||
|
): StructuredAttachmentResponse {
|
||||||
|
val url = Routes.httpUrl(baseUrl, Routes.structuredAttachment(id)).toHttpUrl()
|
||||||
|
.newBuilder()
|
||||||
|
.addQueryParameter("name", fileName)
|
||||||
|
.build()
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.post(contents.toRequestBody(OCTET_STREAM_MEDIA_TYPE))
|
||||||
|
.build()
|
||||||
|
return decode(execute(request))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cancel a structured session's active ACP turn. */
|
||||||
|
suspend fun cancelStructuredTurn(id: Uuid) {
|
||||||
|
executeNoContent(newRequest("POST", Routes.structuredCancel(id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Change one option advertised by a live ACP session. */
|
||||||
|
suspend fun setStructuredConfig(id: Uuid, configId: String, value: JsonElement) {
|
||||||
|
executeNoContent(
|
||||||
|
newRequest(
|
||||||
|
"POST",
|
||||||
|
Routes.structuredConfig(id),
|
||||||
|
body = json.encodeToString(StructuredConfigRequest(configId, value)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Answer a pending ACP permission request. Always resolves once the
|
||||||
|
* session itself is a live structured session — a nonce that is unknown
|
||||||
|
* or already consumed still answers success. */
|
||||||
|
suspend fun resolveStructuredApproval(
|
||||||
|
id: Uuid,
|
||||||
|
nonce: String,
|
||||||
|
decision: StructuredApprovalDecision,
|
||||||
|
) {
|
||||||
|
executeNoContent(
|
||||||
|
newRequest(
|
||||||
|
"POST",
|
||||||
|
Routes.structuredApproval(id, nonce),
|
||||||
|
body = json.encodeToString(decision),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- plumbing ------------------------------------------------------
|
||||||
|
|
||||||
|
private suspend inline fun <reified T> get(
|
||||||
|
path: String,
|
||||||
|
query: List<Pair<String, String>> = emptyList(),
|
||||||
|
): T = decode(execute(newRequest("GET", path, query = query)))
|
||||||
|
|
||||||
|
private suspend inline fun <reified B, reified T> post(path: String, body: B): T =
|
||||||
|
decode(execute(newRequest("POST", path, body = json.encodeToString(body))))
|
||||||
|
|
||||||
|
private suspend inline fun <reified B, reified T> patch(path: String, body: B): T =
|
||||||
|
decode(execute(newRequest("PATCH", path, body = json.encodeToString(body))))
|
||||||
|
|
||||||
|
private fun newRequest(
|
||||||
|
method: String,
|
||||||
|
path: String,
|
||||||
|
query: List<Pair<String, String>> = emptyList(),
|
||||||
|
body: String? = null,
|
||||||
|
): Request {
|
||||||
|
val urlBuilder = Routes.httpUrl(baseUrl, path).toHttpUrl().newBuilder()
|
||||||
|
for ((key, value) in query) urlBuilder.addQueryParameter(key, value)
|
||||||
|
// OkHttp refuses a null body on POST/PUT/PATCH even when the daemon
|
||||||
|
// expects none (e.g. the structured-turn cancel) — an empty body
|
||||||
|
// satisfies it without implying a JSON payload.
|
||||||
|
val requestBody = when {
|
||||||
|
body != null -> body.toRequestBody(JSON_MEDIA_TYPE)
|
||||||
|
method == "POST" || method == "PUT" || method == "PATCH" -> ByteArray(0).toRequestBody(null)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
return Request.Builder()
|
||||||
|
.url(urlBuilder.build())
|
||||||
|
.method(method, requestBody)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun execute(request: Request): Response =
|
||||||
|
suspendCancellableCoroutine { cont ->
|
||||||
|
val call = httpClient.newCall(request)
|
||||||
|
cont.invokeOnCancellation { call.cancel() }
|
||||||
|
call.enqueue(
|
||||||
|
object : Callback {
|
||||||
|
override fun onFailure(call: Call, e: IOException) {
|
||||||
|
if (!cont.isCancelled) cont.resumeWithException(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResponse(call: Call, response: Response) {
|
||||||
|
cont.resume(response)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun executeNoContent(request: Request) {
|
||||||
|
execute(request).use { if (!it.isSuccessful) throw toApiException(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun <reified T> decode(response: Response): T =
|
||||||
|
response.use {
|
||||||
|
if (!it.isSuccessful) throw toApiException(it)
|
||||||
|
json.decodeFromString<T>(it.body?.string().orEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toApiException(response: Response): MaestroApiException {
|
||||||
|
val text = response.body?.string().orEmpty()
|
||||||
|
val info = runCatching { json.decodeFromString<ApiError>(text) }.getOrNull()?.error
|
||||||
|
return MaestroApiException(
|
||||||
|
status = response.code,
|
||||||
|
code = info?.code ?: "",
|
||||||
|
detail = info?.message ?: text,
|
||||||
|
unsavedWork = info?.unsaved_work,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package pt.naps.maestro.api
|
||||||
|
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import okhttp3.WebSocket
|
||||||
|
import okhttp3.WebSocketListener
|
||||||
|
import okio.ByteString
|
||||||
|
import pt.naps.maestro.data.CatalogEvent
|
||||||
|
import pt.naps.maestro.data.ClientControl
|
||||||
|
import pt.naps.maestro.data.StructuredEvent
|
||||||
|
import pt.naps.maestro.data.Uuid
|
||||||
|
|
||||||
|
private val wsJson = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
isLenient = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One frame received on `WS /api/attach/:id`: raw PTY bytes, or a decoded
|
||||||
|
* [ClientControl] text message. */
|
||||||
|
sealed class AttachFrame {
|
||||||
|
data class Pty(val bytes: ByteArray) : AttachFrame()
|
||||||
|
data class Control(val message: ClientControl) : AttachFrame()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A live `WS /api/attach/:id` connection. PTY bytes travel in binary frames
|
||||||
|
* both directions; control messages ([ClientControl]) travel in text
|
||||||
|
* frames. */
|
||||||
|
class AttachConnection internal constructor(
|
||||||
|
private val socket: WebSocket,
|
||||||
|
val frames: Flow<AttachFrame>,
|
||||||
|
) {
|
||||||
|
/** Type raw bytes into the session's pane, as if typed on an attached
|
||||||
|
* terminal. */
|
||||||
|
fun sendInput(bytes: ByteArray) {
|
||||||
|
socket.send(ByteString.of(*bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resize(cols: Int, rows: Int) {
|
||||||
|
sendControl(ClientControl.Resize(cols, rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ping() {
|
||||||
|
sendControl(ClientControl.Ping)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendControl(message: ClientControl) {
|
||||||
|
socket.send(wsJson.encodeToString(message))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun close() {
|
||||||
|
socket.close(1000, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A live `WS /api/structured/:id` connection: transcript/state snapshots
|
||||||
|
* and live deltas for one structured session. */
|
||||||
|
class StructuredConnection internal constructor(
|
||||||
|
private val socket: WebSocket,
|
||||||
|
val events: Flow<StructuredEvent>,
|
||||||
|
) {
|
||||||
|
fun close() {
|
||||||
|
socket.close(1000, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A live `WS /api/events` connection: catalog snapshot + session deltas. */
|
||||||
|
class EventsConnection internal constructor(
|
||||||
|
private val socket: WebSocket,
|
||||||
|
val events: Flow<CatalogEvent>,
|
||||||
|
) {
|
||||||
|
fun close() {
|
||||||
|
socket.close(1000, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebSocket half of the daemon's `/api` surface. Mirrors the `*_url`
|
||||||
|
* helpers in `crates/maestro-protocol/src/routes.rs`.
|
||||||
|
*
|
||||||
|
* Each `connect*` call opens the socket immediately and returns a handle
|
||||||
|
* whose `Flow` starts buffering frames right away — a slow collector never
|
||||||
|
* misses a frame, it only sees it later. Call `close()` on the returned
|
||||||
|
* connection when done; there is no automatic reconnect.
|
||||||
|
*/
|
||||||
|
class MaestroWebSocket(
|
||||||
|
private val baseUrl: String,
|
||||||
|
private val httpClient: OkHttpClient = OkHttpClient(),
|
||||||
|
) {
|
||||||
|
/** `WS /api/events`: catalog snapshot + session deltas. */
|
||||||
|
fun connectEvents(): EventsConnection {
|
||||||
|
val channel = Channel<CatalogEvent>(Channel.BUFFERED)
|
||||||
|
val socket = open(
|
||||||
|
Routes.eventsWsUrl(baseUrl),
|
||||||
|
object : WebSocketListener() {
|
||||||
|
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||||
|
runCatching { wsJson.decodeFromString<CatalogEvent>(text) }
|
||||||
|
.onSuccess { channel.trySend(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||||
|
channel.close(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
|
channel.close()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return EventsConnection(socket, channel.receiveAsFlow())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `WS /api/attach/:id`: raw PTY bytes both directions, control messages
|
||||||
|
* as text JSON. */
|
||||||
|
fun connectAttach(id: Uuid): AttachConnection {
|
||||||
|
val channel = Channel<AttachFrame>(Channel.BUFFERED)
|
||||||
|
val socket = open(
|
||||||
|
Routes.attachWsUrl(baseUrl, id),
|
||||||
|
object : WebSocketListener() {
|
||||||
|
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||||
|
runCatching { wsJson.decodeFromString<ClientControl>(text) }
|
||||||
|
.onSuccess { channel.trySend(AttachFrame.Control(it)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
|
||||||
|
channel.trySend(AttachFrame.Pty(bytes.toByteArray()))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||||
|
channel.close(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
|
channel.close()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return AttachConnection(socket, channel.receiveAsFlow())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `WS /api/structured/:id`: transcript/state snapshots and live deltas
|
||||||
|
* for one structured session. */
|
||||||
|
fun connectStructured(id: Uuid): StructuredConnection {
|
||||||
|
val channel = Channel<StructuredEvent>(Channel.BUFFERED)
|
||||||
|
val socket = open(
|
||||||
|
Routes.structuredWsUrl(baseUrl, id),
|
||||||
|
object : WebSocketListener() {
|
||||||
|
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||||
|
runCatching { wsJson.decodeFromString<StructuredEvent>(text) }
|
||||||
|
.onSuccess { channel.trySend(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||||
|
channel.close(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
|
channel.close()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return StructuredConnection(socket, channel.receiveAsFlow())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun open(url: String, listener: WebSocketListener): WebSocket {
|
||||||
|
val request = Request.Builder().url(url).build()
|
||||||
|
return httpClient.newWebSocket(request, listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package pt.naps.maestro.api
|
||||||
|
|
||||||
|
import pt.naps.maestro.data.Uuid
|
||||||
|
|
||||||
|
/** The daemon's `/api` surface: paths, and the scheme swap needed to reach
|
||||||
|
* the WebSocket endpoints. Mirrors `maestro-protocol::routes` /
|
||||||
|
* `web/src/routes.ts`. */
|
||||||
|
object Routes {
|
||||||
|
const val API_PREFIX = "/api"
|
||||||
|
|
||||||
|
const val SESSIONS = "/api/sessions"
|
||||||
|
const val INFO = "/api/info"
|
||||||
|
const val DIRS = "/api/dirs"
|
||||||
|
const val RECENT_DIRS = "/api/recent-dirs"
|
||||||
|
const val PROJECTS = "/api/projects"
|
||||||
|
const val GITROOT = "/api/gitroot"
|
||||||
|
const val NAMES = "/api/names"
|
||||||
|
const val BRANCHES = "/api/branches"
|
||||||
|
const val EVENTS = "/api/events"
|
||||||
|
|
||||||
|
/** Name of the query parameter [DIRS], [GITROOT] and [BRANCHES] take. */
|
||||||
|
const val PATH_QUERY = "path"
|
||||||
|
|
||||||
|
/** Name of the query parameter `DELETE /api/sessions/:id` takes:
|
||||||
|
* `force=true` overrides the kill refusal over a guarded worktree. */
|
||||||
|
const val FORCE_QUERY = "force"
|
||||||
|
|
||||||
|
/** Name of the query parameter [NAMES] takes. */
|
||||||
|
const val COUNT_QUERY = "count"
|
||||||
|
|
||||||
|
fun session(id: Uuid): String = "$SESSIONS/$id"
|
||||||
|
|
||||||
|
fun sessionFocus(id: Uuid): String = "${session(id)}/focus"
|
||||||
|
|
||||||
|
fun attach(id: Uuid): String = "$API_PREFIX/attach/$id"
|
||||||
|
|
||||||
|
/** `WS /api/structured/:id` — transcript/state snapshots and live
|
||||||
|
* deltas. */
|
||||||
|
fun structured(id: Uuid): String = "$API_PREFIX/structured/$id"
|
||||||
|
|
||||||
|
/** `POST /api/sessions/:id/structured/prompt` — send an ACP prompt. */
|
||||||
|
fun structuredPrompt(id: Uuid): String = "${session(id)}/structured/prompt"
|
||||||
|
|
||||||
|
fun structuredAttachment(id: Uuid): String = "${session(id)}/structured/attachments"
|
||||||
|
|
||||||
|
/** `POST /api/sessions/:id/structured/cancel` — cancel an ACP turn. */
|
||||||
|
fun structuredCancel(id: Uuid): String = "${session(id)}/structured/cancel"
|
||||||
|
|
||||||
|
/** `POST /api/sessions/:id/structured/config` — change an ACP session
|
||||||
|
* option. */
|
||||||
|
fun structuredConfig(id: Uuid): String = "${session(id)}/structured/config"
|
||||||
|
|
||||||
|
/** `POST /api/sessions/:id/structured/approvals/:nonce` — answer a
|
||||||
|
* permission request. */
|
||||||
|
fun structuredApproval(id: Uuid, nonce: String): String =
|
||||||
|
"${session(id)}/structured/approvals/$nonce"
|
||||||
|
|
||||||
|
/** Strip a trailing slash so `{base}{path}` never doubles up. */
|
||||||
|
fun normalizeBase(base: String): String = base.trimEnd('/')
|
||||||
|
|
||||||
|
/** Join a daemon base URL and one of this object's paths. */
|
||||||
|
fun httpUrl(base: String, path: String): String = "${normalizeBase(base)}$path"
|
||||||
|
|
||||||
|
/** Swap an HTTP base URL for its WebSocket equivalent: `http://` becomes
|
||||||
|
* `ws://`, `https://` becomes `wss://`; a base already `ws(s)://` passes
|
||||||
|
* through. */
|
||||||
|
fun wsBase(httpBase: String): String {
|
||||||
|
val base = normalizeBase(httpBase)
|
||||||
|
return when {
|
||||||
|
base.startsWith("https://") -> "wss://${base.removePrefix("https://")}"
|
||||||
|
base.startsWith("http://") -> "ws://${base.removePrefix("http://")}"
|
||||||
|
base.startsWith("wss://") || base.startsWith("ws://") -> base
|
||||||
|
else -> throw IllegalArgumentException(
|
||||||
|
"daemon url has no supported scheme (want http/https/ws/wss): $httpBase",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun eventsWsUrl(base: String): String = "${wsBase(base)}$EVENTS"
|
||||||
|
|
||||||
|
fun attachWsUrl(base: String, id: Uuid): String = "${wsBase(base)}${attach(id)}"
|
||||||
|
|
||||||
|
fun structuredWsUrl(base: String, id: Uuid): String = "${wsBase(base)}${structured(id)}"
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package pt.naps.maestro.connection
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exponential backoff for daemon reconnect attempts: doubles from
|
||||||
|
* [initialMs] up to [maxMs], then holds. A phone's radio swinging between
|
||||||
|
* wifi and cellular (or losing signal outright) is a bigger, longer-lived
|
||||||
|
* change than the LAN blips the web/TUI clients ride out with a flat poll,
|
||||||
|
* so this backs off instead of hammering the daemon while nothing changed.
|
||||||
|
*/
|
||||||
|
class Backoff(
|
||||||
|
private val initialMs: Long = INITIAL_MS,
|
||||||
|
private val maxMs: Long = MAX_MS,
|
||||||
|
) {
|
||||||
|
private var currentMs = initialMs
|
||||||
|
|
||||||
|
/** Delay before the next attempt; advances the sequence for the one after. */
|
||||||
|
fun nextDelayMs(): Long {
|
||||||
|
val delay = currentMs
|
||||||
|
currentMs = (currentMs * 2).coerceAtMost(maxMs)
|
||||||
|
return delay
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Back to [initialMs] after a successful connection. */
|
||||||
|
fun reset() {
|
||||||
|
currentMs = initialMs
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val INITIAL_MS = 500L
|
||||||
|
const val MAX_MS = 10_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package pt.naps.maestro.connection
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import pt.naps.maestro.data.CatalogEvent
|
||||||
|
import pt.naps.maestro.data.Session
|
||||||
|
import pt.naps.maestro.data.Uuid
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session catalog folded from `WS /api/events`. A [CatalogEvent.Snapshot]
|
||||||
|
* replaces the map outright; deltas mutate one entry.
|
||||||
|
*/
|
||||||
|
class CatalogStore {
|
||||||
|
private val _sessions = MutableStateFlow<Map<Uuid, Session>>(emptyMap())
|
||||||
|
val sessions: StateFlow<Map<Uuid, Session>> = _sessions.asStateFlow()
|
||||||
|
|
||||||
|
/** Applies one event. Returns the skip count for [CatalogEvent.Lagged] —
|
||||||
|
* the caller re-fetches a full snapshot over REST and calls [replace],
|
||||||
|
* mirroring how the web client reacts to a dropped broadcast frame. */
|
||||||
|
fun apply(event: CatalogEvent): Long? =
|
||||||
|
when (event) {
|
||||||
|
is CatalogEvent.Snapshot -> {
|
||||||
|
_sessions.value = event.sessions.associateBy { it.id }
|
||||||
|
null
|
||||||
|
}
|
||||||
|
is CatalogEvent.SessionAdded -> {
|
||||||
|
_sessions.value = _sessions.value + (event.session.id to event.session)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
is CatalogEvent.SessionUpdated -> {
|
||||||
|
_sessions.value = _sessions.value + (event.session.id to event.session)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
is CatalogEvent.SessionRemoved -> {
|
||||||
|
_sessions.value = _sessions.value - event.id
|
||||||
|
null
|
||||||
|
}
|
||||||
|
is CatalogEvent.Lagged -> event.skipped
|
||||||
|
}
|
||||||
|
|
||||||
|
fun replace(sessions: List<Session>) {
|
||||||
|
_sessions.value = sessions.associateBy { it.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
_sessions.value = emptyMap()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package pt.naps.maestro.connection
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.Network
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
import android.net.NetworkRequest
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notifies [onAvailable] whenever a usable network appears — including the
|
||||||
|
* wifi/cellular handoff, which Android reports as one network disappearing
|
||||||
|
* and another becoming available rather than a single "changed" event.
|
||||||
|
* [DaemonLink] treats every call as "worth reconnecting for" rather than
|
||||||
|
* inspecting which network it is.
|
||||||
|
*/
|
||||||
|
class ConnectivityObserver(
|
||||||
|
context: Context,
|
||||||
|
private val onAvailable: () -> Unit,
|
||||||
|
) {
|
||||||
|
private val connectivityManager =
|
||||||
|
context.applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||||
|
|
||||||
|
private val callback = object : ConnectivityManager.NetworkCallback() {
|
||||||
|
override fun onAvailable(network: Network) = onAvailable.invoke()
|
||||||
|
|
||||||
|
override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
|
||||||
|
if (networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
|
||||||
|
networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||||
|
) {
|
||||||
|
onAvailable.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var registered = false
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
if (registered) return
|
||||||
|
val request = NetworkRequest.Builder()
|
||||||
|
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||||
|
.build()
|
||||||
|
connectivityManager.registerNetworkCallback(request, callback)
|
||||||
|
registered = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
if (!registered) return
|
||||||
|
runCatching { connectivityManager.unregisterNetworkCallback(callback) }
|
||||||
|
registered = false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package pt.naps.maestro.connection
|
||||||
|
|
||||||
|
import pt.naps.maestro.data.BuildInfo
|
||||||
|
import pt.naps.maestro.data.DaemonInfo
|
||||||
|
|
||||||
|
/** What happened between two `/api/info` replies. */
|
||||||
|
enum class RestartKind { SAME, RESTARTED, UPGRADED }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare two daemon replies. Mirrors `compareIdentity` in
|
||||||
|
* `web/src/components/daemon/DaemonLink.tsx` / `compare_identity` in
|
||||||
|
* `crates/maestro-tui/src/daemon_link.rs`.
|
||||||
|
*
|
||||||
|
* The commit decides first: a different one is new server code. `instance`
|
||||||
|
* catches everything else — a restart of the same build — and is the only
|
||||||
|
* signal for a daemon built outside a git checkout, where the commit is
|
||||||
|
* always empty and an empty-vs-empty pair must never read as an upgrade.
|
||||||
|
*/
|
||||||
|
fun compareIdentity(before: DaemonInfo, after: DaemonInfo): RestartKind {
|
||||||
|
val a = before.build.commit
|
||||||
|
val b = after.build.commit
|
||||||
|
if (a.isNotEmpty() && b.isNotEmpty() && a != b) return RestartKind.UPGRADED
|
||||||
|
if (before.instance.isNotEmpty() && after.instance.isNotEmpty() && before.instance != after.instance) {
|
||||||
|
return RestartKind.RESTARTED
|
||||||
|
}
|
||||||
|
return RestartKind.SAME
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One line naming a release: `f89be4a716c7 · 2026-09-02`, `(dirty)` when the
|
||||||
|
* build carried uncommitted changes. Mirrors `formatRelease` / `format_release`. */
|
||||||
|
fun formatRelease(build: BuildInfo): String {
|
||||||
|
if (build.commit.isEmpty()) return "unknown release"
|
||||||
|
val parts = mutableListOf(build.commit)
|
||||||
|
if (build.commit_date.length >= 10) parts.add("· ${build.commit_date.take(10)}")
|
||||||
|
if (build.dirty) parts.add("(dirty)")
|
||||||
|
return parts.joinToString(" ")
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
package pt.naps.maestro.connection
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.lifecycle.DefaultLifecycleObserver
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import androidx.lifecycle.ProcessLifecycleOwner
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import pt.naps.maestro.api.EventsConnection
|
||||||
|
import pt.naps.maestro.api.MaestroClient
|
||||||
|
import pt.naps.maestro.api.MaestroWebSocket
|
||||||
|
import pt.naps.maestro.data.BuildInfo
|
||||||
|
import pt.naps.maestro.data.DaemonInfo
|
||||||
|
|
||||||
|
/** How long a dropped link stays [LinkState.RECONNECTING] before flipping to
|
||||||
|
* [LinkState.DOWN] — long enough to ride out a blip, short enough that a real
|
||||||
|
* outage shows up. Mirrors the grace window in `docs/RESTART.md`. */
|
||||||
|
private const val GRACE_MS = 1_200L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the connection to one daemon for the app's whole life: liveness,
|
||||||
|
* identity, and the session catalog. A Kotlin port of the state machine in
|
||||||
|
* `web/src/components/daemon/DaemonLink.tsx` / `crates/maestro-tui/src/daemon_link.rs`
|
||||||
|
* — see `docs/RESTART.md` for the contract both mirror — with exponential
|
||||||
|
* [Backoff] standing in for their flat poll, since a phone's network changes
|
||||||
|
* are bigger and longer-lived than the LAN blips those clients ride out.
|
||||||
|
*
|
||||||
|
* Everything that can change the link — a probe landing, the events socket
|
||||||
|
* dropping, the app resuming, the network coming back, a new daemon URL —
|
||||||
|
* goes through one [signals] channel consumed by one coroutine, so state
|
||||||
|
* transitions never race each other.
|
||||||
|
*/
|
||||||
|
class DaemonLink(
|
||||||
|
context: Context,
|
||||||
|
private val prefs: DaemonPrefs,
|
||||||
|
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
||||||
|
private val httpClient: OkHttpClient = OkHttpClient(),
|
||||||
|
) : DefaultLifecycleObserver {
|
||||||
|
private val appContext = context.applicationContext
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(LinkState.CONNECTING)
|
||||||
|
val state: StateFlow<LinkState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private val _build = MutableStateFlow<BuildInfo?>(null)
|
||||||
|
val build: StateFlow<BuildInfo?> = _build.asStateFlow()
|
||||||
|
|
||||||
|
private val _attempts = MutableStateFlow(0)
|
||||||
|
val attempts: StateFlow<Int> = _attempts.asStateFlow()
|
||||||
|
|
||||||
|
val catalog = CatalogStore()
|
||||||
|
|
||||||
|
private val backoff = Backoff()
|
||||||
|
private val signals = Channel<Signal>(Channel.UNLIMITED)
|
||||||
|
private val connectivity = ConnectivityObserver(appContext) { signals.trySend(Signal.NetworkAvailable) }
|
||||||
|
|
||||||
|
private var generation = 0
|
||||||
|
private var currentBaseUrl: String = prefs.baseUrl.value
|
||||||
|
private var client = MaestroClient(currentBaseUrl, httpClient)
|
||||||
|
private var socket = MaestroWebSocket(currentBaseUrl, httpClient)
|
||||||
|
private var identity: DaemonInfo? = null
|
||||||
|
|
||||||
|
private var eventsJob: Job? = null
|
||||||
|
private var eventsConnection: EventsConnection? = null
|
||||||
|
private var retryJob: Job? = null
|
||||||
|
private var graceJob: Job? = null
|
||||||
|
private var loopJob: Job? = null
|
||||||
|
private var started = false
|
||||||
|
|
||||||
|
private sealed class Signal {
|
||||||
|
object Start : Signal()
|
||||||
|
object Resume : Signal()
|
||||||
|
object NetworkAvailable : Signal()
|
||||||
|
object SocketDropped : Signal()
|
||||||
|
data class BaseUrlChanged(val url: String) : Signal()
|
||||||
|
data class ProbeResult(val generation: Int, val info: DaemonInfo?) : Signal()
|
||||||
|
data class Lagged(val skipped: Long) : Signal()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Starts the link: subscribes to process lifecycle and connectivity, and
|
||||||
|
* kicks off the first probe. Call once. */
|
||||||
|
fun start() {
|
||||||
|
if (started) return
|
||||||
|
started = true
|
||||||
|
loopJob = scope.launch { runLoop() }
|
||||||
|
connectivity.start()
|
||||||
|
scope.launch(Dispatchers.Main.immediate) {
|
||||||
|
ProcessLifecycleOwner.get().lifecycle.addObserver(this@DaemonLink)
|
||||||
|
}
|
||||||
|
scope.launch {
|
||||||
|
prefs.baseUrl.collect { url ->
|
||||||
|
if (url != currentBaseUrl) signals.trySend(Signal.BaseUrlChanged(url))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
signals.trySend(Signal.Start)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tears everything down. Only needed by tests and by an owner that is
|
||||||
|
* itself going away for good (this link is meant to outlive an Activity). */
|
||||||
|
fun close() {
|
||||||
|
connectivity.stop()
|
||||||
|
cancelRetry()
|
||||||
|
cancelGrace()
|
||||||
|
closeEvents()
|
||||||
|
loopJob?.cancel()
|
||||||
|
signals.close()
|
||||||
|
scope.launch(Dispatchers.Main.immediate) {
|
||||||
|
runCatching { ProcessLifecycleOwner.get().lifecycle.removeObserver(this@DaemonLink) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStart(owner: LifecycleOwner) {
|
||||||
|
signals.trySend(Signal.Resume)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun runLoop() {
|
||||||
|
for (signal in signals) {
|
||||||
|
when (signal) {
|
||||||
|
is Signal.Start -> probeNow()
|
||||||
|
is Signal.Resume -> {
|
||||||
|
probeNow()
|
||||||
|
ensureEventsConnected()
|
||||||
|
}
|
||||||
|
is Signal.NetworkAvailable -> {
|
||||||
|
cancelRetry()
|
||||||
|
probeNow()
|
||||||
|
}
|
||||||
|
is Signal.SocketDropped -> handleSocketDropped()
|
||||||
|
is Signal.BaseUrlChanged -> handleBaseUrlChanged(signal.url)
|
||||||
|
is Signal.ProbeResult -> {
|
||||||
|
if (signal.generation == generation) handleProbeResult(signal.info)
|
||||||
|
}
|
||||||
|
is Signal.Lagged -> handleLagged()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun probeNow() {
|
||||||
|
val gen = generation
|
||||||
|
val currentClient = client
|
||||||
|
scope.launch {
|
||||||
|
val info = runCatching { currentClient.getDaemonInfo() }.getOrNull()
|
||||||
|
signals.trySend(Signal.ProbeResult(gen, info))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleProbeResult(info: DaemonInfo?) {
|
||||||
|
if (info == null) {
|
||||||
|
onProbeFailed()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val before = identity
|
||||||
|
identity = info
|
||||||
|
_build.value = info.build
|
||||||
|
if (before != null && compareIdentity(before, info) == RestartKind.UPGRADED) {
|
||||||
|
_state.value = LinkState.UPDATING
|
||||||
|
fullReconnect()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
backoff.reset()
|
||||||
|
cancelRetry()
|
||||||
|
cancelGrace()
|
||||||
|
_attempts.value = 0
|
||||||
|
_state.value = LinkState.ONLINE
|
||||||
|
ensureEventsConnected()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onProbeFailed() {
|
||||||
|
_attempts.value += 1
|
||||||
|
if (_state.value != LinkState.DOWN && _state.value != LinkState.UPDATING) {
|
||||||
|
_state.value = LinkState.RECONNECTING
|
||||||
|
armGrace()
|
||||||
|
}
|
||||||
|
scheduleRetry()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun armGrace() {
|
||||||
|
if (graceJob?.isActive == true) return
|
||||||
|
graceJob = scope.launch {
|
||||||
|
delay(GRACE_MS)
|
||||||
|
if (_state.value == LinkState.RECONNECTING) _state.value = LinkState.DOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelGrace() {
|
||||||
|
graceJob?.cancel()
|
||||||
|
graceJob = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleRetry() {
|
||||||
|
if (retryJob?.isActive == true) return
|
||||||
|
val delayMs = backoff.nextDelayMs()
|
||||||
|
retryJob = scope.launch {
|
||||||
|
delay(delayMs)
|
||||||
|
retryJob = null
|
||||||
|
probeNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelRetry() {
|
||||||
|
retryJob?.cancel()
|
||||||
|
retryJob = null
|
||||||
|
backoff.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A build-stamp change: drop the events socket and the catalog it fed,
|
||||||
|
* then probe again shortly. The re-probe sees the same identity it just
|
||||||
|
* recorded, so it resolves straight to [LinkState.ONLINE] and reopens
|
||||||
|
* the socket — this *is* the full reconnect. */
|
||||||
|
private fun fullReconnect() {
|
||||||
|
closeEvents()
|
||||||
|
catalog.clear()
|
||||||
|
backoff.reset()
|
||||||
|
scope.launch {
|
||||||
|
delay(200)
|
||||||
|
probeNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleSocketDropped() {
|
||||||
|
if (_state.value == LinkState.DOWN || _state.value == LinkState.UPDATING) return
|
||||||
|
_state.value = LinkState.RECONNECTING
|
||||||
|
armGrace()
|
||||||
|
probeNow()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleBaseUrlChanged(url: String) {
|
||||||
|
currentBaseUrl = url
|
||||||
|
generation += 1
|
||||||
|
client = MaestroClient(url, httpClient)
|
||||||
|
socket = MaestroWebSocket(url, httpClient)
|
||||||
|
identity = null
|
||||||
|
_build.value = null
|
||||||
|
_attempts.value = 0
|
||||||
|
backoff.reset()
|
||||||
|
cancelRetry()
|
||||||
|
cancelGrace()
|
||||||
|
closeEvents()
|
||||||
|
catalog.clear()
|
||||||
|
_state.value = LinkState.CONNECTING
|
||||||
|
probeNow()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The broadcast channel dropped frames: re-fetch the full list over REST
|
||||||
|
* rather than trusting the map to still be complete. */
|
||||||
|
private fun handleLagged() {
|
||||||
|
val currentClient = client
|
||||||
|
scope.launch {
|
||||||
|
runCatching { currentClient.listSessions() }.onSuccess { catalog.replace(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureEventsConnected() {
|
||||||
|
if (eventsJob?.isActive == true) return
|
||||||
|
val connection = socket.connectEvents()
|
||||||
|
eventsConnection = connection
|
||||||
|
eventsJob = scope.launch {
|
||||||
|
try {
|
||||||
|
connection.events.collect { event ->
|
||||||
|
val skipped = catalog.apply(event)
|
||||||
|
if (skipped != null) signals.trySend(Signal.Lagged(skipped))
|
||||||
|
}
|
||||||
|
signals.trySend(Signal.SocketDropped)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
signals.trySend(Signal.SocketDropped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun closeEvents() {
|
||||||
|
eventsJob?.cancel()
|
||||||
|
eventsJob = null
|
||||||
|
eventsConnection?.close()
|
||||||
|
eventsConnection = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package pt.naps.maestro.connection
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.core.content.edit
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the daemon base URL lives between launches. A settings screen and
|
||||||
|
* the [EXTRA_DAEMON_URL] intent extra both write through here; [DaemonLink]
|
||||||
|
* reads [baseUrl] and reconnects on change.
|
||||||
|
*/
|
||||||
|
class DaemonPrefs(context: Context) {
|
||||||
|
private val prefs = context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
private val _baseUrl =
|
||||||
|
MutableStateFlow(prefs.getString(KEY_BASE_URL, DEFAULT_BASE_URL) ?: DEFAULT_BASE_URL)
|
||||||
|
val baseUrl: StateFlow<String> = _baseUrl.asStateFlow()
|
||||||
|
|
||||||
|
fun setBaseUrl(url: String) {
|
||||||
|
val trimmed = url.trim()
|
||||||
|
if (trimmed.isEmpty() || trimmed == _baseUrl.value) return
|
||||||
|
prefs.edit { putString(KEY_BASE_URL, trimmed) }
|
||||||
|
_baseUrl.value = trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val FILE = "daemon_prefs"
|
||||||
|
private const val KEY_BASE_URL = "base_url"
|
||||||
|
|
||||||
|
/** Shown until the user points the app at their own daemon. */
|
||||||
|
const val DEFAULT_BASE_URL = "http://192.168.1.x:7070"
|
||||||
|
|
||||||
|
/** Intent extra a launcher may set to point this launch at a specific
|
||||||
|
* daemon without going through settings. Persisted on read, so it
|
||||||
|
* sticks after the launch that carried it. */
|
||||||
|
const val EXTRA_DAEMON_URL = "pt.naps.maestro.EXTRA_DAEMON_URL"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package pt.naps.maestro.connection
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connection lifecycle to the daemon. Mirrors the states in
|
||||||
|
* `web/src/components/daemon/DaemonLink.tsx` / `crates/maestro-tui/src/daemon_link.rs`
|
||||||
|
* (see `docs/RESTART.md`), plus [UPDATING] for a build-stamp change.
|
||||||
|
*
|
||||||
|
* - [CONNECTING] — first probe hasn't landed yet.
|
||||||
|
* - [ONLINE] — the daemon answered and identity is settled.
|
||||||
|
* - [RECONNECTING] — the link dropped, still inside the grace window.
|
||||||
|
* - [DOWN] — unreachable past the grace window; the restart overlay blocks.
|
||||||
|
* - [UPDATING] — the daemon answered on a different commit; reconnecting fresh.
|
||||||
|
*/
|
||||||
|
enum class LinkState { CONNECTING, ONLINE, RECONNECTING, DOWN, UPDATING }
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package pt.naps.maestro.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.KSerializer
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||||
|
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||||
|
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||||
|
import kotlinx.serialization.encoding.Decoder
|
||||||
|
import kotlinx.serialization.encoding.Encoder
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session activity, orthogonal to [SessionStatus]. Mirrors
|
||||||
|
* `maestro_protocol::ActivityState`.
|
||||||
|
*
|
||||||
|
* Deserializes leniently: an unknown variant from a newer daemon decodes as
|
||||||
|
* [Idle] instead of failing the whole [Session].
|
||||||
|
*/
|
||||||
|
@Serializable(with = ActivityStateSerializer::class)
|
||||||
|
enum class ActivityState {
|
||||||
|
Active,
|
||||||
|
Idle,
|
||||||
|
AwaitingInput,
|
||||||
|
Error,
|
||||||
|
Starting,
|
||||||
|
}
|
||||||
|
|
||||||
|
object ActivityStateSerializer : KSerializer<ActivityState> {
|
||||||
|
override val descriptor: SerialDescriptor =
|
||||||
|
PrimitiveSerialDescriptor("ActivityState", PrimitiveKind.STRING)
|
||||||
|
|
||||||
|
override fun serialize(encoder: Encoder, value: ActivityState) {
|
||||||
|
encoder.encodeString(value.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun deserialize(decoder: Decoder): ActivityState =
|
||||||
|
when (decoder.decodeString()) {
|
||||||
|
"Active" -> ActivityState.Active
|
||||||
|
"AwaitingInput" -> ActivityState.AwaitingInput
|
||||||
|
"Error" -> ActivityState.Error
|
||||||
|
"Starting" -> ActivityState.Starting
|
||||||
|
else -> ActivityState.Idle
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package pt.naps.maestro.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/** Structured failure body inside an [ApiError] envelope. Mirrors
|
||||||
|
* `maestro_protocol::ErrorInfo`. */
|
||||||
|
@Serializable
|
||||||
|
data class ErrorInfo(
|
||||||
|
val code: String,
|
||||||
|
val message: String,
|
||||||
|
/** Set only by the kill guard: what blocked an unforced
|
||||||
|
* `DELETE /api/sessions/:id`. */
|
||||||
|
val unsaved_work: InTheWay? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The envelope every `/api` failure is wrapped in: `{"error": {...}}`.
|
||||||
|
* Mirrors `maestro_protocol::ApiError`. */
|
||||||
|
@Serializable
|
||||||
|
data class ApiError(val error: ErrorInfo)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package pt.naps.maestro.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/** One local branch of a repository, from `GET /api/branches`. A client
|
||||||
|
* disables the entry when [is_head] is set or [worktree] is present — git
|
||||||
|
* refuses to check the same branch out in two worktrees at once. Mirrors
|
||||||
|
* `maestro_protocol::BranchInfo`. */
|
||||||
|
@Serializable
|
||||||
|
data class BranchInfo(
|
||||||
|
/** Short branch name (`main`, `feature/foo`). */
|
||||||
|
val name: String,
|
||||||
|
/** Whether this branch is the main checkout's current HEAD. */
|
||||||
|
val is_head: Boolean,
|
||||||
|
/** Toplevel path of the linked worktree holding this branch, if any. */
|
||||||
|
val worktree: String? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package pt.naps.maestro.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Messages the daemon broadcasts to clients over `WS /api/events`. Mirrors
|
||||||
|
* `maestro_protocol::CatalogEvent`; tagged by a `type` field.
|
||||||
|
*
|
||||||
|
* A subscriber receives a single [Snapshot] immediately on connect, then a
|
||||||
|
* stream of deltas as sessions are created, updated, and killed. Skipped
|
||||||
|
* events (broadcast channel lag) surface as [Lagged].
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
sealed class CatalogEvent {
|
||||||
|
@Serializable
|
||||||
|
@SerialName("Snapshot")
|
||||||
|
data class Snapshot(val sessions: List<Session>) : CatalogEvent()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("SessionAdded")
|
||||||
|
data class SessionAdded(val session: Session) : CatalogEvent()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("SessionRemoved")
|
||||||
|
data class SessionRemoved(val id: Uuid) : CatalogEvent()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("SessionUpdated")
|
||||||
|
data class SessionUpdated(val session: Session) : CatalogEvent()
|
||||||
|
|
||||||
|
/** Server-issued notification that the broadcast queue dropped frames.
|
||||||
|
* Clients should re-fetch a full snapshot. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("Lagged")
|
||||||
|
data class Lagged(val skipped: Long) : CatalogEvent()
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package pt.naps.maestro.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/** Text-frame control messages on a `WS /api/attach/:id` connection. PTY
|
||||||
|
* bytes travel in binary frames and are not modeled here. Mirrors
|
||||||
|
* `maestro_protocol::ClientControl`; tagged by a `type` field. */
|
||||||
|
@Serializable
|
||||||
|
sealed class ClientControl {
|
||||||
|
@Serializable
|
||||||
|
@SerialName("Resize")
|
||||||
|
data class Resize(val cols: Int, val rows: Int) : ClientControl()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("Ping")
|
||||||
|
object Ping : ClientControl()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("Pong")
|
||||||
|
object Pong : ClientControl()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("Error")
|
||||||
|
data class Error(
|
||||||
|
val code: String,
|
||||||
|
val message: String,
|
||||||
|
val unsaved_work: InTheWay? = null,
|
||||||
|
) : ClientControl()
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package pt.naps.maestro.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/** `GET /api/info` — the daemon's identity plus its spawn presets. Mirrors
|
||||||
|
* `maestro_protocol::DaemonInfo`. */
|
||||||
|
@Serializable
|
||||||
|
data class DaemonInfo(
|
||||||
|
val hostname: String = "",
|
||||||
|
/** The protocol version the daemon implements. */
|
||||||
|
val version: String = "",
|
||||||
|
/** Home dir of the user the daemon runs as; empty for older daemons. */
|
||||||
|
val home_dir: String = "",
|
||||||
|
val presets: List<SpawnPreset> = emptyList(),
|
||||||
|
val build: BuildInfo = BuildInfo(),
|
||||||
|
/** Identity of the daemon *process*, regenerated on every start — a
|
||||||
|
* changed value across a reconnect means the daemon restarted. */
|
||||||
|
val instance: String = "",
|
||||||
|
/** Canonical leader key the operator configured (`[tui].leader`), so a
|
||||||
|
* client binds the same chord the TUI does. Empty means "use your
|
||||||
|
* default". */
|
||||||
|
val leader: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A spawn-form command preset advertised by the daemon. */
|
||||||
|
@Serializable
|
||||||
|
data class SpawnPreset(
|
||||||
|
val id: String,
|
||||||
|
val label: String,
|
||||||
|
val command: String,
|
||||||
|
val auto_approve: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Build provenance of the running daemon. Every field is empty when the
|
||||||
|
* binary was built outside a git checkout. */
|
||||||
|
@Serializable
|
||||||
|
data class BuildInfo(
|
||||||
|
val commit: String = "",
|
||||||
|
val commit_date: String = "",
|
||||||
|
val dirty: Boolean = false,
|
||||||
|
)
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package pt.naps.maestro.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/** `GET /api/dirs?path=` — one directory's immediate subdirectories. Mirrors
|
||||||
|
* `maestro_protocol::DirListing`. */
|
||||||
|
@Serializable
|
||||||
|
data class DirListing(
|
||||||
|
/** Resolved absolute directory that was listed (after ~/relative
|
||||||
|
* expansion). */
|
||||||
|
val path: String,
|
||||||
|
/** Immediate subdirectory names, sorted case-insensitively. */
|
||||||
|
val entries: List<String> = emptyList(),
|
||||||
|
/** True when the listing was capped server-side. */
|
||||||
|
val truncated: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** One deduplicated recent working dir for the spawn form's recents picker.
|
||||||
|
* Mirrors `maestro_protocol::RecentDir`. */
|
||||||
|
@Serializable
|
||||||
|
data class RecentDir(
|
||||||
|
val cwd: String,
|
||||||
|
val last_command: String,
|
||||||
|
val last_used: IsoTimestamp,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `GET /api/recent-dirs` — the daemon's recent working dirs, newest-first
|
||||||
|
* and deduped by `cwd`. */
|
||||||
|
@Serializable
|
||||||
|
data class RecentDirs(
|
||||||
|
val dirs: List<RecentDir> = emptyList(),
|
||||||
|
val truncated: Boolean = false,
|
||||||
|
)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package pt.naps.maestro.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/** `GET /api/gitroot?path=` — the enclosing repo toplevel of a path, or
|
||||||
|
* `null` when the path isn't inside a repo. Mirrors
|
||||||
|
* `maestro_protocol::GitRoot`. */
|
||||||
|
@Serializable
|
||||||
|
data class GitRoot(val root: String? = null)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package pt.naps.maestro.data
|
||||||
|
|
||||||
|
/** A session id. Mirrors `uuid::Uuid` on the wire — a plain UUID string. */
|
||||||
|
typealias Uuid = String
|
||||||
|
|
||||||
|
/** An RFC 3339 timestamp, as the daemon serializes `chrono::DateTime<Utc>`. */
|
||||||
|
typealias IsoTimestamp = String
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package pt.naps.maestro.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/** What currently blocks an unforced kill of a session's worktree,
|
||||||
|
* recomputed by the daemon on every read. Mirrors
|
||||||
|
* `maestro_protocol::InTheWay`. */
|
||||||
|
@Serializable
|
||||||
|
data class InTheWay(
|
||||||
|
val uncommitted_files: Int = 0,
|
||||||
|
val untracked_files: Int = 0,
|
||||||
|
val unpushed_commits: Int = 0,
|
||||||
|
/** Lines added across the tracked changes, staged and unstaged together. */
|
||||||
|
val insertions: Int = 0,
|
||||||
|
/** Lines removed across the same tracked changes. */
|
||||||
|
val deletions: Int = 0,
|
||||||
|
)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package pt.naps.maestro.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/** `GET /api/names?count=N` — a batch of fresh session handles. Mirrors
|
||||||
|
* `maestro_protocol::NameBatch`. */
|
||||||
|
@Serializable
|
||||||
|
data class NameBatch(val names: List<String>)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package pt.naps.maestro.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.KSerializer
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||||
|
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||||
|
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||||
|
import kotlinx.serialization.encoding.Decoder
|
||||||
|
import kotlinx.serialization.encoding.Encoder
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a session's stored name (`metadata["name"]`) came from. Mirrors
|
||||||
|
* `maestro_protocol::NameSource`.
|
||||||
|
*
|
||||||
|
* Deserializes leniently: an unknown spelling decodes as [User], the settled
|
||||||
|
* variant — a name wrongly painted as settled is only a missed dimming; one
|
||||||
|
* wrongly painted as provisional invites overwriting a name a person chose.
|
||||||
|
*/
|
||||||
|
@Serializable(with = NameSourceSerializer::class)
|
||||||
|
enum class NameSource {
|
||||||
|
User,
|
||||||
|
Generated,
|
||||||
|
Haiku,
|
||||||
|
}
|
||||||
|
|
||||||
|
object NameSourceSerializer : KSerializer<NameSource> {
|
||||||
|
override val descriptor: SerialDescriptor =
|
||||||
|
PrimitiveSerialDescriptor("NameSource", PrimitiveKind.STRING)
|
||||||
|
|
||||||
|
override fun serialize(encoder: Encoder, value: NameSource) {
|
||||||
|
encoder.encodeString(value.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun deserialize(decoder: Decoder): NameSource =
|
||||||
|
when (decoder.decodeString()) {
|
||||||
|
"Generated" -> NameSource.Generated
|
||||||
|
"Haiku" -> NameSource.Haiku
|
||||||
|
else -> NameSource.User
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package pt.naps.maestro.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/** One discovered git repository (or linked worktree) for `GET
|
||||||
|
* /api/projects`. Mirrors `maestro_protocol::Project`. */
|
||||||
|
@Serializable
|
||||||
|
data class Project(
|
||||||
|
/** Absolute path of the repository/worktree toplevel. */
|
||||||
|
val path: String,
|
||||||
|
/** Basename of [path]. */
|
||||||
|
val name: String,
|
||||||
|
val is_worktree: Boolean = false,
|
||||||
|
/** The main checkout this worktree belongs to; `null` on plain repos. */
|
||||||
|
val base_repo: String? = null,
|
||||||
|
/** Later of the repo's git HEAD mtime and the newest spawn-history use of
|
||||||
|
* any directory inside it; `null` when neither is known. */
|
||||||
|
val last_activity: IsoTimestamp? = null,
|
||||||
|
/** Command last spawned in this project, from spawn history. */
|
||||||
|
val last_command: String? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package pt.naps.maestro.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
|
||||||
|
/** Default for [Session.last_activity_at] / [Session.activity_since] when an
|
||||||
|
* older payload omits them — mirrors the daemon's Unix-epoch default. */
|
||||||
|
const val EPOCH: IsoTimestamp = "1970-01-01T00:00:00Z"
|
||||||
|
|
||||||
|
/** A live or recently-dead tmux session managed by the daemon. Mirrors
|
||||||
|
* `maestro_protocol::Session`. */
|
||||||
|
@Serializable
|
||||||
|
data class Session(
|
||||||
|
val id: Uuid,
|
||||||
|
val cwd: String,
|
||||||
|
val command: String,
|
||||||
|
val env: Map<String, String> = emptyMap(),
|
||||||
|
val metadata: Map<String, JsonElement> = emptyMap(),
|
||||||
|
/** The worktree this session owns, when it was spawned with one. */
|
||||||
|
val worktree: SessionWorktree? = null,
|
||||||
|
val status: SessionStatus,
|
||||||
|
val created_at: IsoTimestamp,
|
||||||
|
/** Current foreground command in the pane (e.g. `claude`, `bash`), as the
|
||||||
|
* daemon's activity watcher observed it. `null` until observed. */
|
||||||
|
val foreground: String? = null,
|
||||||
|
/** Inferred activity dimension, orthogonal to [status]. */
|
||||||
|
val activity: ActivityState = ActivityState.Idle,
|
||||||
|
/** Short human string an agent reported alongside its activity — e.g. why
|
||||||
|
* it is in [ActivityState.Error]. `null` unless an agent reported one. */
|
||||||
|
val activity_detail: String? = null,
|
||||||
|
val last_activity_at: IsoTimestamp = EPOCH,
|
||||||
|
val activity_since: IsoTimestamp = EPOCH,
|
||||||
|
/** The pane's *current* working directory, distinct from [cwd] (the spawn
|
||||||
|
* dir). `null` until first observed. */
|
||||||
|
val current_cwd: String? = null,
|
||||||
|
/** What currently blocks an unforced kill of this session, as the
|
||||||
|
* daemon's watcher last probed it. `null` while clear or not yet probed. */
|
||||||
|
val blocked_work: InTheWay? = null,
|
||||||
|
/** When this session last became the pane a client was showing. `null`
|
||||||
|
* until the session has been focused at least once. */
|
||||||
|
val last_focused_at: IsoTimestamp? = null,
|
||||||
|
/** Terminal (PTY bytes) or structured (ACP rows). Fixed at spawn and
|
||||||
|
* immutable for the session's life. */
|
||||||
|
val view: SessionView = SessionView.Terminal,
|
||||||
|
/** The ACP session id the selected provider adapter returned from
|
||||||
|
* `session/new`. Only a [SessionView.Structured] session whose handshake
|
||||||
|
* succeeded has one. */
|
||||||
|
val acp_session_id: String? = null,
|
||||||
|
/** When `true`, the daemon auto-approves every ACP permission request for
|
||||||
|
* this session instead of surfacing them to the client. */
|
||||||
|
val auto_approve: Boolean = false,
|
||||||
|
/** Prompts queued on the daemon while a turn was in flight or the adapter
|
||||||
|
* was still booting. */
|
||||||
|
val queued_prompts: List<String> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class SessionStatus { Running, Dead }
|
||||||
|
|
||||||
|
/** A session's process shape: mirrors `maestro_protocol::SessionView`.
|
||||||
|
* `Terminal` is the default. */
|
||||||
|
@Serializable
|
||||||
|
enum class SessionView { Terminal, Structured }
|
||||||
|
|
||||||
|
/** A session's daemon-created worktree. `base_repo` is the repository's main
|
||||||
|
* checkout, which is what the session tree groups by. */
|
||||||
|
@Serializable
|
||||||
|
data class SessionWorktree(
|
||||||
|
val path: String,
|
||||||
|
val base_repo: String,
|
||||||
|
)
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package pt.naps.maestro.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
|
||||||
|
/** `POST /api/sessions` body. Mirrors
|
||||||
|
* `maestro_protocol::CreateSessionRequest`. */
|
||||||
|
@Serializable
|
||||||
|
data class CreateSessionRequest(
|
||||||
|
val cwd: String,
|
||||||
|
val command: String,
|
||||||
|
val env: Map<String, String> = emptyMap(),
|
||||||
|
val metadata: Map<String, JsonElement> = emptyMap(),
|
||||||
|
val worktree: WorktreeSpec? = null,
|
||||||
|
/** The process shape to spawn. Defaults to a `Terminal` session. */
|
||||||
|
val view: SessionView = SessionView.Terminal,
|
||||||
|
/** When `true`, the daemon auto-approves every ACP permission request for
|
||||||
|
* this structured session. */
|
||||||
|
val auto_approve: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Optional worktree request attached to a [CreateSessionRequest]. Mirrors
|
||||||
|
* `maestro_protocol::WorktreeSpec`. */
|
||||||
|
@Serializable
|
||||||
|
data class WorktreeSpec(
|
||||||
|
val base_repo: String,
|
||||||
|
val branch: String,
|
||||||
|
/** When `false`, [branch] already exists and is checked out as-is.
|
||||||
|
* Defaults to `true` (create a new branch). */
|
||||||
|
val create_branch: Boolean = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `PATCH /api/sessions/:id` body: rename a live session. An empty [name]
|
||||||
|
* clears the custom one, so the card falls back to the command. Mirrors
|
||||||
|
* `maestro_protocol::RenameSessionRequest`. */
|
||||||
|
@Serializable
|
||||||
|
data class RenameSessionRequest(val name: String)
|
||||||
|
|
||||||
|
/** `POST /api/sessions/:id/focus` body: a client reports whether its pane is
|
||||||
|
* the one currently showing this session. Mirrors
|
||||||
|
* `maestro_protocol::SetFocusRequest`. */
|
||||||
|
@Serializable
|
||||||
|
data class SetFocusRequest(val focused: Boolean)
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package pt.naps.maestro.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
|
||||||
|
/** The semantic shape of a [TranscriptRow]. Mirrors
|
||||||
|
* `maestro_protocol::TranscriptRowKind`, snake_case on the wire. */
|
||||||
|
@Serializable
|
||||||
|
enum class TranscriptRowKind {
|
||||||
|
@SerialName("user") User,
|
||||||
|
@SerialName("agent_text") AgentText,
|
||||||
|
@SerialName("agent_thought") AgentThought,
|
||||||
|
@SerialName("tool_call") ToolCall,
|
||||||
|
@SerialName("tool_diff") ToolDiff,
|
||||||
|
@SerialName("approval") Approval,
|
||||||
|
@SerialName("plan") Plan,
|
||||||
|
@SerialName("notice") Notice,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One client-neutral item in a structured session transcript. [data]
|
||||||
|
* preserves the ACP payload verbatim without committing to an ACP
|
||||||
|
* dependency. Mirrors `maestro_protocol::TranscriptRow`. */
|
||||||
|
@Serializable
|
||||||
|
data class TranscriptRow(
|
||||||
|
/** Stable within one session, so later deltas can address this row. */
|
||||||
|
val id: String,
|
||||||
|
val kind: TranscriptRowKind,
|
||||||
|
val data: JsonElement,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The complete transcript at a durable event-log position — a structured
|
||||||
|
* session's first `WS /api/structured/:id` message. Mirrors
|
||||||
|
* `maestro_protocol::TranscriptSnapshot`. */
|
||||||
|
@Serializable
|
||||||
|
data class TranscriptSnapshot(
|
||||||
|
val cursor: Long,
|
||||||
|
val rows: List<TranscriptRow> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A change to one transcript row, addressed by [id]. Mirrors
|
||||||
|
* `maestro_protocol::TranscriptDelta`; tagged by a snake_case `type` field. */
|
||||||
|
@Serializable
|
||||||
|
sealed class TranscriptDelta {
|
||||||
|
@Serializable
|
||||||
|
@SerialName("append")
|
||||||
|
data class Append(val row: TranscriptRow) : TranscriptDelta()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("patch")
|
||||||
|
data class Patch(val row: TranscriptRow) : TranscriptDelta()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("remove")
|
||||||
|
data class Remove(val id: String) : TranscriptDelta()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-turn indicators derived from the ACP stream — read verbatim, never
|
||||||
|
* re-derived client-side. Mirrors `maestro_protocol::TurnState`. */
|
||||||
|
@Serializable
|
||||||
|
data class TurnState(
|
||||||
|
val responding: Boolean = false,
|
||||||
|
val thinking: Boolean = false,
|
||||||
|
val active_tool_calls: List<String> = emptyList(),
|
||||||
|
/** Wall-clock start of the active prompt, so a reconnecting client keeps
|
||||||
|
* one continuous elapsed-time display instead of restarting at zero. */
|
||||||
|
val started_at: IsoTimestamp? = null,
|
||||||
|
val started_context_tokens: Long? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A whole-session view derived from ACP notifications. Mirrors
|
||||||
|
* `maestro_protocol::ReducedState`.
|
||||||
|
*
|
||||||
|
* [pending_approval], [plan], [usage], [context] and [config_options] carry
|
||||||
|
* opaque ACP JSON — their shape is ACP's, not covered by this crate's own
|
||||||
|
* compatibility guarantee, so they are kept as [JsonElement] rather than
|
||||||
|
* modeled strongly.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class ReducedState(
|
||||||
|
val turn: TurnState = TurnState(),
|
||||||
|
val pending_approval: JsonElement? = null,
|
||||||
|
val plan: JsonElement? = null,
|
||||||
|
val usage: JsonElement? = null,
|
||||||
|
val context: JsonElement? = null,
|
||||||
|
val model: String? = null,
|
||||||
|
val mode: String? = null,
|
||||||
|
val config_options: JsonElement? = null,
|
||||||
|
/** Prompts queued on the daemon while a turn is in flight. */
|
||||||
|
val queued_prompts: List<String> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Server messages on `WS /api/structured/:id`. Mirrors
|
||||||
|
* `maestro_protocol::StructuredEvent`; tagged by a snake_case `type` field. */
|
||||||
|
@Serializable
|
||||||
|
sealed class StructuredEvent {
|
||||||
|
@Serializable
|
||||||
|
@SerialName("transcript_snapshot")
|
||||||
|
data class TranscriptSnapshotEvent(val snapshot: TranscriptSnapshot) : StructuredEvent()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("reduced_state")
|
||||||
|
data class ReducedStateEvent(val state: ReducedState) : StructuredEvent()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("projection_update")
|
||||||
|
data class ProjectionUpdate(
|
||||||
|
val transcript: TranscriptDelta? = null,
|
||||||
|
val state: ReducedState,
|
||||||
|
) : StructuredEvent()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `POST /api/sessions/:id/structured/prompt` body. */
|
||||||
|
@Serializable
|
||||||
|
data class StructuredPromptRequest(val prompt: String)
|
||||||
|
|
||||||
|
/** `POST /api/sessions/:id/structured/config` body: change one option
|
||||||
|
* advertised by a live ACP session. */
|
||||||
|
@Serializable
|
||||||
|
data class StructuredConfigRequest(val config_id: String, val value: JsonElement)
|
||||||
|
|
||||||
|
/** Operator decision for a pending ACP permission request
|
||||||
|
* (`POST /api/sessions/:id/structured/approvals/:nonce` body) — a bare JSON
|
||||||
|
* string on the wire, not `{decision: ...}`. Mirrors
|
||||||
|
* `maestro_protocol::StructuredApprovalDecision`. */
|
||||||
|
@Serializable
|
||||||
|
enum class StructuredApprovalDecision {
|
||||||
|
@SerialName("allow_once") AllowOnce,
|
||||||
|
@SerialName("allow_for_session") AllowForSession,
|
||||||
|
@SerialName("deny") Deny,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Response of `POST /api/sessions/:id/structured/attachments`. */
|
||||||
|
@Serializable
|
||||||
|
data class StructuredAttachmentResponse(val path: String)
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
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.RepeatMode
|
||||||
|
import androidx.compose.animation.core.animateFloat
|
||||||
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
|
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
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import pt.naps.maestro.data.ActivityState
|
||||||
|
|
||||||
|
/** docs/SIDEBAR.md: the rail's width in the web client. Android mirrors it in
|
||||||
|
* density-independent pixels rather than raw pixels. */
|
||||||
|
val ACTIVITY_RAIL_WIDTH = 7.dp
|
||||||
|
|
||||||
|
private val RAIL_ACTIVE = Color(0xFF3DDC84)
|
||||||
|
private val RAIL_IDLE = Color(0xFF9AA0A6)
|
||||||
|
private val RAIL_AWAITING = Color(0xFFF5A623)
|
||||||
|
private val RAIL_ERROR = Color(0xFFE5484D)
|
||||||
|
|
||||||
|
/** Colour per activity state (docs/SIDEBAR.md's colour table): `Starting`
|
||||||
|
* shares `Idle`'s colour, and an unrecognised state degrades to `Idle` so
|
||||||
|
* rendering never fails (Precedence). */
|
||||||
|
fun activityColor(activity: ActivityState): Color =
|
||||||
|
when (activity) {
|
||||||
|
ActivityState.Active -> RAIL_ACTIVE
|
||||||
|
ActivityState.AwaitingInput -> RAIL_AWAITING
|
||||||
|
ActivityState.Error -> RAIL_ERROR
|
||||||
|
ActivityState.Idle, ActivityState.Starting -> RAIL_IDLE
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The accessible name the rail carries, since colour is never the only
|
||||||
|
* signal (docs/SIDEBAR.md). */
|
||||||
|
fun activityStateWord(activity: ActivityState): String =
|
||||||
|
when (activity) {
|
||||||
|
ActivityState.Active -> "Active"
|
||||||
|
ActivityState.Idle -> "Idle"
|
||||||
|
ActivityState.Starting -> "Starting"
|
||||||
|
ActivityState.AwaitingInput -> "Awaiting input"
|
||||||
|
ActivityState.Error -> "Error"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A held bar that pulses its alpha between 1 and [minAlpha] every
|
||||||
|
* [periodMs] — the blink/flash motion `AwaitingInput`/`Error` earn. */
|
||||||
|
@Composable
|
||||||
|
private fun InfiniteTransition.pulsingAlpha(minAlpha: Float, periodMs: Int): Float {
|
||||||
|
val alpha by animateFloat(
|
||||||
|
initialValue = 1f,
|
||||||
|
targetValue = minAlpha,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(durationMillis = periodMs, easing = LinearEasing),
|
||||||
|
repeatMode = RepeatMode.Reverse,
|
||||||
|
),
|
||||||
|
label = "activity-rail-alpha",
|
||||||
|
)
|
||||||
|
return alpha
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The card's rail: a coloured bar in the gutter, full card height. It paints
|
||||||
|
* for every card, `Idle` included, and carries motion only for states that
|
||||||
|
* need attention — a hard blink for `AwaitingInput`, a fast flash for
|
||||||
|
* `Error`. `Active` and `Idle` stay solid; colour alone is their signal. */
|
||||||
|
@Composable
|
||||||
|
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)
|
||||||
|
else -> 1f
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxHeight()
|
||||||
|
.width(ACTIVITY_RAIL_WIDTH)
|
||||||
|
.semantics { contentDescription = activityStateWord(activity) }
|
||||||
|
.background(color.copy(alpha = alpha)),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package pt.naps.maestro.ui.sidebar
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/** docs/SIDEBAR.md: a collapsible directory in the sidebar tree. An expanded
|
||||||
|
* folder's cards are one row away, so it carries no highlight of its own —
|
||||||
|
* only a collapsed header is ever a keyboard-walk stop, but that distinction
|
||||||
|
* belongs to the caller driving the walk, not this composable. */
|
||||||
|
@Composable
|
||||||
|
fun FolderNode(
|
||||||
|
label: String,
|
||||||
|
collapsed: Boolean,
|
||||||
|
onToggle: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
depthPadding: Int = 0,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onToggle)
|
||||||
|
.padding(start = depthPadding.dp, top = 6.dp, bottom = 6.dp, end = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (collapsed) "▸" else "▾",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
AnimatedVisibility(visible = !collapsed) {
|
||||||
|
Column { content() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
package pt.naps.maestro.ui.sidebar
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import pt.naps.maestro.data.InTheWay
|
||||||
|
import pt.naps.maestro.data.Session
|
||||||
|
import pt.naps.maestro.data.SessionStatus
|
||||||
|
|
||||||
|
/** docs/SIDEBAR.md: a card is always two lines behind an activity rail —
|
||||||
|
* line 1 the name, line 2 the adaptive meta line. Long-press opens a context
|
||||||
|
* menu (Rename / Kill / Spawn similar); the daemon confirms rename and kill
|
||||||
|
* only through the callbacks the caller supplies. */
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
fun SessionCard(
|
||||||
|
session: Session,
|
||||||
|
nowMs: Long,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
depthPadding: Int = 0,
|
||||||
|
onOpen: (Session) -> Unit = {},
|
||||||
|
onRename: (Session, String) -> Unit = { _, _ -> },
|
||||||
|
onKill: (Session) -> Unit = {},
|
||||||
|
onSpawnSimilar: (Session) -> Unit = {},
|
||||||
|
) {
|
||||||
|
var menuExpanded by remember { mutableStateOf(false) }
|
||||||
|
var renaming by remember { mutableStateOf(false) }
|
||||||
|
var confirmingKill by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val running = session.status == SessionStatus.Running
|
||||||
|
val name = sessionName(session)
|
||||||
|
val muted = isProvisionalName(session)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.combinedClickable(
|
||||||
|
onClick = { onOpen(session) },
|
||||||
|
onLongClick = { menuExpanded = true },
|
||||||
|
)
|
||||||
|
.padding(start = (8 + depthPadding).dp, end = 8.dp, top = 6.dp, bottom = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
ActivityRail(activity = session.activity, modifier = Modifier.height(36.dp))
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
text = name,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
color = when {
|
||||||
|
!running -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
muted -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
else -> MaterialTheme.colorScheme.onSurface
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
MetaLine(session = session, nowMs = nowMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Rename") },
|
||||||
|
onClick = {
|
||||||
|
menuExpanded = false
|
||||||
|
renaming = true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Spawn similar") },
|
||||||
|
onClick = {
|
||||||
|
menuExpanded = false
|
||||||
|
onSpawnSimilar(session)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Kill") },
|
||||||
|
onClick = {
|
||||||
|
menuExpanded = false
|
||||||
|
confirmingKill = true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (renaming) {
|
||||||
|
RenameDialog(
|
||||||
|
initial = customSessionName(session) ?: "",
|
||||||
|
onCommit = {
|
||||||
|
renaming = false
|
||||||
|
onRename(session, it)
|
||||||
|
},
|
||||||
|
onCancel = { renaming = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (confirmingKill) {
|
||||||
|
KillConfirmDialog(
|
||||||
|
session = session,
|
||||||
|
onConfirm = {
|
||||||
|
confirmingKill = false
|
||||||
|
onKill(session)
|
||||||
|
},
|
||||||
|
onCancel = { confirmingKill = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Line 2, left to right: `[duration] [detail] command [diff stat | exited]`
|
||||||
|
* — the line itself is unconditional, only its fragments are adaptive. */
|
||||||
|
@Composable
|
||||||
|
fun MetaLine(session: Session, nowMs: Long, modifier: Modifier = Modifier) {
|
||||||
|
val heldMs = activityHeldMs(session.activity_since, nowMs)
|
||||||
|
val duration = if (heldMs != null && heldMs >= META_LINE_MIN_HOLD_MS) {
|
||||||
|
activityDurationLabel(heldMs)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
val blockers = sessionBlockers(session)
|
||||||
|
|
||||||
|
Row(modifier = modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (duration != null) {
|
||||||
|
Text(
|
||||||
|
text = duration,
|
||||||
|
color = activityColor(session.activity),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
}
|
||||||
|
if (session.activity_detail != null) {
|
||||||
|
Text(
|
||||||
|
text = session.activity_detail,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
modifier = Modifier.weight(1f, fill = false),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = programName(session.command),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
)
|
||||||
|
if (blockers != null) {
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
BlockedFragment(blockers)
|
||||||
|
}
|
||||||
|
if (session.status == SessionStatus.Dead) {
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
text = "exited",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BlockedFragment(blockers: InTheWay) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
blockerStats(blockers).forEachIndexed { index, stat ->
|
||||||
|
if (index > 0) Spacer(Modifier.width(4.dp))
|
||||||
|
Text(
|
||||||
|
text = stat.text,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = when (stat.kind) {
|
||||||
|
"add" -> MaterialTheme.colorScheme.primary
|
||||||
|
"del" -> MaterialTheme.colorScheme.error
|
||||||
|
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RenameDialog(initial: String, onCommit: (String) -> Unit, onCancel: () -> Unit) {
|
||||||
|
var text by remember { mutableStateOf(initial) }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onCancel,
|
||||||
|
title = { Text("Rename session") },
|
||||||
|
text = {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = text,
|
||||||
|
onValueChange = { text = it },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = { TextButton(onClick = { onCommit(text.trim()) }) { Text("Rename") } },
|
||||||
|
dismissButton = { TextButton(onClick = onCancel) { Text("Cancel") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun KillConfirmDialog(session: Session, onConfirm: () -> Unit, onCancel: () -> Unit) {
|
||||||
|
val blockers = sessionBlockers(session)
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onCancel,
|
||||||
|
title = { Text("Kill “${sessionName(session)}”?") },
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
if (blockers != null) {
|
||||||
|
"This worktree has unsaved work: ${formatInTheWay(blockers)}. Killing may be refused unless forced."
|
||||||
|
} else {
|
||||||
|
"This ends the session's process. This can't be undone."
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = { TextButton(onClick = onConfirm) { Text("Kill") } },
|
||||||
|
dismissButton = { TextButton(onClick = onCancel) { Text("Cancel") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package pt.naps.maestro.ui.sidebar
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import pt.naps.maestro.data.InTheWay
|
||||||
|
import pt.naps.maestro.data.NameSource
|
||||||
|
import pt.naps.maestro.data.Session
|
||||||
|
|
||||||
|
/** docs/SIDEBAR.md: a state held shorter than this has no duration fragment. */
|
||||||
|
const val META_LINE_MIN_HOLD_MS: Long = 30_000L
|
||||||
|
|
||||||
|
/** Payloads predating #102 default `activity_since` to the Unix epoch;
|
||||||
|
* anything implausibly old is "never observed" rather than a huge duration. */
|
||||||
|
private val PLAUSIBLE_EPOCH_MS = Instant.parse("2010-01-01T00:00:00Z").toEpochMilli()
|
||||||
|
|
||||||
|
/** Milliseconds held in the current activity state, or `null` when `since` is
|
||||||
|
* missing/unparsable/implausible. Mirrors the web's `activityHeldMs`. */
|
||||||
|
fun activityHeldMs(since: String?, nowMs: Long): Long? {
|
||||||
|
if (since.isNullOrEmpty()) return null
|
||||||
|
val t = runCatching { Instant.parse(since).toEpochMilli() }.getOrNull() ?: return null
|
||||||
|
if (t < PLAUSIBLE_EPOCH_MS) return null
|
||||||
|
return (nowMs - t).coerceAtLeast(0L)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `<60s` whole seconds, `<60m` whole minutes, else whole hours — floored
|
||||||
|
* everywhere so the counter never shows a duration the state hasn't reached. */
|
||||||
|
fun activityDurationLabel(heldMs: Long): String {
|
||||||
|
val seconds = heldMs / 1000
|
||||||
|
if (seconds < 60) return "${seconds}s"
|
||||||
|
val minutes = seconds / 60
|
||||||
|
if (minutes < 60) return "${minutes}m"
|
||||||
|
return "${minutes / 60}h"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The session's own name (#125), without the command fallback: `null` for a
|
||||||
|
* session that was never given one. */
|
||||||
|
fun customSessionName(session: Session): String? {
|
||||||
|
val element = session.metadata["name"] ?: return null
|
||||||
|
val primitive = element as? JsonPrimitive ?: return null
|
||||||
|
if (!primitive.isString) return null
|
||||||
|
return primitive.content.takeIf { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Line 1's name: `metadata.name` if present, else the command line. */
|
||||||
|
fun sessionName(session: Session): String = customSessionName(session) ?: session.command
|
||||||
|
|
||||||
|
/** Where the session's name came from (#131), read leniently: anything
|
||||||
|
* unrecognised — including a session named before the field existed — reads
|
||||||
|
* as [NameSource.User], so an existing name is never dimmed or overwritten. */
|
||||||
|
fun sessionNameSource(session: Session): NameSource {
|
||||||
|
val element = session.metadata["name_source"] as? JsonPrimitive ?: return NameSource.User
|
||||||
|
return when (element.jsonPrimitive.content) {
|
||||||
|
"Generated" -> NameSource.Generated
|
||||||
|
"Haiku" -> NameSource.Haiku
|
||||||
|
else -> NameSource.User
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether line 1 renders the name muted (#131): a handle the daemon
|
||||||
|
* generated at spawn and may still replace with one derived from the first
|
||||||
|
* prompt. */
|
||||||
|
fun isProvisionalName(session: Session): Boolean =
|
||||||
|
customSessionName(session) != null && sessionNameSource(session) == NameSource.Generated
|
||||||
|
|
||||||
|
/** One fragment of the compact blocked-work stat (docs/SIDEBAR.md). `kind`
|
||||||
|
* picks the colour; `text` is what the card prints. */
|
||||||
|
data class BlockerStat(val kind: String, val text: String)
|
||||||
|
|
||||||
|
/** #83: this session's blocked-work breakdown when something actually blocks
|
||||||
|
* an unforced kill; `null` while clear or not yet probed. */
|
||||||
|
fun sessionBlockers(session: Session): InTheWay? {
|
||||||
|
val w = session.blocked_work ?: return null
|
||||||
|
val blocked = w.uncommitted_files > 0 || w.untracked_files > 0 || w.unpushed_commits > 0
|
||||||
|
return if (blocked) w else null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The blocked-work breakdown as diff notation: `+124 -18 ?3 ↑2`. A tree whose
|
||||||
|
* only tracked change carries no line delta falls back to the file count
|
||||||
|
* (`~2`) rather than claiming `+0 -0`. */
|
||||||
|
fun blockerStats(w: InTheWay): List<BlockerStat> {
|
||||||
|
val stats = mutableListOf<BlockerStat>()
|
||||||
|
if (w.insertions > 0 || w.deletions > 0) {
|
||||||
|
if (w.insertions > 0) stats += BlockerStat("add", "+${w.insertions}")
|
||||||
|
if (w.deletions > 0) stats += BlockerStat("del", "-${w.deletions}")
|
||||||
|
} else if (w.uncommitted_files > 0) {
|
||||||
|
stats += BlockerStat("files", "~${w.uncommitted_files}")
|
||||||
|
}
|
||||||
|
if (w.untracked_files > 0) stats += BlockerStat("untracked", "?${w.untracked_files}")
|
||||||
|
if (w.unpushed_commits > 0) stats += BlockerStat("unpushed", "↑${w.unpushed_commits}")
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The spelled-out phrasing shared by the kill refusal and every client's
|
||||||
|
* tooltip, so no two describe the same tree differently. */
|
||||||
|
fun formatInTheWay(w: InTheWay): String {
|
||||||
|
val parts = mutableListOf<String>()
|
||||||
|
fun push(n: Int, unit: String) {
|
||||||
|
if (n <= 0) return
|
||||||
|
parts += if (n == 1) "1 $unit" else "$n ${unit}s"
|
||||||
|
}
|
||||||
|
push(w.uncommitted_files, "uncommitted file")
|
||||||
|
push(w.untracked_files, "untracked file")
|
||||||
|
push(w.unpushed_commits, "unpushed commit")
|
||||||
|
return if (parts.isNotEmpty()) parts.joinToString(", ") else "no unsaved work"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val ENV_ASSIGNMENT = Regex("^[A-Za-z_][A-Za-z0-9_]*=.*$")
|
||||||
|
private val PLAIN_WRAPPERS = setOf("sudo", "env")
|
||||||
|
private val DLX_CAPABLE = setOf("pnpm", "yarn")
|
||||||
|
|
||||||
|
/** The program a command line runs — never its arguments (docs/SIDEBAR.md).
|
||||||
|
* Looks through leading `FOO=bar` assignments, `sudo`/`env` wrappers (and
|
||||||
|
* their own flags), and JS-toolchain launchers (`npx`, `bunx`, `pnpm`/`yarn
|
||||||
|
* dlx`), then basenames what is left. A command that is nothing but wrappers
|
||||||
|
* is returned as written rather than as an empty string. */
|
||||||
|
fun programName(command: String): String {
|
||||||
|
val trimmed = command.trim()
|
||||||
|
val words = trimmed.split(Regex("\\s+")).filter { it.isNotEmpty() }
|
||||||
|
if (words.isEmpty()) return trimmed
|
||||||
|
|
||||||
|
var i = 0
|
||||||
|
while (i < words.size && ENV_ASSIGNMENT.matches(words[i])) i++
|
||||||
|
while (i < words.size && words[i] in PLAIN_WRAPPERS) {
|
||||||
|
i++
|
||||||
|
while (i < words.size && (ENV_ASSIGNMENT.matches(words[i]) || words[i].startsWith("-"))) i++
|
||||||
|
}
|
||||||
|
if (i >= words.size) return basename(words.last())
|
||||||
|
|
||||||
|
when {
|
||||||
|
words[i] in DLX_CAPABLE && words.getOrNull(i + 1) == "dlx" -> i += 2
|
||||||
|
words[i] == "npx" || words[i] == "bunx" -> i += 1
|
||||||
|
words[i] == "bun" && words.getOrNull(i + 1) == "x" -> i += 2
|
||||||
|
}
|
||||||
|
val program = words.getOrNull(i) ?: return basename(words.last())
|
||||||
|
return basename(program)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun basename(path: String): String = path.substringAfterLast('/')
|
||||||
|
|
||||||
|
/** Sessions are grouped by the repository they belong to (#265): a
|
||||||
|
* worktree's `base_repo`, or the session's own `cwd` when it owns none. */
|
||||||
|
fun sessionGroupKey(session: Session): String = session.worktree?.base_repo ?: session.cwd
|
||||||
|
|
||||||
|
/** One folder in the sidebar tree: a repo/cwd group and the sessions in it,
|
||||||
|
* sorted by name. */
|
||||||
|
data class SessionGroup(val key: String, val label: String, val sessions: List<Session>)
|
||||||
|
|
||||||
|
/** Group sessions by [sessionGroupKey], each group sorted by name and the
|
||||||
|
* groups themselves sorted by label. */
|
||||||
|
fun groupSessions(sessions: Collection<Session>): List<SessionGroup> =
|
||||||
|
sessions
|
||||||
|
.groupBy(::sessionGroupKey)
|
||||||
|
.map { (key, members) ->
|
||||||
|
SessionGroup(
|
||||||
|
key = key,
|
||||||
|
label = key.trimEnd('/').substringAfterLast('/').ifEmpty { key },
|
||||||
|
sessions = members.sortedBy { sessionName(it).lowercase() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.sortedBy { it.label.lowercase() }
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package pt.naps.maestro.ui.sidebar
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import pt.naps.maestro.connection.CatalogStore
|
||||||
|
import pt.naps.maestro.data.Session
|
||||||
|
import pt.naps.maestro.data.Uuid
|
||||||
|
|
||||||
|
/** A clock that advances at least once every 30s (docs/SIDEBAR.md), ticked
|
||||||
|
* faster than that so a duration's cutover lands within one visible frame. */
|
||||||
|
@Composable
|
||||||
|
fun rememberTickingNowMs(periodMs: Long = 5_000L): Long {
|
||||||
|
var now by remember { mutableStateOf(System.currentTimeMillis()) }
|
||||||
|
LaunchedEffect(periodMs) {
|
||||||
|
while (true) {
|
||||||
|
delay(periodMs)
|
||||||
|
now = System.currentTimeMillis()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return now
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Assembles the full sidebar from the [CatalogStore] built in #264: sessions
|
||||||
|
* grouped into collapsible folders by repository, each rendered as a
|
||||||
|
* two-line [SessionCard] behind its [ActivityRail]. */
|
||||||
|
@Composable
|
||||||
|
fun SidebarContent(
|
||||||
|
catalogStore: CatalogStore,
|
||||||
|
onOpenSession: (Session) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onRenameSession: (Uuid, String) -> Unit = { _, _ -> },
|
||||||
|
onKillSession: (Uuid) -> Unit = {},
|
||||||
|
onSpawnSimilar: (Session) -> Unit = {},
|
||||||
|
) {
|
||||||
|
val sessions by catalogStore.sessions.collectAsState()
|
||||||
|
SidebarContent(
|
||||||
|
sessions = sessions.values.toList(),
|
||||||
|
onOpenSession = onOpenSession,
|
||||||
|
modifier = modifier,
|
||||||
|
onRenameSession = onRenameSession,
|
||||||
|
onKillSession = onKillSession,
|
||||||
|
onSpawnSimilar = onSpawnSimilar,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same as the [CatalogStore]-driven overload, but takes the session list
|
||||||
|
* directly — the shape used by tests and previews. */
|
||||||
|
@Composable
|
||||||
|
fun SidebarContent(
|
||||||
|
sessions: List<Session>,
|
||||||
|
onOpenSession: (Session) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onRenameSession: (Uuid, String) -> Unit = { _, _ -> },
|
||||||
|
onKillSession: (Uuid) -> Unit = {},
|
||||||
|
onSpawnSimilar: (Session) -> Unit = {},
|
||||||
|
) {
|
||||||
|
val nowMs = rememberTickingNowMs()
|
||||||
|
val groups = remember(sessions) { groupSessions(sessions) }
|
||||||
|
val collapsed = remember { mutableStateMapOf<String, Boolean>() }
|
||||||
|
|
||||||
|
Column(modifier = modifier.fillMaxSize()) {
|
||||||
|
Text(
|
||||||
|
text = "Sessions",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
)
|
||||||
|
if (groups.isEmpty()) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||||
|
Text(
|
||||||
|
text = "No sessions",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
|
for (group in groups) {
|
||||||
|
val isCollapsed = collapsed[group.key] == true
|
||||||
|
item(key = "folder:${group.key}") {
|
||||||
|
FolderNode(
|
||||||
|
label = group.label,
|
||||||
|
collapsed = isCollapsed,
|
||||||
|
onToggle = { collapsed[group.key] = !isCollapsed },
|
||||||
|
depthPadding = 8,
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
for (session in group.sessions) {
|
||||||
|
SessionCard(
|
||||||
|
session = session,
|
||||||
|
nowMs = nowMs,
|
||||||
|
depthPadding = 16,
|
||||||
|
onOpen = onOpenSession,
|
||||||
|
onRename = { s, name -> onRenameSession(s.id, name) },
|
||||||
|
onKill = { s -> onKillSession(s.id) },
|
||||||
|
onSpawnSimilar = onSpawnSimilar,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package pt.naps.maestro.ui.spawn
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import pt.naps.maestro.data.CreateSessionRequest
|
||||||
|
import pt.naps.maestro.data.SessionView
|
||||||
|
import pt.naps.maestro.data.SpawnPreset
|
||||||
|
import pt.naps.maestro.data.WorktreeSpec
|
||||||
|
|
||||||
|
/** Tools whose sessions can render as a structured (ACP) transcript instead
|
||||||
|
* of a raw terminal. Mirrors `STRUCTURED_VIEW_TOOLS` in
|
||||||
|
* `web/src/components/spawn/model.ts`. */
|
||||||
|
private val STRUCTURED_VIEW_TOOLS = setOf("claude", "codex")
|
||||||
|
|
||||||
|
/** Commands that just launch another program, looked through to find the
|
||||||
|
* real one: `env -i codex` and `sudo codex` both resolve to `codex`. */
|
||||||
|
private val WRAPPER_PROGRAMS = setOf("env", "sudo", "nohup", "command")
|
||||||
|
|
||||||
|
private val ENV_ASSIGNMENT = Regex("[A-Za-z_][A-Za-z0-9_]*=.*")
|
||||||
|
|
||||||
|
/** Program a command line would run, stripped of leading `FOO=bar`
|
||||||
|
* assignments, known wrappers, its own arguments, and any path prefix. A
|
||||||
|
* simplified stand-in for the shell-grammar-aware `splitCommand` the web
|
||||||
|
* client uses — good enough for the preset commands and the common
|
||||||
|
* `env FOO=1 claude` shapes the spawn form actually produces. */
|
||||||
|
internal fun resolveProgram(command: String): String {
|
||||||
|
val words = command.trim().split(Regex("\\s+")).filter { it.isNotEmpty() }
|
||||||
|
var i = 0
|
||||||
|
while (i < words.size) {
|
||||||
|
while (i < words.size && words[i].matches(ENV_ASSIGNMENT)) i++
|
||||||
|
if (i >= words.size) return ""
|
||||||
|
val word = words[i].substringAfterLast('/').lowercase()
|
||||||
|
if (word !in WRAPPER_PROGRAMS) return word
|
||||||
|
i++
|
||||||
|
while (i < words.size && words[i].startsWith("-")) i++
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
fun supportsStructuredView(command: String): Boolean = resolveProgram(command) in STRUCTURED_VIEW_TOOLS
|
||||||
|
|
||||||
|
/** Where a worktree's branch comes from: freshly created (default) or picked
|
||||||
|
* from the repository's existing branches. Mirrors `WorktreeBranchMode` in
|
||||||
|
* `web/src/components/spawn/model.ts`. */
|
||||||
|
enum class WorktreeBranchMode { New, Existing }
|
||||||
|
|
||||||
|
/** The spawn form's current values, gathered at submit time. Mirrors
|
||||||
|
* `SpawnFormValues` in `web/src/components/spawn/model.ts`. */
|
||||||
|
data class SpawnFormValues(
|
||||||
|
val command: String,
|
||||||
|
val cwd: String,
|
||||||
|
val name: String,
|
||||||
|
val structuredView: Boolean,
|
||||||
|
val autoApprove: Boolean,
|
||||||
|
val worktreeEnabled: Boolean,
|
||||||
|
val worktreeBranch: String,
|
||||||
|
val worktreeBranchMode: WorktreeBranchMode,
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed interface SpawnBuildResult {
|
||||||
|
data class Ok(val request: CreateSessionRequest) : SpawnBuildResult
|
||||||
|
data class Error(val message: String) : SpawnBuildResult
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Turns validated form values into a `POST /api/sessions` body, or an error
|
||||||
|
* message when something required is missing. Mirrors `buildSpawnRequest` in
|
||||||
|
* `web/src/components/spawn/model.ts`. */
|
||||||
|
fun buildSpawnRequest(values: SpawnFormValues, baseRepo: String?): SpawnBuildResult {
|
||||||
|
if (values.cwd.isBlank()) return SpawnBuildResult.Error("Pick a working directory.")
|
||||||
|
if (values.command.isBlank()) return SpawnBuildResult.Error("Pick a command (or fill the custom command).")
|
||||||
|
|
||||||
|
val name = values.name.trim()
|
||||||
|
val metadata = if (name.isNotEmpty()) mapOf("name" to JsonPrimitive(name)) else emptyMap()
|
||||||
|
|
||||||
|
var worktree: WorktreeSpec? = null
|
||||||
|
if (values.worktreeEnabled) {
|
||||||
|
val branch = values.worktreeBranch.trim()
|
||||||
|
if (baseRepo.isNullOrBlank()) {
|
||||||
|
return SpawnBuildResult.Error("Worktree needs a directory inside a git repository.")
|
||||||
|
}
|
||||||
|
if (branch.isEmpty()) {
|
||||||
|
return SpawnBuildResult.Error(
|
||||||
|
if (values.worktreeBranchMode == WorktreeBranchMode.Existing) "Pick an existing branch."
|
||||||
|
else "Worktree requires a branch name.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
worktree = WorktreeSpec(
|
||||||
|
base_repo = baseRepo,
|
||||||
|
branch = branch,
|
||||||
|
create_branch = values.worktreeBranchMode != WorktreeBranchMode.Existing,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return SpawnBuildResult.Ok(
|
||||||
|
CreateSessionRequest(
|
||||||
|
cwd = values.cwd,
|
||||||
|
command = values.command,
|
||||||
|
metadata = metadata,
|
||||||
|
worktree = worktree,
|
||||||
|
view = if (values.structuredView && supportsStructuredView(values.command)) {
|
||||||
|
SessionView.Structured
|
||||||
|
} else {
|
||||||
|
SessionView.Terminal
|
||||||
|
},
|
||||||
|
auto_approve = values.autoApprove,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Built-ins shown when the daemon's `/api/info` presets haven't loaded yet
|
||||||
|
* (or can't be fetched). Mirrors `FALLBACK_PRESETS` in
|
||||||
|
* `web/src/components/spawn/model.ts`, reordered per #269's chip row. */
|
||||||
|
val FALLBACK_PRESETS: List<SpawnPreset> = listOf(
|
||||||
|
SpawnPreset(id = "claude", label = "Claude", command = "claude"),
|
||||||
|
SpawnPreset(id = "codex", label = "Codex", command = "codex"),
|
||||||
|
SpawnPreset(id = "zsh", label = "Zsh", command = "zsh"),
|
||||||
|
SpawnPreset(id = "custom", label = "Custom…", command = ""),
|
||||||
|
)
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
package pt.naps.maestro.ui.spawn
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.SheetState
|
||||||
|
import androidx.compose.material3.Snackbar
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import pt.naps.maestro.data.BranchInfo
|
||||||
|
import pt.naps.maestro.data.Project
|
||||||
|
import pt.naps.maestro.data.Session
|
||||||
|
import pt.naps.maestro.data.SpawnPreset
|
||||||
|
|
||||||
|
/** Bottom sheet spawn form (#269), matching the web spawn modal's flow:
|
||||||
|
* a preset chip row, a searchable project list, a raw-path autocomplete
|
||||||
|
* fallback, a branch/worktree picker, a session name, and the
|
||||||
|
* structured-view toggle. [SpawnSheet] wires this to a live [SpawnViewModel];
|
||||||
|
* [SpawnSheetContent] is the pure, state-driven body used by previews and
|
||||||
|
* tests. */
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SpawnSheet(
|
||||||
|
viewModel: SpawnViewModel,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onSpawned: (Session) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
|
|
||||||
|
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, modifier = modifier) {
|
||||||
|
SpawnSheetContent(
|
||||||
|
state = state,
|
||||||
|
onSelectPreset = viewModel::selectPreset,
|
||||||
|
onCustomCommandChange = viewModel::setCustomCommand,
|
||||||
|
onStructuredViewChange = viewModel::setStructuredView,
|
||||||
|
onProjectQueryChange = viewModel::setProjectQuery,
|
||||||
|
onSelectProject = viewModel::selectProject,
|
||||||
|
onCwdChange = viewModel::setCwd,
|
||||||
|
onPickDirSuggestion = viewModel::pickDirSuggestion,
|
||||||
|
onWorktreeEnabledChange = viewModel::setWorktreeEnabled,
|
||||||
|
onBranchModeChange = viewModel::setBranchMode,
|
||||||
|
onNewBranchChange = viewModel::setNewBranch,
|
||||||
|
onExistingBranchChange = viewModel::setExistingBranch,
|
||||||
|
onNameChange = viewModel::setName,
|
||||||
|
onReroll = viewModel::rerollName,
|
||||||
|
onDismissError = viewModel::dismissError,
|
||||||
|
onSubmit = { viewModel.submit(onSpawned) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SpawnSheetContent(
|
||||||
|
state: SpawnUiState,
|
||||||
|
onSelectPreset: (String) -> Unit,
|
||||||
|
onCustomCommandChange: (String) -> Unit,
|
||||||
|
onStructuredViewChange: (Boolean) -> Unit,
|
||||||
|
onProjectQueryChange: (String) -> Unit,
|
||||||
|
onSelectProject: (Project) -> Unit,
|
||||||
|
onCwdChange: (String) -> Unit,
|
||||||
|
onPickDirSuggestion: (String) -> Unit,
|
||||||
|
onWorktreeEnabledChange: (Boolean) -> Unit,
|
||||||
|
onBranchModeChange: (WorktreeBranchMode) -> Unit,
|
||||||
|
onNewBranchChange: (String) -> Unit,
|
||||||
|
onExistingBranchChange: (String) -> Unit,
|
||||||
|
onNameChange: (String) -> Unit,
|
||||||
|
onReroll: () -> Unit,
|
||||||
|
onDismissError: () -> Unit,
|
||||||
|
onSubmit: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
LaunchedEffect(state.error) {
|
||||||
|
val message = state.error ?: return@LaunchedEffect
|
||||||
|
snackbarHostState.showSnackbar(message)
|
||||||
|
onDismissError()
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(modifier = modifier.fillMaxWidth()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.padding(bottom = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Text("Spawn session", style = MaterialTheme.typography.titleMedium)
|
||||||
|
|
||||||
|
PresetRow(
|
||||||
|
presets = state.presets,
|
||||||
|
selectedPresetId = state.selectedPresetId,
|
||||||
|
onSelectPreset = onSelectPreset,
|
||||||
|
)
|
||||||
|
if (state.isCustomSelected) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.customCommand,
|
||||||
|
onValueChange = onCustomCommandChange,
|
||||||
|
label = { Text("Command") },
|
||||||
|
placeholder = { Text("bash -l") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ProjectPicker(
|
||||||
|
query = state.projectQuery,
|
||||||
|
onQueryChange = onProjectQueryChange,
|
||||||
|
projects = state.visibleProjects,
|
||||||
|
loading = state.projectsLoading,
|
||||||
|
selectedPath = state.selectedProjectPath,
|
||||||
|
homeDir = state.homeDir,
|
||||||
|
onSelectProject = onSelectProject,
|
||||||
|
)
|
||||||
|
|
||||||
|
DirAutocompleteField(
|
||||||
|
cwd = state.cwd,
|
||||||
|
suggestions = state.dirSuggestions,
|
||||||
|
onCwdChange = onCwdChange,
|
||||||
|
onPickSuggestion = onPickDirSuggestion,
|
||||||
|
)
|
||||||
|
|
||||||
|
BranchSection(
|
||||||
|
enabled = state.worktreeEnabled,
|
||||||
|
onEnabledChange = onWorktreeEnabledChange,
|
||||||
|
baseRepo = state.baseRepo,
|
||||||
|
mode = state.branchMode,
|
||||||
|
onModeChange = onBranchModeChange,
|
||||||
|
newBranch = state.newBranch,
|
||||||
|
onNewBranchChange = onNewBranchChange,
|
||||||
|
branches = state.branches,
|
||||||
|
branchesLoading = state.branchesLoading,
|
||||||
|
existingBranch = state.effectiveExistingBranch,
|
||||||
|
onExistingBranchChange = onExistingBranchChange,
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.name,
|
||||||
|
onValueChange = onNameChange,
|
||||||
|
label = { Text("Session name") },
|
||||||
|
placeholder = { Text("named after your first prompt") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
TextButton(onClick = onReroll) { Text("Reroll") }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (supportsStructuredView(state.command)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Checkbox(checked = state.structuredView, onCheckedChange = onStructuredViewChange)
|
||||||
|
Text("Structured view")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = onSubmit,
|
||||||
|
enabled = !state.submitting,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
if (state.submitting) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.height(18.dp), strokeWidth = 2.dp)
|
||||||
|
} else {
|
||||||
|
Text("Spawn")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SnackbarHost(hostState = snackbarHostState, modifier = Modifier.align(Alignment.BottomCenter)) {
|
||||||
|
Snackbar(snackbarData = it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A row is its own small touch target: tap anywhere in it, not just the
|
||||||
|
* leading control, to select it. */
|
||||||
|
private fun Modifier.clickableRow(enabled: Boolean = true, onClick: () -> Unit): Modifier =
|
||||||
|
this.clickable(enabled = enabled, onClick = onClick)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PresetRow(presets: List<SpawnPreset>, selectedPresetId: String, onSelectPreset: (String) -> Unit) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Text("Harness", style = MaterialTheme.typography.labelLarge)
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||||
|
) {
|
||||||
|
for (preset in presets) {
|
||||||
|
val label = if (preset.command.isEmpty()) "Custom…" else preset.label
|
||||||
|
FilterChip(
|
||||||
|
selected = preset.id == selectedPresetId,
|
||||||
|
onClick = { onSelectPreset(preset.id) },
|
||||||
|
label = { Text(label) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ProjectPicker(
|
||||||
|
query: String,
|
||||||
|
onQueryChange: (String) -> Unit,
|
||||||
|
projects: List<Project>,
|
||||||
|
loading: Boolean,
|
||||||
|
selectedPath: String?,
|
||||||
|
homeDir: String,
|
||||||
|
onSelectProject: (Project) -> Unit,
|
||||||
|
) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Text("Project", style = MaterialTheme.typography.labelLarge)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = query,
|
||||||
|
onValueChange = onQueryChange,
|
||||||
|
placeholder = { Text("Filter projects…") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
when {
|
||||||
|
loading -> Text(
|
||||||
|
"Loading projects…",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
projects.isEmpty() -> Text(
|
||||||
|
"No projects match.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
else -> LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 220.dp)) {
|
||||||
|
items(projects, key = { it.path }) { project ->
|
||||||
|
ProjectRow(
|
||||||
|
project = project,
|
||||||
|
active = project.path == selectedPath,
|
||||||
|
homeDir = homeDir,
|
||||||
|
onClick = { onSelectProject(project) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ProjectRow(project: Project, active: Boolean, homeDir: String, onClick: () -> Unit) {
|
||||||
|
val background = if (active) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surface
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(MaterialTheme.shapes.small)
|
||||||
|
.background(background)
|
||||||
|
.clickableRow(onClick = onClick)
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
Text(project.name, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
Text(
|
||||||
|
abbreviateHome(parentDir(project.path), homeDir),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun abbreviateHome(path: String, homeDir: String): String =
|
||||||
|
if (homeDir.isNotEmpty() && path.startsWith(homeDir)) "~${path.removePrefix(homeDir)}" else path
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DirAutocompleteField(
|
||||||
|
cwd: String,
|
||||||
|
suggestions: List<String>,
|
||||||
|
onCwdChange: (String) -> Unit,
|
||||||
|
onPickSuggestion: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = cwd,
|
||||||
|
onValueChange = onCwdChange,
|
||||||
|
label = { Text("Or type a directory") },
|
||||||
|
placeholder = { Text("/home/user/project") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
if (suggestions.isNotEmpty()) {
|
||||||
|
LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 160.dp)) {
|
||||||
|
items(suggestions) { entry ->
|
||||||
|
Text(
|
||||||
|
text = "$entry/",
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickableRow { onPickSuggestion(entry) }
|
||||||
|
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BranchSection(
|
||||||
|
enabled: Boolean,
|
||||||
|
onEnabledChange: (Boolean) -> Unit,
|
||||||
|
baseRepo: String?,
|
||||||
|
mode: WorktreeBranchMode,
|
||||||
|
onModeChange: (WorktreeBranchMode) -> Unit,
|
||||||
|
newBranch: String,
|
||||||
|
onNewBranchChange: (String) -> Unit,
|
||||||
|
branches: List<BranchInfo>,
|
||||||
|
branchesLoading: Boolean,
|
||||||
|
existingBranch: String,
|
||||||
|
onExistingBranchChange: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Checkbox(checked = enabled, onCheckedChange = onEnabledChange)
|
||||||
|
Text("Create git worktree")
|
||||||
|
}
|
||||||
|
if (!enabled) return@Column
|
||||||
|
|
||||||
|
if (baseRepo == null) {
|
||||||
|
Text(
|
||||||
|
"Choose a directory inside a git repository first.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
return@Column
|
||||||
|
}
|
||||||
|
Text("repo: $baseRepo", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
RadioButton(selected = mode == WorktreeBranchMode.New, onClick = { onModeChange(WorktreeBranchMode.New) })
|
||||||
|
Text("New branch")
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
RadioButton(selected = mode == WorktreeBranchMode.Existing, onClick = { onModeChange(WorktreeBranchMode.Existing) })
|
||||||
|
Text("Existing branch")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode == WorktreeBranchMode.New) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = newBranch,
|
||||||
|
onValueChange = onNewBranchChange,
|
||||||
|
label = { Text("New branch name") },
|
||||||
|
placeholder = { Text("feat/foo") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
when {
|
||||||
|
branchesLoading -> Text(
|
||||||
|
"Loading branches…",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
branches.isEmpty() -> Text(
|
||||||
|
"No local branches found.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
else -> LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 180.dp)) {
|
||||||
|
items(branches, key = { it.name }) { branch ->
|
||||||
|
val held = branch.worktree ?: (if (branch.is_head) "the main checkout" else null)
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickableRow(enabled = held == null) { onExistingBranchChange(branch.name) }
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
RadioButton(
|
||||||
|
selected = branch.name == existingBranch,
|
||||||
|
onClick = { onExistingBranchChange(branch.name) },
|
||||||
|
enabled = held == null,
|
||||||
|
)
|
||||||
|
Column {
|
||||||
|
Text(branch.name, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
if (held != null) {
|
||||||
|
Text(
|
||||||
|
"held by $held",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
package pt.naps.maestro.ui.spawn
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import pt.naps.maestro.api.MaestroApiException
|
||||||
|
import pt.naps.maestro.api.MaestroClient
|
||||||
|
import pt.naps.maestro.data.BranchInfo
|
||||||
|
import pt.naps.maestro.data.Project
|
||||||
|
import pt.naps.maestro.data.Session
|
||||||
|
import pt.naps.maestro.data.SpawnPreset
|
||||||
|
|
||||||
|
/** Quiet period after the operator types a raw path before `/api/dirs` and
|
||||||
|
* `/api/gitroot` fire (#269). */
|
||||||
|
private const val DIR_AUTOCOMPLETE_DEBOUNCE_MS = 300L
|
||||||
|
|
||||||
|
/** Everything above the last segment of an absolute path: `/a/b/c` -> `/a/b`.
|
||||||
|
* A top-level path keeps `/`, and a trailing slash is ignored. Mirrors
|
||||||
|
* `parentDir` in `web/src/paths.ts`. */
|
||||||
|
internal fun parentDir(path: String): String {
|
||||||
|
val trimmed = if (path.length > 1 && path.endsWith("/")) path.dropLast(1) else path
|
||||||
|
val slash = trimmed.lastIndexOf('/')
|
||||||
|
return when {
|
||||||
|
slash < 0 -> ""
|
||||||
|
slash == 0 -> "/"
|
||||||
|
else -> trimmed.substring(0, slash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Splits a path into its directory and trailing (possibly partial) segment.
|
||||||
|
* Mirrors `splitPath` in `web/src/paths.ts`. */
|
||||||
|
internal fun splitPath(value: String): Pair<String, String> {
|
||||||
|
val slash = value.lastIndexOf('/')
|
||||||
|
return if (slash == -1) "" to value else value.substring(0, slash + 1) to value.substring(slash + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class SpawnUiState(
|
||||||
|
val presets: List<SpawnPreset> = FALLBACK_PRESETS,
|
||||||
|
val selectedPresetId: String = "",
|
||||||
|
val customCommand: String = "",
|
||||||
|
val commandTouched: Boolean = false,
|
||||||
|
val structuredView: Boolean = false,
|
||||||
|
val structuredViewTouched: Boolean = false,
|
||||||
|
val homeDir: String = "",
|
||||||
|
val projects: List<Project> = emptyList(),
|
||||||
|
val projectsLoading: Boolean = true,
|
||||||
|
val projectQuery: String = "",
|
||||||
|
val selectedProjectPath: String? = null,
|
||||||
|
val cwd: String = "",
|
||||||
|
val dirSuggestions: List<String> = emptyList(),
|
||||||
|
val baseRepo: String? = null,
|
||||||
|
val worktreeEnabled: Boolean = false,
|
||||||
|
val branchMode: WorktreeBranchMode = WorktreeBranchMode.New,
|
||||||
|
val newBranch: String = "",
|
||||||
|
val existingBranch: String = "",
|
||||||
|
val branches: List<BranchInfo> = emptyList(),
|
||||||
|
val branchesLoading: Boolean = false,
|
||||||
|
val name: String = "",
|
||||||
|
val submitting: Boolean = false,
|
||||||
|
val error: String? = null,
|
||||||
|
) {
|
||||||
|
val activePreset: SpawnPreset? get() = presets.find { it.id == selectedPresetId }
|
||||||
|
val isCustomSelected: Boolean get() = activePreset == null || activePreset?.command == ""
|
||||||
|
val command: String get() = if (isCustomSelected) customCommand.trim() else activePreset?.command.orEmpty()
|
||||||
|
|
||||||
|
val visibleProjects: List<Project>
|
||||||
|
get() {
|
||||||
|
val query = projectQuery.trim().lowercase()
|
||||||
|
val filtered = if (query.isEmpty()) {
|
||||||
|
projects
|
||||||
|
} else {
|
||||||
|
projects.filter { it.name.lowercase().contains(query) || it.path.lowercase().contains(query) }
|
||||||
|
}
|
||||||
|
return filtered.sortedByDescending { runCatching { Instant.parse(it.last_activity) }.getOrNull() }
|
||||||
|
}
|
||||||
|
|
||||||
|
val effectiveExistingBranch: String
|
||||||
|
get() = existingBranch.ifEmpty {
|
||||||
|
branches.firstOrNull { it.worktree == null && !it.is_head }?.name.orEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Form state and API calls behind [SpawnSheet]. A plain class rather than an
|
||||||
|
* `androidx.lifecycle.ViewModel` — the sheet's own `remember` scope is enough
|
||||||
|
* for a short-lived form, and this keeps the class trivially testable with a
|
||||||
|
* `TestScope`, the same shape as [pt.naps.maestro.connection.DaemonLink].
|
||||||
|
*/
|
||||||
|
class SpawnViewModel(
|
||||||
|
private val client: MaestroClient,
|
||||||
|
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
||||||
|
) {
|
||||||
|
private val _state = MutableStateFlow(SpawnUiState())
|
||||||
|
val state: StateFlow<SpawnUiState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private var dirJob: Job? = null
|
||||||
|
private var branchesJob: Job? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
scope.launch {
|
||||||
|
val info = runCatching { client.getDaemonInfo() }.getOrNull()
|
||||||
|
_state.update { s ->
|
||||||
|
val presets = info?.presets?.takeIf { it.isNotEmpty() } ?: FALLBACK_PRESETS
|
||||||
|
val seeded = if (!s.commandTouched && s.selectedPresetId.isEmpty()) {
|
||||||
|
presets.firstOrNull { it.command.isNotEmpty() }?.id ?: s.selectedPresetId
|
||||||
|
} else {
|
||||||
|
s.selectedPresetId
|
||||||
|
}
|
||||||
|
s.copy(homeDir = info?.home_dir.orEmpty(), presets = presets, selectedPresetId = seeded)
|
||||||
|
.withSyncedStructuredView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scope.launch {
|
||||||
|
val projects = runCatching { client.listProjects() }.getOrDefault(emptyList())
|
||||||
|
_state.update { it.copy(projects = projects, projectsLoading = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectPreset(id: String) {
|
||||||
|
_state.update { it.copy(selectedPresetId = id, commandTouched = true).withSyncedStructuredView() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCustomCommand(text: String) {
|
||||||
|
_state.update { it.copy(customCommand = text, commandTouched = true).withSyncedStructuredView() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setStructuredView(enabled: Boolean) {
|
||||||
|
_state.update { it.copy(structuredView = enabled, structuredViewTouched = true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-derives the structured-view checkbox from the command this state
|
||||||
|
* carries, unless the operator has already ticked/unticked it by hand. */
|
||||||
|
private fun SpawnUiState.withSyncedStructuredView(): SpawnUiState =
|
||||||
|
if (structuredViewTouched) this else copy(structuredView = supportsStructuredView(command))
|
||||||
|
|
||||||
|
fun setProjectQuery(query: String) {
|
||||||
|
_state.update { it.copy(projectQuery = query) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectProject(project: Project) {
|
||||||
|
dirJob?.cancel()
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
selectedProjectPath = project.path,
|
||||||
|
cwd = project.path,
|
||||||
|
dirSuggestions = emptyList(),
|
||||||
|
baseRepo = project.base_repo ?: project.path,
|
||||||
|
branches = emptyList(),
|
||||||
|
existingBranch = "",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The operator typed a path by hand: clears any project pick, and
|
||||||
|
* schedules a debounced refresh of both the directory-suggestion
|
||||||
|
* dropdown and the git-root probe that resolves [SpawnUiState.baseRepo]. */
|
||||||
|
fun setCwd(raw: String) {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
cwd = raw,
|
||||||
|
selectedProjectPath = null,
|
||||||
|
baseRepo = null,
|
||||||
|
branches = emptyList(),
|
||||||
|
existingBranch = "",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
dirJob?.cancel()
|
||||||
|
dirJob = scope.launch {
|
||||||
|
delay(DIR_AUTOCOMPLETE_DEBOUNCE_MS)
|
||||||
|
refreshDirSuggestions(raw)
|
||||||
|
refreshBaseRepo(raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Appends [entry] as the next path segment, matching the `cd dir/sub`
|
||||||
|
* drill-in gesture the web `DirAutocomplete` uses. */
|
||||||
|
fun pickDirSuggestion(entry: String) {
|
||||||
|
val (dir, _) = splitPath(_state.value.cwd)
|
||||||
|
setCwd("$dir$entry/")
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun refreshDirSuggestions(raw: String) {
|
||||||
|
val (dir, prefix) = splitPath(raw)
|
||||||
|
val entries = runCatching { client.listDir(dir).entries }.getOrDefault(emptyList())
|
||||||
|
if (_state.value.cwd != raw) return // a newer edit superseded this one
|
||||||
|
val filtered = entries.filter { it.startsWith(prefix, ignoreCase = true) }
|
||||||
|
_state.update { it.copy(dirSuggestions = filtered) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun refreshBaseRepo(raw: String) {
|
||||||
|
val root = runCatching { client.gitRoot(raw).root }.getOrNull()
|
||||||
|
if (_state.value.cwd != raw) return // a newer edit superseded this one
|
||||||
|
_state.update { it.copy(baseRepo = root) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setWorktreeEnabled(enabled: Boolean) {
|
||||||
|
_state.update { it.copy(worktreeEnabled = enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setBranchMode(mode: WorktreeBranchMode) {
|
||||||
|
_state.update { it.copy(branchMode = mode) }
|
||||||
|
if (mode == WorktreeBranchMode.Existing) loadBranchesIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setNewBranch(text: String) {
|
||||||
|
_state.update { it.copy(newBranch = text) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setExistingBranch(text: String) {
|
||||||
|
_state.update { it.copy(existingBranch = text) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadBranchesIfNeeded() {
|
||||||
|
val cwd = _state.value.cwd
|
||||||
|
if (cwd.isEmpty() || _state.value.branchesLoading) return
|
||||||
|
branchesJob?.cancel()
|
||||||
|
_state.update { it.copy(branchesLoading = true) }
|
||||||
|
branchesJob = scope.launch {
|
||||||
|
val branches = runCatching { client.listBranches(cwd) }.getOrDefault(emptyList())
|
||||||
|
if (_state.value.cwd != cwd) return@launch // a newer cwd superseded this fetch
|
||||||
|
_state.update { it.copy(branches = branches, branchesLoading = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setName(text: String) {
|
||||||
|
_state.update { it.copy(name = text) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `GET /api/names?count=1`: replaces the session-name field with a
|
||||||
|
* freshly generated handle. */
|
||||||
|
fun rerollName() {
|
||||||
|
scope.launch {
|
||||||
|
val handle = runCatching { client.fetchNames(1).names.firstOrNull() }.getOrNull()
|
||||||
|
if (handle != null) _state.update { it.copy(name = handle) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validates the form, `POST`s it, and calls [onSuccess] with the new
|
||||||
|
* session. Any failure — validation or API — lands in
|
||||||
|
* [SpawnUiState.error] instead. */
|
||||||
|
fun submit(onSuccess: (Session) -> Unit) {
|
||||||
|
val s = _state.value
|
||||||
|
val values = SpawnFormValues(
|
||||||
|
command = s.command,
|
||||||
|
cwd = s.cwd,
|
||||||
|
name = s.name,
|
||||||
|
structuredView = s.structuredView,
|
||||||
|
autoApprove = s.activePreset?.auto_approve ?: false,
|
||||||
|
worktreeEnabled = s.worktreeEnabled,
|
||||||
|
worktreeBranch = if (s.branchMode == WorktreeBranchMode.Existing) s.effectiveExistingBranch else s.newBranch,
|
||||||
|
worktreeBranchMode = s.branchMode,
|
||||||
|
)
|
||||||
|
when (val built = buildSpawnRequest(values, s.baseRepo)) {
|
||||||
|
is SpawnBuildResult.Error -> _state.update { it.copy(error = built.message) }
|
||||||
|
is SpawnBuildResult.Ok -> {
|
||||||
|
_state.update { it.copy(submitting = true, error = null) }
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val session = client.createSession(built.request)
|
||||||
|
_state.update { it.copy(submitting = false) }
|
||||||
|
onSuccess(session)
|
||||||
|
} catch (e: MaestroApiException) {
|
||||||
|
_state.update { it.copy(submitting = false, error = e.message ?: e.detail) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_state.update { it.copy(submitting = false, error = e.message ?: "Failed to spawn session.") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissError() {
|
||||||
|
_state.update { it.copy(error = null) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package pt.naps.maestro.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
val Purple80 = Color(0xFFD0BCFF)
|
||||||
|
val PurpleGrey80 = Color(0xFFCCC2DC)
|
||||||
|
val Pink80 = Color(0xFFEFB8C8)
|
||||||
|
|
||||||
|
val Purple40 = Color(0xFF6650a4)
|
||||||
|
val PurpleGrey40 = Color(0xFF625b71)
|
||||||
|
val Pink40 = Color(0xFF7D5260)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package pt.naps.maestro.ui.theme
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
|
import androidx.compose.material3.lightColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
|
||||||
|
private val DarkColorScheme = darkColorScheme(
|
||||||
|
primary = Purple80,
|
||||||
|
secondary = PurpleGrey80,
|
||||||
|
tertiary = Pink80,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val LightColorScheme = lightColorScheme(
|
||||||
|
primary = Purple40,
|
||||||
|
secondary = PurpleGrey40,
|
||||||
|
tertiary = Pink40,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MaestroTheme(
|
||||||
|
darkTheme: Boolean = true,
|
||||||
|
dynamicColor: Boolean = false,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
val useDarkTheme = darkTheme || isSystemInDarkTheme()
|
||||||
|
val colorScheme = when {
|
||||||
|
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||||
|
val context = LocalContext.current
|
||||||
|
if (useDarkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||||
|
}
|
||||||
|
useDarkTheme -> DarkColorScheme
|
||||||
|
else -> LightColorScheme
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterialTheme(
|
||||||
|
colorScheme = colorScheme,
|
||||||
|
typography = Typography,
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package pt.naps.maestro.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.material3.Typography
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
val Typography = Typography(
|
||||||
|
bodyLarge = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
lineHeight = 24.sp,
|
||||||
|
letterSpacing = 0.5.sp,
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<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:pathData="M0,0h108v108h-108z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<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" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">Maestro</string>
|
||||||
|
<string name="placeholder_message">Maestro is not connected to a daemon yet.</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<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>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package pt.naps.maestro
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ExampleUnitTest {
|
||||||
|
@Test
|
||||||
|
fun addition_isCorrect() {
|
||||||
|
assertEquals(4, 2 + 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package pt.naps.maestro.connection
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class BackoffTest {
|
||||||
|
@Test
|
||||||
|
fun `doubles from 500ms and caps at 10s`() {
|
||||||
|
val backoff = Backoff(initialMs = 500L, maxMs = 10_000L)
|
||||||
|
val delays = List(8) { backoff.nextDelayMs() }
|
||||||
|
assertEquals(listOf(500L, 1000L, 2000L, 4000L, 8000L, 10_000L, 10_000L, 10_000L), delays)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reset returns to the initial delay`() {
|
||||||
|
val backoff = Backoff(initialMs = 500L, maxMs = 10_000L)
|
||||||
|
repeat(4) { backoff.nextDelayMs() }
|
||||||
|
backoff.reset()
|
||||||
|
assertEquals(500L, backoff.nextDelayMs())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package pt.naps.maestro.connection
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
import pt.naps.maestro.data.CatalogEvent
|
||||||
|
import pt.naps.maestro.data.Session
|
||||||
|
import pt.naps.maestro.data.SessionStatus
|
||||||
|
|
||||||
|
class CatalogStoreTest {
|
||||||
|
private fun session(id: String) = Session(
|
||||||
|
id = id,
|
||||||
|
cwd = "/tmp",
|
||||||
|
command = "bash",
|
||||||
|
status = SessionStatus.Running,
|
||||||
|
created_at = "2026-05-24T12:00:00Z",
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `snapshot replaces the map keyed by id`() {
|
||||||
|
val store = CatalogStore()
|
||||||
|
store.apply(CatalogEvent.Snapshot(listOf(session("a"), session("b"))))
|
||||||
|
assertEquals(setOf("a", "b"), store.sessions.value.keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `added and updated upsert one entry`() {
|
||||||
|
val store = CatalogStore()
|
||||||
|
store.apply(CatalogEvent.Snapshot(emptyList()))
|
||||||
|
store.apply(CatalogEvent.SessionAdded(session("a")))
|
||||||
|
assertEquals(1, store.sessions.value.size)
|
||||||
|
|
||||||
|
val renamed = session("a").copy(command = "zsh")
|
||||||
|
store.apply(CatalogEvent.SessionUpdated(renamed))
|
||||||
|
assertEquals("zsh", store.sessions.value.getValue("a").command)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `removed drops the entry`() {
|
||||||
|
val store = CatalogStore()
|
||||||
|
store.apply(CatalogEvent.Snapshot(listOf(session("a"), session("b"))))
|
||||||
|
store.apply(CatalogEvent.SessionRemoved("a"))
|
||||||
|
assertEquals(setOf("b"), store.sessions.value.keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `lagged reports the skip count without touching the map`() {
|
||||||
|
val store = CatalogStore()
|
||||||
|
store.apply(CatalogEvent.Snapshot(listOf(session("a"))))
|
||||||
|
val skipped = store.apply(CatalogEvent.Lagged(3))
|
||||||
|
assertEquals(3L, skipped)
|
||||||
|
assertEquals(setOf("a"), store.sessions.value.keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `non-lagged events report no skip`() {
|
||||||
|
val store = CatalogStore()
|
||||||
|
assertNull(store.apply(CatalogEvent.Snapshot(emptyList())))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package pt.naps.maestro.connection
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
import pt.naps.maestro.data.BuildInfo
|
||||||
|
import pt.naps.maestro.data.DaemonInfo
|
||||||
|
|
||||||
|
class DaemonIdentityTest {
|
||||||
|
private fun info(commit: String = "", instance: String = "") =
|
||||||
|
DaemonInfo(build = BuildInfo(commit = commit), instance = instance)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `same commit and instance is Same`() {
|
||||||
|
val a = info(commit = "abc123", instance = "i1")
|
||||||
|
assertEquals(RestartKind.SAME, compareIdentity(a, a.copy()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `different commit is Upgraded`() {
|
||||||
|
val before = info(commit = "abc123", instance = "i1")
|
||||||
|
val after = info(commit = "def456", instance = "i2")
|
||||||
|
assertEquals(RestartKind.UPGRADED, compareIdentity(before, after))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `same commit but different instance is Restarted`() {
|
||||||
|
val before = info(commit = "abc123", instance = "i1")
|
||||||
|
val after = info(commit = "abc123", instance = "i2")
|
||||||
|
assertEquals(RestartKind.RESTARTED, compareIdentity(before, after))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `empty commits on both sides never read as an upgrade`() {
|
||||||
|
val before = info(commit = "", instance = "i1")
|
||||||
|
val after = info(commit = "", instance = "i2")
|
||||||
|
assertEquals(RestartKind.RESTARTED, compareIdentity(before, after))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `format release names the commit and short date`() {
|
||||||
|
val build = BuildInfo(commit = "f89be4a716c7", commit_date = "2026-09-02T10:00:00Z", dirty = false)
|
||||||
|
assertEquals("f89be4a716c7 · 2026-09-02", formatRelease(build))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `dirty build is flagged`() {
|
||||||
|
val build = BuildInfo(commit = "f89be4a716c7", commit_date = "2026-09-02T10:00:00Z", dirty = true)
|
||||||
|
assertEquals("f89be4a716c7 · 2026-09-02 (dirty)", formatRelease(build))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `empty commit is unknown release`() {
|
||||||
|
assertEquals("unknown release", formatRelease(BuildInfo()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package pt.naps.maestro.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/** Wire-format checks against fixtures mirrored from
|
||||||
|
* `crates/maestro-protocol/src/lib.rs`'s own test suite, so a Kotlin type
|
||||||
|
* that drifts from the Rust source of truth fails here first. */
|
||||||
|
class WireSerializationTest {
|
||||||
|
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
|
||||||
|
|
||||||
|
private fun legacySessionJson(extra: String = "") = """
|
||||||
|
{"id":"00000000-0000-0000-0000-000000000001","cwd":"/tmp","command":"bash",
|
||||||
|
"status":"Running","created_at":"2026-05-24T12:00:00Z"$extra}
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `legacy session payload defaults new fields`() {
|
||||||
|
val s = json.decodeFromString<Session>(legacySessionJson())
|
||||||
|
assertEquals(ActivityState.Idle, s.activity)
|
||||||
|
assertEquals(SessionView.Terminal, s.view)
|
||||||
|
assertNull(s.foreground)
|
||||||
|
assertNull(s.blocked_work)
|
||||||
|
assertEquals(EPOCH, s.last_activity_at)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unknown activity state degrades to Idle`() {
|
||||||
|
val s = json.decodeFromString<Session>(legacySessionJson(""","activity":"QuantumEntangled""""))
|
||||||
|
assertEquals(ActivityState.Idle, s.activity)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unknown name source degrades to User`() {
|
||||||
|
assertEquals(NameSource.User, json.decodeFromString<NameSource>("\"Rolled\""))
|
||||||
|
assertEquals(NameSource.Haiku, json.decodeFromString<NameSource>("\"Haiku\""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `catalog event tags by type`() {
|
||||||
|
assertEquals(
|
||||||
|
"""{"type":"Lagged","skipped":3}""",
|
||||||
|
json.encodeToString<CatalogEvent>(CatalogEvent.Lagged(3)),
|
||||||
|
)
|
||||||
|
val removed = CatalogEvent.SessionRemoved("00000000-0000-0000-0000-000000000001")
|
||||||
|
assertEquals(removed, json.decodeFromString<CatalogEvent>(json.encodeToString<CatalogEvent>(removed)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `client control tags by type`() {
|
||||||
|
assertEquals(
|
||||||
|
"""{"type":"Resize","cols":120,"rows":40}""",
|
||||||
|
json.encodeToString<ClientControl>(ClientControl.Resize(120, 40)),
|
||||||
|
)
|
||||||
|
assertEquals(ClientControl.Ping, json.decodeFromString<ClientControl>("""{"type":"Ping"}"""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `structured approval decision is a bare string`() {
|
||||||
|
assertEquals(
|
||||||
|
StructuredApprovalDecision.AllowOnce,
|
||||||
|
json.decodeFromString<StructuredApprovalDecision>("\"allow_once\""),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"\"allow_for_session\"",
|
||||||
|
json.encodeToString(StructuredApprovalDecision.AllowForSession),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `structured event tags snake_case`() {
|
||||||
|
val event = json.decodeFromString<StructuredEvent>(
|
||||||
|
"""{"type":"transcript_snapshot","snapshot":{"cursor":7,"rows":[]}}""",
|
||||||
|
)
|
||||||
|
assertTrue(event is StructuredEvent.TranscriptSnapshotEvent)
|
||||||
|
assertEquals(7L, (event as StructuredEvent.TranscriptSnapshotEvent).snapshot.cursor)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `transcript delta tags snake_case and round trips`() {
|
||||||
|
val row = TranscriptRow(
|
||||||
|
id = "tool:read-1",
|
||||||
|
kind = TranscriptRowKind.ToolCall,
|
||||||
|
data = json.parseToJsonElement("""{"name":"vendor.read"}"""),
|
||||||
|
)
|
||||||
|
val patch = TranscriptDelta.Patch(row)
|
||||||
|
val encoded = json.encodeToString<TranscriptDelta>(patch)
|
||||||
|
assertTrue(encoded.startsWith("""{"type":"patch","row":"""))
|
||||||
|
assertEquals(patch, json.decodeFromString<TranscriptDelta>(encoded))
|
||||||
|
assertEquals("\"agent_thought\"", json.encodeToString(TranscriptRowKind.AgentThought))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rename request encodes plainly`() {
|
||||||
|
assertEquals(
|
||||||
|
"""{"name":"amber-falcon"}""",
|
||||||
|
json.encodeToString(RenameSessionRequest("amber-falcon")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `api error decodes nested unsaved work with defaults`() {
|
||||||
|
val apiError = json.decodeFromString<ApiError>(
|
||||||
|
"""{"error":{"code":"unsaved_work","message":"nope","unsaved_work":{"uncommitted_files":3}}}""",
|
||||||
|
)
|
||||||
|
assertEquals(3, apiError.error.unsaved_work?.uncommitted_files)
|
||||||
|
assertEquals(0, apiError.error.unsaved_work?.unpushed_commits)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package pt.naps.maestro.ui.sidebar
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import pt.naps.maestro.data.InTheWay
|
||||||
|
import pt.naps.maestro.data.Session
|
||||||
|
import pt.naps.maestro.data.SessionStatus
|
||||||
|
import pt.naps.maestro.data.SessionWorktree
|
||||||
|
|
||||||
|
private fun session(
|
||||||
|
command: String = "claude",
|
||||||
|
cwd: String = "/home/user/project",
|
||||||
|
metadata: Map<String, JsonPrimitive> = emptyMap(),
|
||||||
|
worktree: SessionWorktree? = null,
|
||||||
|
blockedWork: InTheWay? = null,
|
||||||
|
activitySince: String = "1970-01-01T00:00:00Z",
|
||||||
|
) = Session(
|
||||||
|
id = "s1",
|
||||||
|
cwd = cwd,
|
||||||
|
command = command,
|
||||||
|
metadata = metadata,
|
||||||
|
worktree = worktree,
|
||||||
|
status = SessionStatus.Running,
|
||||||
|
created_at = "2026-01-01T00:00:00Z",
|
||||||
|
blocked_work = blockedWork,
|
||||||
|
activity_since = activitySince,
|
||||||
|
)
|
||||||
|
|
||||||
|
class DurationFormattingTest {
|
||||||
|
@Test
|
||||||
|
fun `floors seconds under a minute`() {
|
||||||
|
assertEquals("30s", activityDurationLabel(30_000))
|
||||||
|
assertEquals("59s", activityDurationLabel(59_999))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `floors minutes under an hour`() {
|
||||||
|
assertEquals("4m", activityDurationLabel(4 * 60_000 + 59_000))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `floors hours with no days unit`() {
|
||||||
|
assertEquals("2h", activityDurationLabel(2 * 3_600_000 + 59 * 60_000))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `held ms is null for missing or implausible since`() {
|
||||||
|
assertNull(activityHeldMs(null, 1_000))
|
||||||
|
assertNull(activityHeldMs("", 1_000))
|
||||||
|
assertNull(activityHeldMs("1970-01-01T00:00:00Z", 1_000))
|
||||||
|
assertNull(activityHeldMs("not-a-timestamp", 1_000))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `held ms is the gap to now for a plausible timestamp`() {
|
||||||
|
val since = "2026-01-01T00:00:00Z"
|
||||||
|
val nowMs = java.time.Instant.parse("2026-01-01T00:00:31Z").toEpochMilli()
|
||||||
|
assertEquals(31_000L, activityHeldMs(since, nowMs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SessionNameTest {
|
||||||
|
@Test
|
||||||
|
fun `falls back to the command when no name is set`() {
|
||||||
|
assertEquals("claude --resume", sessionName(session(command = "claude --resume")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `uses the custom name when set`() {
|
||||||
|
val s = session(metadata = mapOf("name" to JsonPrimitive("api-work")))
|
||||||
|
assertEquals("api-work", sessionName(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an empty stored name is treated as unset`() {
|
||||||
|
val s = session(metadata = mapOf("name" to JsonPrimitive("")))
|
||||||
|
assertNull(customSessionName(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `generated name is provisional, user and haiku are not`() {
|
||||||
|
val generated = session(
|
||||||
|
metadata = mapOf(
|
||||||
|
"name" to JsonPrimitive("brave-otter"),
|
||||||
|
"name_source" to JsonPrimitive("Generated"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val user = session(
|
||||||
|
metadata = mapOf(
|
||||||
|
"name" to JsonPrimitive("api-work"),
|
||||||
|
"name_source" to JsonPrimitive("User"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val unknownSource = session(
|
||||||
|
metadata = mapOf(
|
||||||
|
"name" to JsonPrimitive("api-work"),
|
||||||
|
"name_source" to JsonPrimitive("something-new"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertTrue(isProvisionalName(generated))
|
||||||
|
assertFalse(isProvisionalName(user))
|
||||||
|
assertFalse(isProvisionalName(unknownSource))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BlockerStatsTest {
|
||||||
|
@Test
|
||||||
|
fun `renders insertions and deletions in diff notation`() {
|
||||||
|
val w = InTheWay(uncommitted_files = 2, untracked_files = 3, unpushed_commits = 2, insertions = 124, deletions = 18)
|
||||||
|
val stats = blockerStats(w).associate { it.kind to it.text }
|
||||||
|
assertEquals("+124", stats["add"])
|
||||||
|
assertEquals("-18", stats["del"])
|
||||||
|
assertEquals("?3", stats["untracked"])
|
||||||
|
assertEquals("↑2", stats["unpushed"])
|
||||||
|
assertNull(stats["files"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `falls back to a file count when a tracked change carries no line delta`() {
|
||||||
|
val w = InTheWay(uncommitted_files = 2, insertions = 0, deletions = 0)
|
||||||
|
val stats = blockerStats(w)
|
||||||
|
assertEquals(listOf(BlockerStat("files", "~2")), stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `session blockers are null when nothing blocks a kill`() {
|
||||||
|
assertNull(sessionBlockers(session(blockedWork = null)))
|
||||||
|
assertNull(sessionBlockers(session(blockedWork = InTheWay())))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `format in the way spells out zero as no unsaved work`() {
|
||||||
|
assertEquals("no unsaved work", formatInTheWay(InTheWay()))
|
||||||
|
assertEquals("1 uncommitted file, 2 untracked files", formatInTheWay(InTheWay(uncommitted_files = 1, untracked_files = 2)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProgramNameTest {
|
||||||
|
@Test
|
||||||
|
fun `drops arguments and basenames the path`() {
|
||||||
|
assertEquals("claude", programName("/usr/local/bin/claude -p hi"))
|
||||||
|
assertEquals("claude", programName("claude --dangerously-skip-permissions"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `looks through leading env assignments`() {
|
||||||
|
assertEquals("claude", programName("FOO=bar BAZ=qux claude --resume"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `looks through sudo and env wrappers with their own flags`() {
|
||||||
|
assertEquals("claude", programName("sudo claude"))
|
||||||
|
assertEquals("claude", programName("env -i FOO=bar claude"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `looks through js-toolchain launchers`() {
|
||||||
|
assertEquals("claude-code", programName("npx claude-code"))
|
||||||
|
assertEquals("claude", programName("pnpm dlx claude"))
|
||||||
|
assertEquals("claude", programName("bunx claude"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a command that is nothing but wrappers is shown as written`() {
|
||||||
|
assertEquals("sudo", programName("sudo"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class GroupingTest {
|
||||||
|
@Test
|
||||||
|
fun `groups by worktree base_repo when present`() {
|
||||||
|
val a = session(cwd = "/wt/a", worktree = SessionWorktree(path = "/wt/a", base_repo = "/home/user/maestro"))
|
||||||
|
val b = session(cwd = "/wt/b", worktree = SessionWorktree(path = "/wt/b", base_repo = "/home/user/maestro"))
|
||||||
|
val groups = groupSessions(listOf(a, b))
|
||||||
|
assertEquals(1, groups.size)
|
||||||
|
assertEquals("maestro", groups.single().label)
|
||||||
|
assertEquals(2, groups.single().sessions.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `falls back to cwd with no worktree`() {
|
||||||
|
val a = session(cwd = "/home/user/scratch")
|
||||||
|
val groups = groupSessions(listOf(a))
|
||||||
|
assertEquals("scratch", groups.single().label)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package pt.naps.maestro.ui.spawn
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import pt.naps.maestro.data.SessionView
|
||||||
|
|
||||||
|
private val base = SpawnFormValues(
|
||||||
|
command = "claude",
|
||||||
|
cwd = "/home/user/project",
|
||||||
|
name = "amber-fox",
|
||||||
|
structuredView = false,
|
||||||
|
autoApprove = false,
|
||||||
|
worktreeEnabled = false,
|
||||||
|
worktreeBranch = "",
|
||||||
|
worktreeBranchMode = WorktreeBranchMode.New,
|
||||||
|
)
|
||||||
|
|
||||||
|
class SpawnModelTest {
|
||||||
|
@Test
|
||||||
|
fun `builds a plain request with the picked cwd and command`() {
|
||||||
|
val result = buildSpawnRequest(base, null) as SpawnBuildResult.Ok
|
||||||
|
assertEquals("/home/user/project", result.request.cwd)
|
||||||
|
assertEquals("claude", result.request.command)
|
||||||
|
assertEquals(mapOf("name" to JsonPrimitive("amber-fox")), result.request.metadata)
|
||||||
|
assertEquals(SessionView.Terminal, result.request.view)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `omits metadata name when the name is blank`() {
|
||||||
|
val result = buildSpawnRequest(base.copy(name = " "), null) as SpawnBuildResult.Ok
|
||||||
|
assertTrue(result.request.metadata.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `requests structured view for resolved claude and codex commands`() {
|
||||||
|
for (command in listOf("claude", "FOO=1 codex", "env -i codex")) {
|
||||||
|
val result = buildSpawnRequest(base.copy(command = command, structuredView = true), null) as SpawnBuildResult.Ok
|
||||||
|
assertEquals("expected structured view for `$command`", SessionView.Structured, result.request.view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `does not request structured view for unsupported commands`() {
|
||||||
|
val result = buildSpawnRequest(base.copy(command = "zsh", structuredView = true), null) as SpawnBuildResult.Ok
|
||||||
|
assertEquals(SessionView.Terminal, result.request.view)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `attaches a trimmed branch when worktree is enabled`() {
|
||||||
|
val result = buildSpawnRequest(
|
||||||
|
base.copy(worktreeEnabled = true, worktreeBranch = " feat/foo "),
|
||||||
|
"/home/user/project",
|
||||||
|
) as SpawnBuildResult.Ok
|
||||||
|
assertEquals("/home/user/project", result.request.worktree?.base_repo)
|
||||||
|
assertEquals("feat/foo", result.request.worktree?.branch)
|
||||||
|
assertTrue(result.request.worktree?.create_branch == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `checks out an existing branch when the mode is existing`() {
|
||||||
|
val result = buildSpawnRequest(
|
||||||
|
base.copy(worktreeEnabled = true, worktreeBranch = "spawn-v2", worktreeBranchMode = WorktreeBranchMode.Existing),
|
||||||
|
"/home/user/project",
|
||||||
|
) as SpawnBuildResult.Ok
|
||||||
|
assertEquals(false, result.request.worktree?.create_branch)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rejects an existing-mode worktree with no branch picked`() {
|
||||||
|
val result = buildSpawnRequest(
|
||||||
|
base.copy(worktreeEnabled = true, worktreeBranch = "", worktreeBranchMode = WorktreeBranchMode.Existing),
|
||||||
|
"/repo",
|
||||||
|
)
|
||||||
|
assertTrue(result is SpawnBuildResult.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rejects a missing working directory`() {
|
||||||
|
assertTrue(buildSpawnRequest(base.copy(cwd = ""), null) is SpawnBuildResult.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rejects a missing command`() {
|
||||||
|
assertTrue(buildSpawnRequest(base.copy(command = ""), null) is SpawnBuildResult.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rejects a worktree without an inferred base repo`() {
|
||||||
|
val result = buildSpawnRequest(base.copy(worktreeEnabled = true, worktreeBranch = "b"), null)
|
||||||
|
assertTrue(result is SpawnBuildResult.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rejects a worktree without a branch name`() {
|
||||||
|
val result = buildSpawnRequest(base.copy(worktreeEnabled = true, worktreeBranch = " "), "/repo")
|
||||||
|
assertTrue(result is SpawnBuildResult.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `resolveProgram strips env assignments and paths`() {
|
||||||
|
assertEquals("codex", resolveProgram("FOO=1 /usr/local/bin/codex --flag"))
|
||||||
|
assertEquals("claude", resolveProgram("claude"))
|
||||||
|
assertEquals("zsh", resolveProgram("zsh -l"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
package pt.naps.maestro.ui.spawn
|
||||||
|
|
||||||
|
import app.cash.turbine.test
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.ConcurrentLinkedQueue
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import okhttp3.mockwebserver.Dispatcher
|
||||||
|
import okhttp3.mockwebserver.MockResponse
|
||||||
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
|
import okhttp3.mockwebserver.RecordedRequest
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import pt.naps.maestro.api.MaestroClient
|
||||||
|
import pt.naps.maestro.data.Project
|
||||||
|
|
||||||
|
/** Routes each enqueued response by request path rather than arrival order —
|
||||||
|
* [SpawnViewModel]'s init fires `/api/info` and `/api/projects` concurrently,
|
||||||
|
* so a plain FIFO queue can hand either endpoint the other's response. */
|
||||||
|
private class RoutedDispatcher : Dispatcher() {
|
||||||
|
private val queues = ConcurrentHashMap<String, ConcurrentLinkedQueue<MockResponse>>()
|
||||||
|
|
||||||
|
fun enqueue(path: String, response: MockResponse) {
|
||||||
|
queues.getOrPut(path) { ConcurrentLinkedQueue() }.add(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||||
|
val path = request.path.orEmpty().substringBefore('?')
|
||||||
|
return queues[path]?.poll() ?: MockResponse().setResponseCode(404)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SpawnViewModelTest {
|
||||||
|
private lateinit var server: MockWebServer
|
||||||
|
private lateinit var dispatcher: RoutedDispatcher
|
||||||
|
private lateinit var client: MaestroClient
|
||||||
|
private lateinit var scope: CoroutineScope
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
dispatcher = RoutedDispatcher()
|
||||||
|
server = MockWebServer()
|
||||||
|
server.dispatcher = dispatcher
|
||||||
|
server.start()
|
||||||
|
client = MaestroClient(server.url("/").toString())
|
||||||
|
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
scope.cancel()
|
||||||
|
server.shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun jsonResponse(body: String) =
|
||||||
|
MockResponse().setHeader("Content-Type", "application/json").setBody(body)
|
||||||
|
|
||||||
|
/** Enqueues a passing `/api/info` and an empty `/api/projects`, the pair
|
||||||
|
* every test needs before it can observe anything past init. */
|
||||||
|
private fun enqueueInit(infoBody: String = """{"hostname":"h","version":"1","home_dir":"","presets":[],"instance":"i"}""") {
|
||||||
|
dispatcher.enqueue("/api/info", jsonResponse(infoBody))
|
||||||
|
dispatcher.enqueue("/api/projects", jsonResponse("[]"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun newViewModel() = SpawnViewModel(client, scope)
|
||||||
|
|
||||||
|
private suspend fun SpawnViewModel.awaitState(predicate: (SpawnUiState) -> Boolean): SpawnUiState {
|
||||||
|
var result: SpawnUiState? = null
|
||||||
|
state.test {
|
||||||
|
var item = awaitItem()
|
||||||
|
while (!predicate(item)) item = awaitItem()
|
||||||
|
result = item
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
return result ?: error("state predicate never satisfied")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `loads presets from daemon info and defaults to the first one`() = runBlocking {
|
||||||
|
enqueueInit(
|
||||||
|
"""{"hostname":"h","version":"1","home_dir":"/home/x","presets":[
|
||||||
|
{"id":"claude","label":"Claude","command":"claude"},
|
||||||
|
{"id":"custom","label":"Custom…","command":""}
|
||||||
|
],"instance":"i"}""",
|
||||||
|
)
|
||||||
|
|
||||||
|
val vm = newViewModel()
|
||||||
|
val settled = vm.awaitState { it.selectedPresetId == "claude" }
|
||||||
|
|
||||||
|
assertEquals("claude", settled.command)
|
||||||
|
assertTrue("structured view should auto-tick for claude", settled.structuredView)
|
||||||
|
assertEquals("/home/x", settled.homeDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `falls back to built-in presets when daemon info fails`() = runBlocking {
|
||||||
|
dispatcher.enqueue("/api/info", MockResponse().setResponseCode(500))
|
||||||
|
dispatcher.enqueue("/api/projects", jsonResponse("[]"))
|
||||||
|
|
||||||
|
val vm = newViewModel()
|
||||||
|
val settled = vm.awaitState { it.selectedPresetId.isNotEmpty() }
|
||||||
|
|
||||||
|
assertEquals(FALLBACK_PRESETS, settled.presets)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `selecting a preset updates the command and structured view`() = runBlocking {
|
||||||
|
enqueueInit()
|
||||||
|
val vm = newViewModel()
|
||||||
|
vm.awaitState { it.selectedPresetId.isNotEmpty() } // let init settle on "claude"
|
||||||
|
|
||||||
|
vm.selectPreset("zsh")
|
||||||
|
val afterZsh = vm.awaitState { it.selectedPresetId == "zsh" }
|
||||||
|
assertEquals("zsh", afterZsh.command)
|
||||||
|
assertTrue("zsh has no structured view", !afterZsh.structuredView)
|
||||||
|
|
||||||
|
vm.selectPreset("claude")
|
||||||
|
val afterClaude = vm.awaitState { it.selectedPresetId == "claude" }
|
||||||
|
assertTrue(afterClaude.structuredView)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `manual structured view toggle survives a later preset change`() = runBlocking {
|
||||||
|
enqueueInit()
|
||||||
|
val vm = newViewModel()
|
||||||
|
vm.awaitState { it.selectedPresetId == "claude" }
|
||||||
|
|
||||||
|
vm.setStructuredView(false)
|
||||||
|
vm.selectPreset("codex")
|
||||||
|
val after = vm.awaitState { it.selectedPresetId == "codex" }
|
||||||
|
assertTrue("operator's untick should stick", !after.structuredView)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `selecting a project fills cwd and base repo`() = runBlocking {
|
||||||
|
enqueueInit()
|
||||||
|
val vm = newViewModel()
|
||||||
|
vm.awaitState { !it.projectsLoading }
|
||||||
|
|
||||||
|
vm.selectProject(Project(path = "/repo", name = "repo"))
|
||||||
|
val after = vm.awaitState { it.cwd == "/repo" }
|
||||||
|
assertEquals("/repo", after.baseRepo)
|
||||||
|
assertEquals("/repo", after.selectedProjectPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `typing a raw path debounces dir suggestions and base repo`() = runBlocking {
|
||||||
|
enqueueInit()
|
||||||
|
val vm = newViewModel()
|
||||||
|
vm.awaitState { !it.projectsLoading }
|
||||||
|
|
||||||
|
dispatcher.enqueue("/api/dirs", jsonResponse("""{"path":"/home/user","entries":["proj-a","proj-b"]}"""))
|
||||||
|
dispatcher.enqueue("/api/gitroot", jsonResponse("""{"root":"/home/user/proj-a"}"""))
|
||||||
|
|
||||||
|
vm.setCwd("/home/user/pro")
|
||||||
|
val settled = vm.awaitState { it.dirSuggestions.isNotEmpty() }
|
||||||
|
assertEquals(listOf("proj-a", "proj-b"), settled.dirSuggestions)
|
||||||
|
val withRepo = vm.awaitState { it.baseRepo != null }
|
||||||
|
assertEquals("/home/user/proj-a", withRepo.baseRepo)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reroll fills the name field from the daemon`() = runBlocking {
|
||||||
|
enqueueInit()
|
||||||
|
val vm = newViewModel()
|
||||||
|
vm.awaitState { !it.projectsLoading }
|
||||||
|
|
||||||
|
dispatcher.enqueue("/api/names", jsonResponse("""{"names":["amber-fox"]}"""))
|
||||||
|
vm.rerollName()
|
||||||
|
val after = vm.awaitState { it.name == "amber-fox" }
|
||||||
|
assertEquals("amber-fox", after.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `submit rejects a missing working directory without a network call`() = runBlocking {
|
||||||
|
enqueueInit()
|
||||||
|
val vm = newViewModel()
|
||||||
|
vm.awaitState { it.selectedPresetId == "claude" }
|
||||||
|
|
||||||
|
var succeeded = false
|
||||||
|
vm.submit { succeeded = true }
|
||||||
|
val after = vm.awaitState { it.error != null }
|
||||||
|
assertNotNull(after.error)
|
||||||
|
assertTrue(!succeeded)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `submit posts the built request and reports the new session`() = runBlocking {
|
||||||
|
enqueueInit()
|
||||||
|
val vm = newViewModel()
|
||||||
|
vm.awaitState { it.selectedPresetId == "claude" }
|
||||||
|
vm.selectProject(Project(path = "/repo", name = "repo"))
|
||||||
|
vm.awaitState { it.cwd == "/repo" }
|
||||||
|
|
||||||
|
dispatcher.enqueue(
|
||||||
|
"/api/sessions",
|
||||||
|
jsonResponse(
|
||||||
|
"""{"id":"s1","cwd":"/repo","command":"claude","status":"Running","created_at":"2026-01-01T00:00:00Z"}""",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
var result: pt.naps.maestro.data.Session? = null
|
||||||
|
vm.submit { result = it }
|
||||||
|
val after = vm.awaitState { !it.submitting && it.error == null }
|
||||||
|
assertNull(after.error)
|
||||||
|
assertEquals("s1", result?.id)
|
||||||
|
|
||||||
|
var request = server.takeRequest()
|
||||||
|
while (request.method != "POST") request = server.takeRequest()
|
||||||
|
assertEquals("/api/sessions", request.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `submit surfaces the daemon's error detail on failure`() = runBlocking {
|
||||||
|
enqueueInit()
|
||||||
|
val vm = newViewModel()
|
||||||
|
vm.awaitState { it.selectedPresetId == "claude" }
|
||||||
|
vm.selectProject(Project(path = "/repo", name = "repo"))
|
||||||
|
vm.awaitState { it.cwd == "/repo" }
|
||||||
|
|
||||||
|
dispatcher.enqueue(
|
||||||
|
"/api/sessions",
|
||||||
|
MockResponse().setResponseCode(400).setHeader("Content-Type", "application/json")
|
||||||
|
.setBody("""{"error":{"code":"bad_request","message":"cwd does not exist"}}"""),
|
||||||
|
)
|
||||||
|
vm.submit {}
|
||||||
|
val after = vm.awaitState { it.error != null }
|
||||||
|
assertTrue(after.error!!.contains("cwd does not exist"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application) apply false
|
||||||
|
alias(libs.plugins.kotlin.android) apply false
|
||||||
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
|
alias(libs.plugins.kotlin.serialization) apply false
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
org.gradle.parallel=true
|
||||||
|
android.useAndroidX=true
|
||||||
|
kotlin.code.style=official
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
[versions]
|
||||||
|
agp = "8.5.2"
|
||||||
|
kotlin = "2.0.21"
|
||||||
|
coreKtx = "1.13.1"
|
||||||
|
lifecycleRuntimeKtx = "2.8.6"
|
||||||
|
activityCompose = "1.9.2"
|
||||||
|
composeBom = "2024.09.03"
|
||||||
|
navigationCompose = "2.8.2"
|
||||||
|
okhttp = "4.12.0"
|
||||||
|
kotlinxSerializationJson = "1.7.3"
|
||||||
|
kotlinxCoroutines = "1.9.0"
|
||||||
|
turbine = "1.1.0"
|
||||||
|
|
||||||
|
[libraries]
|
||||||
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
|
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||||
|
androidx-lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycleRuntimeKtx" }
|
||||||
|
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
|
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||||
|
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||||
|
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
||||||
|
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||||
|
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||||
|
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
|
||||||
|
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||||
|
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||||
|
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
|
||||||
|
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
||||||
|
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
|
||||||
|
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
|
||||||
|
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
|
||||||
|
okhttp-mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "okhttp" }
|
||||||
|
turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" }
|
||||||
|
junit = { group = "junit", name = "junit", version = "4.13.2" }
|
||||||
|
androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version = "1.2.1" }
|
||||||
|
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version = "3.6.1" }
|
||||||
|
|
||||||
|
[plugins]
|
||||||
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
|
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||||
BIN
Binary file not shown.
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
+237
@@ -0,0 +1,237 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015-2021 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# issues, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated via the Gradle "wrapper" task
|
||||||
|
# (see https://docs.gradle.org/current/userguide/gradle_wrapper.html)
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH="$APP_HOME/gradle/wrapper/gradle-wrapper.jar"
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command;
|
||||||
|
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments that
|
||||||
|
# shell script including quotes and variable substitutions, so put them in
|
||||||
|
# double quotes to make sure that they get re-expanded; and
|
||||||
|
# * put everything else in single quotes, so that it's not re-expanded.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
org.gradle.wrapper.GradleWrapperMain \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-A-Za-z0-9_+.]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
Vendored
+89
@@ -0,0 +1,89 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" -Dorg.gradle.appname=%APP_BASE_NAME% -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "Maestro"
|
||||||
|
|
||||||
|
include(":app")
|
||||||
@@ -58,6 +58,24 @@ use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
|
|||||||
|
|
||||||
pub use agent_client_protocol::schema::v1 as schema;
|
pub use agent_client_protocol::schema::v1 as schema;
|
||||||
|
|
||||||
|
/// Item on the updates channel. Notifications and turn-done markers share
|
||||||
|
/// the same FIFO so the event pump processes every notification for a turn
|
||||||
|
/// before seeing its done signal.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum AcpUpdate {
|
||||||
|
/// A raw `session/update` notification from the adapter.
|
||||||
|
Notification(Box<SessionNotification>),
|
||||||
|
/// The prompt response for `turn` arrived. All notifications for this
|
||||||
|
/// turn precede this marker on the wire, and therefore in this channel.
|
||||||
|
/// The event pump must fire `done` after persisting every preceding
|
||||||
|
/// notification.
|
||||||
|
TurnDone {
|
||||||
|
turn: u64,
|
||||||
|
result: Result<(), String>,
|
||||||
|
done: oneshot::Sender<Result<(), String>>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
/// 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 {
|
||||||
@@ -354,7 +372,7 @@ pub struct AcpSession {
|
|||||||
pid: u32,
|
pid: u32,
|
||||||
commands: mpsc::UnboundedSender<Command>,
|
commands: mpsc::UnboundedSender<Command>,
|
||||||
child_commands: mpsc::UnboundedSender<ChildCommand>,
|
child_commands: mpsc::UnboundedSender<ChildCommand>,
|
||||||
updates: Mutex<Option<mpsc::UnboundedReceiver<SessionNotification>>>,
|
updates: Mutex<Option<mpsc::UnboundedReceiver<AcpUpdate>>>,
|
||||||
permission_requests: Mutex<Option<mpsc::UnboundedReceiver<PermissionRequest>>>,
|
permission_requests: Mutex<Option<mpsc::UnboundedReceiver<PermissionRequest>>>,
|
||||||
exit: watch::Receiver<Option<ExitStatus>>,
|
exit: watch::Receiver<Option<ExitStatus>>,
|
||||||
}
|
}
|
||||||
@@ -393,9 +411,9 @@ impl AcpSession {
|
|||||||
wait_exit(&self.exit).await
|
wait_exit(&self.exit).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The stream of raw `session/update` notifications, handed out once.
|
/// The stream of updates (notifications + turn-done markers), handed out
|
||||||
/// Notifications that arrived before the take are still queued on it.
|
/// once. Items that arrived before the take are still queued.
|
||||||
pub fn take_updates(&self) -> Option<mpsc::UnboundedReceiver<SessionNotification>> {
|
pub fn take_updates(&self) -> Option<mpsc::UnboundedReceiver<AcpUpdate>> {
|
||||||
self.updates.lock().expect("updates mutex").take()
|
self.updates.lock().expect("updates mutex").take()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -772,7 +790,7 @@ fn config_notification(session_id: &str, options: Vec<SessionConfigOption>) -> S
|
|||||||
async fn run_connection<T>(
|
async fn run_connection<T>(
|
||||||
transport: T,
|
transport: T,
|
||||||
config: ConnectionConfig,
|
config: ConnectionConfig,
|
||||||
updates_tx: mpsc::UnboundedSender<SessionNotification>,
|
updates_tx: mpsc::UnboundedSender<AcpUpdate>,
|
||||||
permission_requests: mpsc::UnboundedSender<PermissionRequest>,
|
permission_requests: mpsc::UnboundedSender<PermissionRequest>,
|
||||||
mut commands_rx: mpsc::UnboundedReceiver<Command>,
|
mut commands_rx: mpsc::UnboundedReceiver<Command>,
|
||||||
ready_tx: oneshot::Sender<Result<String, ReadyError>>,
|
ready_tx: oneshot::Sender<Result<String, ReadyError>>,
|
||||||
@@ -800,7 +818,7 @@ async fn run_connection<T>(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
// A dropped receiver only means nobody wants updates any more.
|
// A dropped receiver only means nobody wants updates any more.
|
||||||
let _ = received_updates.send(notification);
|
let _ = received_updates.send(AcpUpdate::Notification(Box::new(notification)));
|
||||||
Ok(())
|
Ok(())
|
||||||
},
|
},
|
||||||
agent_client_protocol::on_receive_notification!(),
|
agent_client_protocol::on_receive_notification!(),
|
||||||
@@ -916,18 +934,18 @@ async fn run_connection<T>(
|
|||||||
models.as_ref(),
|
models.as_ref(),
|
||||||
)));
|
)));
|
||||||
if let Some(modes) = &modes {
|
if let Some(modes) = &modes {
|
||||||
let _ = updates_tx.send(SessionNotification::new(
|
let _ = updates_tx.send(AcpUpdate::Notification(Box::new(SessionNotification::new(
|
||||||
SessionId::new(session_id.as_str()),
|
SessionId::new(session_id.as_str()),
|
||||||
SessionUpdate::CurrentModeUpdate(CurrentModeUpdate::new(
|
SessionUpdate::CurrentModeUpdate(CurrentModeUpdate::new(
|
||||||
modes.current_mode_id.clone(),
|
modes.current_mode_id.clone(),
|
||||||
)),
|
)),
|
||||||
));
|
))));
|
||||||
}
|
}
|
||||||
if !config_options.lock().expect("config options").is_empty() {
|
if !config_options.lock().expect("config options").is_empty() {
|
||||||
let _ = updates_tx.send(config_notification(
|
let _ = updates_tx.send(AcpUpdate::Notification(Box::new(config_notification(
|
||||||
&session_id,
|
&session_id,
|
||||||
config_options.lock().expect("config options").clone(),
|
config_options.lock().expect("config options").clone(),
|
||||||
));
|
))));
|
||||||
}
|
}
|
||||||
let _ = ready_tx.send(Ok(session_id.clone()));
|
let _ = ready_tx.send(Ok(session_id.clone()));
|
||||||
|
|
||||||
@@ -955,16 +973,23 @@ async fn run_connection<T>(
|
|||||||
let done = Arc::new(Mutex::new(Some(done)));
|
let done = Arc::new(Mutex::new(Some(done)));
|
||||||
let callback_done = done.clone();
|
let callback_done = done.clone();
|
||||||
let callback_turn = permission_turn.clone();
|
let callback_turn = permission_turn.clone();
|
||||||
|
let callback_updates = updates_tx.clone();
|
||||||
if let Err(error) = cx.send_request(request).on_receiving_result(move |result| {
|
if let Err(error) = cx.send_request(request).on_receiving_result(move |result| {
|
||||||
let callback_done = callback_done.clone();
|
let callback_done = callback_done.clone();
|
||||||
let callback_turn = callback_turn.clone();
|
let callback_turn = callback_turn.clone();
|
||||||
|
let callback_updates = callback_updates.clone();
|
||||||
async move {
|
async move {
|
||||||
callback_turn
|
callback_turn
|
||||||
.lock()
|
.lock()
|
||||||
.expect("permission turn state")
|
.expect("permission turn state")
|
||||||
.finish(turn);
|
.finish(turn);
|
||||||
|
// Route through the same channel as notifications so the
|
||||||
|
// event pump sees this after every chunk for the turn.
|
||||||
|
let result = result.map(|_| ()).map_err(|e| e.to_string());
|
||||||
if let Some(done) = callback_done.lock().expect("prompt sender").take() {
|
if let Some(done) = callback_done.lock().expect("prompt sender").take() {
|
||||||
let _ = done.send(result.map(|_| ()).map_err(|e| e.to_string()));
|
if callback_updates.send(AcpUpdate::TurnDone { turn, result, done }).is_err() {
|
||||||
|
// Channel closed — nowhere left to signal.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -998,7 +1023,7 @@ async fn run_connection<T>(
|
|||||||
}).on_receiving_result(move |result| async move {
|
}).on_receiving_result(move |result| async move {
|
||||||
if result.is_ok() {
|
if result.is_ok() {
|
||||||
let options = replace_config_value(&callback_options, &callback_config_id, &callback_value);
|
let options = replace_config_value(&callback_options, &callback_config_id, &callback_value);
|
||||||
let _ = callback_updates.send(config_notification(&callback_session, options));
|
let _ = callback_updates.send(AcpUpdate::Notification(Box::new(config_notification(&callback_session, options))));
|
||||||
}
|
}
|
||||||
if let Some(done) = callback_done.lock().expect("config sender").take() {
|
if let Some(done) = callback_done.lock().expect("config sender").take() {
|
||||||
let _ = done.send(result.map(|_| ()).map_err(|error| error.to_string()));
|
let _ = done.send(result.map(|_| ()).map_err(|error| error.to_string()));
|
||||||
@@ -1017,13 +1042,13 @@ async fn run_connection<T>(
|
|||||||
)).on_receiving_result(move |result| async move {
|
)).on_receiving_result(move |result| async move {
|
||||||
if result.is_ok() {
|
if result.is_ok() {
|
||||||
let options = replace_config_value(&callback_options, &callback_config_id, &callback_value);
|
let options = replace_config_value(&callback_options, &callback_config_id, &callback_value);
|
||||||
let _ = callback_updates.send(config_notification(&callback_session, options));
|
let _ = callback_updates.send(AcpUpdate::Notification(Box::new(config_notification(&callback_session, options))));
|
||||||
let _ = callback_updates.send(SessionNotification::new(
|
let _ = callback_updates.send(AcpUpdate::Notification(Box::new(SessionNotification::new(
|
||||||
SessionId::new(callback_session.as_str()),
|
SessionId::new(callback_session.as_str()),
|
||||||
SessionUpdate::CurrentModeUpdate(CurrentModeUpdate::new(
|
SessionUpdate::CurrentModeUpdate(CurrentModeUpdate::new(
|
||||||
callback_value.as_value_id().expect("checked mode value").0.clone(),
|
callback_value.as_value_id().expect("checked mode value").0.clone(),
|
||||||
)),
|
)),
|
||||||
));
|
))));
|
||||||
}
|
}
|
||||||
if let Some(done) = callback_done.lock().expect("config sender").take() {
|
if let Some(done) = callback_done.lock().expect("config sender").take() {
|
||||||
let _ = done.send(result.map(|_| ()).map_err(|error| error.to_string()));
|
let _ = done.send(result.map(|_| ()).map_err(|error| error.to_string()));
|
||||||
@@ -1041,7 +1066,7 @@ async fn run_connection<T>(
|
|||||||
let mut current = callback_options.lock().expect("config options");
|
let mut current = callback_options.lock().expect("config options");
|
||||||
current.retain(|option| option.id.0.as_ref().starts_with("__maestro_legacy_"));
|
current.retain(|option| option.id.0.as_ref().starts_with("__maestro_legacy_"));
|
||||||
current.extend(response.config_options.clone());
|
current.extend(response.config_options.clone());
|
||||||
let _ = callback_updates.send(config_notification(&callback_session, current.clone()));
|
let _ = callback_updates.send(AcpUpdate::Notification(Box::new(config_notification(&callback_session, current.clone()))));
|
||||||
}
|
}
|
||||||
if let Some(done) = callback_done.lock().expect("config sender").take() {
|
if let Some(done) = callback_done.lock().expect("config sender").take() {
|
||||||
let _ = done.send(result.map(|_| ()).map_err(|error| error.to_string()));
|
let _ = done.send(result.map(|_| ()).map_err(|error| error.to_string()));
|
||||||
|
|||||||
@@ -193,6 +193,9 @@ async fn session_updates_reach_the_taken_receiver() {
|
|||||||
.await
|
.await
|
||||||
.expect("update within 5s")
|
.expect("update within 5s")
|
||||||
.expect("channel open");
|
.expect("channel open");
|
||||||
|
let maestro_acp::AcpUpdate::Notification(first) = first else {
|
||||||
|
panic!("expected Notification");
|
||||||
|
};
|
||||||
assert_eq!(first.session_id.0.as_ref(), session.session_id());
|
assert_eq!(first.session_id.0.as_ref(), session.session_id());
|
||||||
match first.update {
|
match first.update {
|
||||||
SessionUpdate::AgentMessageChunk(chunk) => match chunk.content {
|
SessionUpdate::AgentMessageChunk(chunk) => match chunk.content {
|
||||||
@@ -221,7 +224,10 @@ async fn advertised_settings_are_normalized_and_mutable() {
|
|||||||
.await
|
.await
|
||||||
.expect("settings update")
|
.expect("settings update")
|
||||||
.expect("channel open");
|
.expect("channel open");
|
||||||
if let SessionUpdate::ConfigOptionUpdate(options) = update.update {
|
let maestro_acp::AcpUpdate::Notification(n) = update else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if let SessionUpdate::ConfigOptionUpdate(options) = n.update {
|
||||||
break options.config_options;
|
break options.config_options;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -390,7 +396,10 @@ async fn drop_terminates_and_reaps_the_child() {
|
|||||||
fake.wait_for_exit().await;
|
fake.wait_for_exit().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn chunk_text(n: &maestro_acp::schema::SessionNotification) -> String {
|
fn chunk_text(update: &maestro_acp::AcpUpdate) -> String {
|
||||||
|
let maestro_acp::AcpUpdate::Notification(n) = update else {
|
||||||
|
panic!("expected Notification, got {update:?}");
|
||||||
|
};
|
||||||
match &n.update {
|
match &n.update {
|
||||||
SessionUpdate::AgentMessageChunk(chunk) => match &chunk.content {
|
SessionUpdate::AgentMessageChunk(chunk) => match &chunk.content {
|
||||||
ContentBlock::Text(t) => t.text.clone(),
|
ContentBlock::Text(t) => t.text.clone(),
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ pub struct KillArgs {
|
|||||||
/// Session ID to kill.
|
/// Session ID to kill.
|
||||||
pub session_id: Uuid,
|
pub session_id: Uuid,
|
||||||
/// End the session even when its worktree holds unsaved work (#81). The
|
/// End the session even when its worktree holds unsaved work (#81). The
|
||||||
/// worktree is left on disk — never removed.
|
/// worktree is removed along with the session.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub force: bool,
|
pub force: bool,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -345,6 +345,8 @@ mod tests {
|
|||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
acp_session_id: None,
|
acp_session_id: None,
|
||||||
|
auto_approve: false,
|
||||||
|
queued_prompts: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -482,14 +482,13 @@ async fn structured_prompt(
|
|||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
Json(body): Json<StructuredPromptRequest>,
|
Json(body): Json<StructuredPromptRequest>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
match s.manager.queue_acp_prompt(id, body.prompt).await {
|
match s.manager.queue_acp_prompt(id, body.prompt, None).await {
|
||||||
Ok(QueueAcpPrompt::Queued) | Ok(QueueAcpPrompt::Pending) => {
|
Ok(QueueAcpPrompt::Dispatched) => StatusCode::NO_CONTENT.into_response(),
|
||||||
StatusCode::NO_CONTENT.into_response()
|
Ok(QueueAcpPrompt::Queued) => StatusCode::ACCEPTED.into_response(),
|
||||||
}
|
Ok(QueueAcpPrompt::Full) => error_response(
|
||||||
Ok(QueueAcpPrompt::InFlight) => error_response(
|
StatusCode::TOO_MANY_REQUESTS,
|
||||||
StatusCode::CONFLICT,
|
"queue_full",
|
||||||
"turn_in_flight",
|
"the prompt queue has reached its capacity",
|
||||||
"the structured session already has an active turn",
|
|
||||||
),
|
),
|
||||||
Ok(QueueAcpPrompt::Unavailable) => error_response(
|
Ok(QueueAcpPrompt::Unavailable) => error_response(
|
||||||
StatusCode::CONFLICT,
|
StatusCode::CONFLICT,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
//! single point through which the API handlers and the per-session WS
|
//! single point through which the API handlers and the per-session WS
|
||||||
//! handler interact with tmux.
|
//! handler interact with tmux.
|
||||||
|
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet, VecDeque};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||||
use std::sync::{Arc, Weak};
|
use std::sync::{Arc, Weak};
|
||||||
@@ -15,8 +15,8 @@ use chrono::Utc;
|
|||||||
use maestro_acp::{AcpAdapter, AcpSession, PermissionRequest, SpawnConfig};
|
use maestro_acp::{AcpAdapter, AcpSession, PermissionRequest, SpawnConfig};
|
||||||
use maestro_protocol::display::command_tool;
|
use maestro_protocol::display::command_tool;
|
||||||
use maestro_protocol::{
|
use maestro_protocol::{
|
||||||
ActivityState, CatalogEvent, CreateSessionRequest, ErrorInfo, NameSource, Session,
|
ActivityState, CatalogEvent, CreateSessionRequest, ErrorInfo, NameSource, ReducedState,
|
||||||
SessionStatus, SessionView, StructuredReplayEvent, TranscriptDelta, TranscriptRow,
|
Session, SessionStatus, SessionView, StructuredReplayEvent, TranscriptDelta, TranscriptRow,
|
||||||
TranscriptRowKind, WorktreeSpec, NAME_KEY, NAME_SOURCE_KEY,
|
TranscriptRowKind, WorktreeSpec, NAME_KEY, NAME_SOURCE_KEY,
|
||||||
};
|
};
|
||||||
use tokio::sync::{broadcast, oneshot, watch, Mutex, Notify};
|
use tokio::sync::{broadcast, oneshot, watch, Mutex, Notify};
|
||||||
@@ -122,9 +122,6 @@ pub struct SessionRecord {
|
|||||||
/// When `true`, the permission pump auto-responds `AllowAlways` to every
|
/// When `true`, the permission pump auto-responds `AllowAlways` to every
|
||||||
/// ACP permission request without surfacing it to clients.
|
/// ACP permission request without surfacing it to clients.
|
||||||
pub auto_approve: bool,
|
pub auto_approve: bool,
|
||||||
/// A prompt submitted while the adapter was still booting (`Starting`).
|
|
||||||
/// The background handshake task drains this on success.
|
|
||||||
pub pending_prompt: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SessionRecord {
|
impl SessionRecord {
|
||||||
@@ -149,6 +146,9 @@ struct StructuredActivityWatermark {
|
|||||||
sequence: u64,
|
sequence: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Maximum number of prompts that can be queued while a turn is in flight.
|
||||||
|
const PROMPT_QUEUE_CAP: usize = 20;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct AcpEventStream {
|
struct AcpEventStream {
|
||||||
log: EventLog,
|
log: EventLog,
|
||||||
@@ -160,6 +160,10 @@ struct AcpEventStream {
|
|||||||
/// claimed: it is queued onto ACP before another operation can proceed.
|
/// claimed: it is queued onto ACP before another operation can proceed.
|
||||||
operation: Arc<Mutex<()>>,
|
operation: Arc<Mutex<()>>,
|
||||||
dispatch: AcpDispatchState,
|
dispatch: AcpDispatchState,
|
||||||
|
/// Server-side FIFO of prompts accepted while a turn was in flight or the
|
||||||
|
/// adapter was still booting. Drained as a single concatenated prompt when
|
||||||
|
/// the current turn completes. Persisted to the session sidecar.
|
||||||
|
prompt_queue: VecDeque<String>,
|
||||||
activity_sequence: u64,
|
activity_sequence: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,6 +172,23 @@ impl AcpEventStream {
|
|||||||
self.activity_sequence += 1;
|
self.activity_sequence += 1;
|
||||||
(self.activity_sequence, self.projection.activity())
|
(self.activity_sequence, self.projection.activity())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The reduced state with the prompt queue overlaid. Every broadcast and
|
||||||
|
/// snapshot should use this instead of `projection.state()` directly so
|
||||||
|
/// clients always see the current queue.
|
||||||
|
fn state_with_queue(&self) -> ReducedState {
|
||||||
|
let mut state = self.projection.state();
|
||||||
|
state.queued_prompts = self.prompt_queue.iter().cloned().collect();
|
||||||
|
state
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Broadcast a projection update with the prompt queue overlaid into the
|
||||||
|
/// state. Every `projected_live.send` should go through this so clients
|
||||||
|
/// never see a stale queue.
|
||||||
|
fn broadcast(&self, mut update: ProjectionUpdate) {
|
||||||
|
update.state.queued_prompts = self.prompt_queue.iter().cloned().collect();
|
||||||
|
let _ = self.projected_live.send(update);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
@@ -235,11 +256,14 @@ pub enum BeginAcpTurn {
|
|||||||
/// Result of serializing a projected turn claim with its ACP prompt enqueue.
|
/// Result of serializing a projected turn claim with its ACP prompt enqueue.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum QueueAcpPrompt {
|
pub enum QueueAcpPrompt {
|
||||||
|
/// The prompt was dispatched to ACP immediately (no turn was in flight).
|
||||||
|
Dispatched,
|
||||||
|
/// The prompt was accepted into the server-side queue and will be
|
||||||
|
/// dispatched when the current turn completes.
|
||||||
Queued,
|
Queued,
|
||||||
/// The adapter is still booting; the prompt is held until it is ready.
|
|
||||||
Pending,
|
|
||||||
InFlight,
|
|
||||||
Unavailable,
|
Unavailable,
|
||||||
|
/// The prompt queue has reached its capacity.
|
||||||
|
Full,
|
||||||
Failed(String),
|
Failed(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -662,7 +686,7 @@ impl SessionManager {
|
|||||||
let live = stream.projected_live.subscribe();
|
let live = stream.projected_live.subscribe();
|
||||||
Ok(Some((
|
Ok(Some((
|
||||||
stream.projection.snapshot(stream.log.last_seq()),
|
stream.projection.snapshot(stream.log.last_seq()),
|
||||||
stream.projection.state(),
|
stream.state_with_queue(),
|
||||||
live,
|
live,
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
@@ -692,7 +716,7 @@ impl SessionManager {
|
|||||||
return Ok(false);
|
return Ok(false);
|
||||||
};
|
};
|
||||||
let projected = persist_turn_update(&mut stream, projected);
|
let projected = persist_turn_update(&mut stream, projected);
|
||||||
let _ = stream.projected_live.send(projected);
|
stream.broadcast(projected);
|
||||||
drop(stream);
|
drop(stream);
|
||||||
sync_structured_activity(&self.inner, id).await;
|
sync_structured_activity(&self.inner, id).await;
|
||||||
Ok(true)
|
Ok(true)
|
||||||
@@ -717,7 +741,7 @@ impl SessionManager {
|
|||||||
return Ok(BeginAcpTurn::InFlight);
|
return Ok(BeginAcpTurn::InFlight);
|
||||||
}
|
}
|
||||||
let (turn, update) = stream.projection.begin_turn();
|
let (turn, update) = stream.projection.begin_turn();
|
||||||
let _ = stream.projected_live.send(update);
|
stream.broadcast(update);
|
||||||
drop(stream);
|
drop(stream);
|
||||||
sync_structured_activity(&self.inner, id).await;
|
sync_structured_activity(&self.inner, id).await;
|
||||||
Ok(BeginAcpTurn::Started(acp, turn))
|
Ok(BeginAcpTurn::Started(acp, turn))
|
||||||
@@ -733,15 +757,31 @@ impl SessionManager {
|
|||||||
&self,
|
&self,
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
text: String,
|
text: String,
|
||||||
|
row_metadata: Option<serde_json::Value>,
|
||||||
) -> Result<QueueAcpPrompt, SessionError> {
|
) -> Result<QueueAcpPrompt, SessionError> {
|
||||||
let (acp, stream) = {
|
let (acp, stream) = {
|
||||||
let mut sessions = self.inner.sessions.lock().await;
|
let sessions = self.inner.sessions.lock().await;
|
||||||
let record = sessions.get_mut(&id).ok_or(SessionError::NotFound(id))?;
|
let record = sessions.get(&id).ok_or(SessionError::NotFound(id))?;
|
||||||
// While the adapter is booting, stash the prompt for the
|
|
||||||
// background handshake task to drain on success.
|
|
||||||
if record.is_starting_structured() {
|
if record.is_starting_structured() {
|
||||||
record.pending_prompt = Some(text);
|
// Adapter still booting — queue the prompt for drain on boot
|
||||||
return Ok(QueueAcpPrompt::Pending);
|
// success. The AcpEventStream exists, just no ACP handle yet.
|
||||||
|
let stream = record
|
||||||
|
.acp_events
|
||||||
|
.clone()
|
||||||
|
.expect("structured session has events at Starting");
|
||||||
|
let mut stream = stream.lock().await;
|
||||||
|
if stream.prompt_queue.len() >= PROMPT_QUEUE_CAP {
|
||||||
|
return Ok(QueueAcpPrompt::Full);
|
||||||
|
}
|
||||||
|
stream.prompt_queue.push_back(text);
|
||||||
|
let update = ProjectionUpdate {
|
||||||
|
transcript: None,
|
||||||
|
state: stream.projection.state(),
|
||||||
|
};
|
||||||
|
stream.broadcast(update);
|
||||||
|
drop(stream);
|
||||||
|
self.persist_prompt_queue(id).await;
|
||||||
|
return Ok(QueueAcpPrompt::Queued);
|
||||||
}
|
}
|
||||||
if !record.is_live_structured() {
|
if !record.is_live_structured() {
|
||||||
return Ok(QueueAcpPrompt::Unavailable);
|
return Ok(QueueAcpPrompt::Unavailable);
|
||||||
@@ -762,19 +802,51 @@ impl SessionManager {
|
|||||||
}
|
}
|
||||||
if let Some(turn) = stream.dispatch.queued_turn {
|
if let Some(turn) = stream.dispatch.queued_turn {
|
||||||
if stream.dispatch.cancelled_turn != Some(turn) {
|
if stream.dispatch.cancelled_turn != Some(turn) {
|
||||||
return Ok(QueueAcpPrompt::InFlight);
|
// Turn is in flight — queue instead of rejecting.
|
||||||
|
if stream.prompt_queue.len() >= PROMPT_QUEUE_CAP {
|
||||||
|
return Ok(QueueAcpPrompt::Full);
|
||||||
|
}
|
||||||
|
stream.prompt_queue.push_back(text);
|
||||||
|
let update = ProjectionUpdate {
|
||||||
|
transcript: None,
|
||||||
|
state: stream.projection.state(),
|
||||||
|
};
|
||||||
|
stream.broadcast(update);
|
||||||
|
drop(stream);
|
||||||
|
self.persist_prompt_queue(id).await;
|
||||||
|
return Ok(QueueAcpPrompt::Queued);
|
||||||
}
|
}
|
||||||
Some((turn, stream.dispatch.settled.clone()))
|
Some((turn, stream.dispatch.settled.clone()))
|
||||||
} else {
|
} else {
|
||||||
if stream.projection.turn_active() {
|
if stream.projection.turn_active() {
|
||||||
return Ok(QueueAcpPrompt::InFlight);
|
// Projection says active but dispatch says idle —
|
||||||
|
// queue rather than rejecting.
|
||||||
|
if stream.prompt_queue.len() >= PROMPT_QUEUE_CAP {
|
||||||
|
return Ok(QueueAcpPrompt::Full);
|
||||||
|
}
|
||||||
|
stream.prompt_queue.push_back(text);
|
||||||
|
let update = ProjectionUpdate {
|
||||||
|
transcript: None,
|
||||||
|
state: stream.projection.state(),
|
||||||
|
};
|
||||||
|
stream.broadcast(update);
|
||||||
|
drop(stream);
|
||||||
|
self.persist_prompt_queue(id).await;
|
||||||
|
return Ok(QueueAcpPrompt::Queued);
|
||||||
|
}
|
||||||
|
let mut data = serde_json::json!({
|
||||||
|
"content": {"type": "text", "text": text},
|
||||||
|
});
|
||||||
|
if let Some(extra) = &row_metadata {
|
||||||
|
if let (Some(base), Some(extra)) = (data.as_object_mut(), extra.as_object())
|
||||||
|
{
|
||||||
|
base.extend(extra.iter().map(|(k, v)| (k.clone(), v.clone())));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let row = TranscriptRow {
|
let row = TranscriptRow {
|
||||||
id: format!("user:{}", Uuid::new_v4()),
|
id: format!("user:{}", Uuid::new_v4()),
|
||||||
kind: TranscriptRowKind::User,
|
kind: TranscriptRowKind::User,
|
||||||
data: serde_json::json!({
|
data,
|
||||||
"content": {"type": "text", "text": text},
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
let event = PersistedEvent::User { row: row.clone() };
|
let event = PersistedEvent::User { row: row.clone() };
|
||||||
let seq = match stream.log.append(&event) {
|
let seq = match stream.log.append(&event) {
|
||||||
@@ -789,9 +861,9 @@ impl SessionManager {
|
|||||||
let logged = LoggedEvent { seq, event };
|
let logged = LoggedEvent { seq, event };
|
||||||
let projected = stream.projection.restore_row(row);
|
let projected = stream.projection.restore_row(row);
|
||||||
let _ = stream.live.send(logged);
|
let _ = stream.live.send(logged);
|
||||||
let _ = stream.projected_live.send(projected);
|
stream.broadcast(projected);
|
||||||
let (turn, update) = stream.projection.begin_turn();
|
let (turn, update) = stream.projection.begin_turn();
|
||||||
let _ = stream.projected_live.send(update);
|
stream.broadcast(update);
|
||||||
match acp.prompt(text.clone(), turn) {
|
match acp.prompt(text.clone(), turn) {
|
||||||
Ok(done) => {
|
Ok(done) => {
|
||||||
stream.dispatch.queued_turn = Some(turn);
|
stream.dispatch.queued_turn = Some(turn);
|
||||||
@@ -809,7 +881,7 @@ impl SessionManager {
|
|||||||
if let Ok(true) = self.claim_autoname(id).await {
|
if let Ok(true) = self.claim_autoname(id).await {
|
||||||
crate::autoname::spawn_naming(self.clone(), id, text);
|
crate::autoname::spawn_naming(self.clone(), id, text);
|
||||||
}
|
}
|
||||||
return Ok(QueueAcpPrompt::Queued);
|
return Ok(QueueAcpPrompt::Dispatched);
|
||||||
}
|
}
|
||||||
Err(message) => {
|
Err(message) => {
|
||||||
cancel_pending_approvals(&mut stream);
|
cancel_pending_approvals(&mut stream);
|
||||||
@@ -820,7 +892,7 @@ impl SessionManager {
|
|||||||
"newly claimed turn remains current while stream is locked",
|
"newly claimed turn remains current while stream is locked",
|
||||||
);
|
);
|
||||||
let projected = persist_turn_update(&mut stream, projected);
|
let projected = persist_turn_update(&mut stream, projected);
|
||||||
let _ = stream.projected_live.send(projected);
|
stream.broadcast(projected);
|
||||||
drop(stream);
|
drop(stream);
|
||||||
sync_structured_activity(&self.inner, id).await;
|
sync_structured_activity(&self.inner, id).await;
|
||||||
return Ok(QueueAcpPrompt::Failed(message));
|
return Ok(QueueAcpPrompt::Failed(message));
|
||||||
@@ -840,6 +912,27 @@ impl SessionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Persist the current prompt queue to the session sidecar.
|
||||||
|
async fn persist_prompt_queue(&self, id: Uuid) {
|
||||||
|
let stream_arc = {
|
||||||
|
let sessions = self.inner.sessions.lock().await;
|
||||||
|
sessions.get(&id).and_then(|r| r.acp_events.clone())
|
||||||
|
};
|
||||||
|
let Some(stream_arc) = stream_arc else { return };
|
||||||
|
let queue: Vec<String> = stream_arc
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.prompt_queue
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
let mut sessions = self.inner.sessions.lock().await;
|
||||||
|
if let Some(record) = sessions.get_mut(&id) {
|
||||||
|
record.session.queued_prompts = queue;
|
||||||
|
let _ = self.inner.sidecar.write(&record.session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn finish_queued_acp_turn(&self, id: Uuid, turn: u64, lifecycle: TurnLifecycle) {
|
async fn finish_queued_acp_turn(&self, id: Uuid, turn: u64, lifecycle: TurnLifecycle) {
|
||||||
let stream = self
|
let stream = self
|
||||||
.inner
|
.inner
|
||||||
@@ -860,16 +953,33 @@ impl SessionManager {
|
|||||||
stream.dispatch.cancelled_turn = None;
|
stream.dispatch.cancelled_turn = None;
|
||||||
}
|
}
|
||||||
stream.dispatch.settled.notify_waiters();
|
stream.dispatch.settled.notify_waiters();
|
||||||
if stream.projection.active_turn() != Some(turn) {
|
if stream.projection.active_turn() == Some(turn) {
|
||||||
return;
|
cancel_pending_approvals(&mut stream);
|
||||||
}
|
if let Some(projected) = stream.projection.turn_lifecycle(lifecycle, turn) {
|
||||||
cancel_pending_approvals(&mut stream);
|
let projected = persist_turn_update(&mut stream, projected);
|
||||||
if let Some(projected) = stream.projection.turn_lifecycle(lifecycle, turn) {
|
stream.broadcast(projected);
|
||||||
let projected = persist_turn_update(&mut stream, projected);
|
}
|
||||||
let _ = stream.projected_live.send(projected);
|
|
||||||
}
|
}
|
||||||
|
// Drain the prompt queue: concatenate all queued prompts into a single
|
||||||
|
// prompt and dispatch as the next turn. This must run even when the
|
||||||
|
// projection already ended the turn (e.g. cancel settled via the event
|
||||||
|
// stream before this function ran).
|
||||||
|
let queued_text = if stream.prompt_queue.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let texts: Vec<String> = stream.prompt_queue.drain(..).collect();
|
||||||
|
Some(texts.join("\n\n"))
|
||||||
|
};
|
||||||
drop(stream);
|
drop(stream);
|
||||||
sync_structured_activity(&self.inner, id).await;
|
sync_structured_activity(&self.inner, id).await;
|
||||||
|
if let Some(text) = queued_text {
|
||||||
|
self.persist_prompt_queue(id).await;
|
||||||
|
// Cannot call queue_acp_prompt here (async cycle via the spawn
|
||||||
|
// inside queue_acp_prompt → finish_queued_acp_turn → here).
|
||||||
|
// Spawn an independent task instead.
|
||||||
|
let manager = self.clone();
|
||||||
|
drain_prompt_queue(manager, id, text);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fail_cancelled_acp_turn(
|
async fn fail_cancelled_acp_turn(
|
||||||
@@ -983,7 +1093,7 @@ impl SessionManager {
|
|||||||
.turn_lifecycle(TurnLifecycle::Cancel, turn)
|
.turn_lifecycle(TurnLifecycle::Cancel, turn)
|
||||||
.expect("active turn remains current while stream is locked");
|
.expect("active turn remains current while stream is locked");
|
||||||
let projected = persist_turn_update(&mut stream, projected);
|
let projected = persist_turn_update(&mut stream, projected);
|
||||||
let _ = stream.projected_live.send(projected);
|
stream.broadcast(projected);
|
||||||
drop(stream);
|
drop(stream);
|
||||||
sync_structured_activity(&self.inner, id).await;
|
sync_structured_activity(&self.inner, id).await;
|
||||||
if let Some(turn) = cancelled_queued_turn {
|
if let Some(turn) = cancelled_queued_turn {
|
||||||
@@ -1032,7 +1142,7 @@ impl SessionManager {
|
|||||||
let outcome = permission_outcome(decision, &pending.options);
|
let outcome = permission_outcome(decision, &pending.options);
|
||||||
let _ = pending.response.send(outcome);
|
let _ = pending.response.send(outcome);
|
||||||
if let Some(update) = stream.projection.resolve_approval(nonce) {
|
if let Some(update) = stream.projection.resolve_approval(nonce) {
|
||||||
let _ = stream.projected_live.send(update);
|
stream.broadcast(update);
|
||||||
}
|
}
|
||||||
drop(stream);
|
drop(stream);
|
||||||
sync_structured_activity(&self.inner, id).await;
|
sync_structured_activity(&self.inner, id).await;
|
||||||
@@ -1340,6 +1450,7 @@ impl SessionManager {
|
|||||||
pending_approvals: HashMap::new(),
|
pending_approvals: HashMap::new(),
|
||||||
operation: Arc::new(Mutex::new(())),
|
operation: Arc::new(Mutex::new(())),
|
||||||
dispatch: AcpDispatchState::default(),
|
dispatch: AcpDispatchState::default(),
|
||||||
|
prompt_queue: VecDeque::new(),
|
||||||
activity_sequence: 0,
|
activity_sequence: 0,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -1404,6 +1515,8 @@ impl SessionManager {
|
|||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
acp_session_id: None,
|
acp_session_id: None,
|
||||||
view: req.view,
|
view: req.view,
|
||||||
|
auto_approve: req.auto_approve,
|
||||||
|
queued_prompts: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = self.inner.sidecar.write(&session) {
|
if let Err(e) = self.inner.sidecar.write(&session) {
|
||||||
@@ -1435,7 +1548,6 @@ impl SessionManager {
|
|||||||
structured_activity_watermark: None,
|
structured_activity_watermark: None,
|
||||||
acp_event_pump: None,
|
acp_event_pump: None,
|
||||||
auto_approve: req.auto_approve,
|
auto_approve: req.auto_approve,
|
||||||
pending_prompt: None,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1489,8 +1601,8 @@ impl SessionManager {
|
|||||||
let acp = Arc::new(acp_session);
|
let acp = Arc::new(acp_session);
|
||||||
let done = start_acp_event_pump(&inner, id, &acp, events.clone());
|
let done = start_acp_event_pump(&inner, id, &acp, events.clone());
|
||||||
start_acp_permission_pump(&inner, id, &acp, events.clone(), auto_approve);
|
start_acp_permission_pump(&inner, id, &acp, events.clone(), auto_approve);
|
||||||
// Drain any prompt submitted while Starting.
|
// Drain any prompts queued while the adapter was Starting.
|
||||||
let pending_prompt = {
|
let queued_text = {
|
||||||
let mut sessions = inner.sessions.lock().await;
|
let mut sessions = inner.sessions.lock().await;
|
||||||
let Some(record) = sessions.get_mut(&id) else {
|
let Some(record) = sessions.get_mut(&id) else {
|
||||||
// Session was killed while we were booting.
|
// Session was killed while we were booting.
|
||||||
@@ -1504,17 +1616,30 @@ impl SessionManager {
|
|||||||
record.session.acp_session_id = Some(acp.session_id().to_string());
|
record.session.acp_session_id = Some(acp.session_id().to_string());
|
||||||
record.session.activity = ActivityState::Idle;
|
record.session.activity = ActivityState::Idle;
|
||||||
record.session.activity_since = Utc::now();
|
record.session.activity_since = Utc::now();
|
||||||
|
let queued = if let Some(ref stream) = record.acp_events {
|
||||||
|
let mut stream = stream.lock().await;
|
||||||
|
if stream.prompt_queue.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let texts: Vec<String> =
|
||||||
|
stream.prompt_queue.drain(..).collect();
|
||||||
|
Some(texts.join("\n\n"))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
record.session.queued_prompts.clear();
|
||||||
let _ = inner.sidecar.write(&record.session);
|
let _ = inner.sidecar.write(&record.session);
|
||||||
let _ = inner.events.send(CatalogEvent::SessionUpdated {
|
let _ = inner.events.send(CatalogEvent::SessionUpdated {
|
||||||
session: record.session.clone(),
|
session: record.session.clone(),
|
||||||
});
|
});
|
||||||
record.pending_prompt.take()
|
queued
|
||||||
};
|
};
|
||||||
if let Some(prompt) = pending_prompt {
|
if let Some(text) = queued_text {
|
||||||
let manager = SessionManager {
|
let manager = SessionManager {
|
||||||
inner: inner.clone(),
|
inner: inner.clone(),
|
||||||
};
|
};
|
||||||
let _ = manager.queue_acp_prompt(id, prompt).await;
|
let _ = manager.queue_acp_prompt(id, text, None).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -1545,15 +1670,10 @@ impl SessionManager {
|
|||||||
/// uncommitted tracked changes, untracked non-ignored files, or commits
|
/// uncommitted tracked changes, untracked non-ignored files, or commits
|
||||||
/// reachable from no remote-tracking ref. Nothing is killed and nothing
|
/// reachable from no remote-tracking ref. Nothing is killed and nothing
|
||||||
/// is removed; the error names what blocked it and how much of it there
|
/// is removed; the error names what blocked it and how much of it there
|
||||||
/// is. The session stays as the operator's handle on their own unfinished
|
/// is.
|
||||||
/// work, so ending a session that still matters becomes a deliberate
|
|
||||||
/// `--force`, which leaves the tree alone rather than destroying it
|
|
||||||
/// (WORKTREES.md).
|
|
||||||
///
|
///
|
||||||
/// With `force` (#81) the refusal — and nothing else — is overridden:
|
/// With `force` (#81) the refusal is overridden: the session ends and
|
||||||
/// the session ends and the guarded worktree is left on disk, never
|
/// the worktree is removed, even when it holds unsaved work.
|
||||||
/// removed. A tree whose guard is clear never needed the override; force
|
|
||||||
/// removes it as usual.
|
|
||||||
///
|
///
|
||||||
/// This is also how an already-dead session is dismissed. tmux is only
|
/// This is also how an already-dead session is dismissed. tmux is only
|
||||||
/// asked to kill something it still has — calling `kill-session` on a
|
/// asked to kill something it still has — calling `kill-session` on a
|
||||||
@@ -1588,7 +1708,7 @@ impl SessionManager {
|
|||||||
sessions.remove(&id)
|
sessions.remove(&id)
|
||||||
};
|
};
|
||||||
let record = removed.ok_or(SessionError::NotFound(id))?;
|
let record = removed.ok_or(SessionError::NotFound(id))?;
|
||||||
cleanup_record(&self.inner, id, record, alive, guarded).await;
|
cleanup_record(&self.inner, id, record, alive).await;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1683,6 +1803,8 @@ impl SessionManager {
|
|||||||
// Unknown on adoption without a sidecar; matches every
|
// Unknown on adoption without a sidecar; matches every
|
||||||
// other field's forward-compat default.
|
// other field's forward-compat default.
|
||||||
view: maestro_protocol::SessionView::Terminal,
|
view: maestro_protocol::SessionView::Terminal,
|
||||||
|
auto_approve: false,
|
||||||
|
queued_prompts: Vec::new(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
let fifo_path = self.inner.fifo_dir.join(format!("{id}.fifo"));
|
let fifo_path = self.inner.fifo_dir.join(format!("{id}.fifo"));
|
||||||
@@ -1720,8 +1842,7 @@ impl SessionManager {
|
|||||||
acp_events: None,
|
acp_events: None,
|
||||||
structured_activity_watermark: None,
|
structured_activity_watermark: None,
|
||||||
acp_event_pump: None,
|
acp_event_pump: None,
|
||||||
auto_approve: false,
|
auto_approve: session.auto_approve,
|
||||||
pending_prompt: None,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let _ = self
|
let _ = self
|
||||||
@@ -1849,6 +1970,7 @@ impl SessionManager {
|
|||||||
pending_approvals: HashMap::new(),
|
pending_approvals: HashMap::new(),
|
||||||
operation: Arc::new(Mutex::new(())),
|
operation: Arc::new(Mutex::new(())),
|
||||||
dispatch: AcpDispatchState::default(),
|
dispatch: AcpDispatchState::default(),
|
||||||
|
prompt_queue: session.queued_prompts.drain(..).collect(),
|
||||||
activity_sequence: 0,
|
activity_sequence: 0,
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
@@ -1896,21 +2018,45 @@ impl SessionManager {
|
|||||||
focused_since: None,
|
focused_since: None,
|
||||||
awaiting_suppressed: false,
|
awaiting_suppressed: false,
|
||||||
acp,
|
acp,
|
||||||
acp_events,
|
acp_events: acp_events.clone(),
|
||||||
structured_activity_watermark: None,
|
structured_activity_watermark: None,
|
||||||
acp_event_pump: None,
|
acp_event_pump: None,
|
||||||
// Recovery cannot know the original preset's flag; the
|
auto_approve: session.auto_approve,
|
||||||
// operator re-enables it by restarting with the preset.
|
|
||||||
auto_approve: false,
|
|
||||||
pending_prompt: None,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if let Some((acp, events)) = event_pump {
|
if let Some((acp, events)) = event_pump {
|
||||||
let done = start_acp_event_pump(&self.inner, id, &acp, events.clone());
|
let done = start_acp_event_pump(&self.inner, id, &acp, events.clone());
|
||||||
start_acp_permission_pump(&self.inner, id, &acp, events, false);
|
start_acp_permission_pump(&self.inner, id, &acp, events, session.auto_approve);
|
||||||
if let Some(record) = self.inner.sessions.lock().await.get_mut(&id) {
|
if let Some(record) = self.inner.sessions.lock().await.get_mut(&id) {
|
||||||
record.acp_event_pump = Some(done);
|
record.acp_event_pump = Some(done);
|
||||||
}
|
}
|
||||||
|
// Drain any prompts that were queued before the daemon
|
||||||
|
// restarted. If none were queued, send a synthetic continue
|
||||||
|
// prompt so the agent resumes autonomously.
|
||||||
|
let queued_text = {
|
||||||
|
let stream = acp_events
|
||||||
|
.as_ref()
|
||||||
|
.expect("recovery with acp implies acp_events");
|
||||||
|
let mut stream = stream.lock().await;
|
||||||
|
if stream.prompt_queue.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let texts: Vec<String> = stream.prompt_queue.drain(..).collect();
|
||||||
|
Some(texts.join("\n\n"))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(text) = queued_text {
|
||||||
|
self.persist_prompt_queue(id).await;
|
||||||
|
let _ = self.queue_acp_prompt(id, text, None).await;
|
||||||
|
} else {
|
||||||
|
let _ = self
|
||||||
|
.queue_acp_prompt(
|
||||||
|
id,
|
||||||
|
"The daemon restarted. Continue where you left off.".into(),
|
||||||
|
Some(serde_json::json!({"maestro": {"type": "restart_continue"}})),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let _ = self
|
let _ = self
|
||||||
.inner
|
.inner
|
||||||
@@ -2257,34 +2403,49 @@ fn start_acp_event_pump(
|
|||||||
let weak_inner = Arc::downgrade(inner);
|
let weak_inner = Arc::downgrade(inner);
|
||||||
let weak_acp = Arc::downgrade(acp);
|
let weak_acp = Arc::downgrade(acp);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while let Some(notification) = updates.recv().await {
|
while let Some(update) = updates.recv().await {
|
||||||
let append_error = {
|
match update {
|
||||||
let mut events = events.lock().await;
|
maestro_acp::AcpUpdate::Notification(notification) => {
|
||||||
let event = PersistedEvent::Notification(Box::new(notification));
|
let append_error = {
|
||||||
match events.log.append(&event) {
|
let mut events = events.lock().await;
|
||||||
Ok(seq) => {
|
let event = PersistedEvent::Notification(notification);
|
||||||
let logged = LoggedEvent {
|
match events.log.append(&event) {
|
||||||
seq,
|
Ok(seq) => {
|
||||||
event: event.clone(),
|
let logged = LoggedEvent {
|
||||||
};
|
seq,
|
||||||
let projected = apply_persisted_event(&mut events.projection, &logged);
|
event: event.clone(),
|
||||||
let _ = events.live.send(logged);
|
};
|
||||||
let _ = events.projected_live.send(projected);
|
let projected =
|
||||||
None
|
apply_persisted_event(&mut events.projection, &logged);
|
||||||
|
let _ = events.live.send(logged);
|
||||||
|
events.broadcast(projected);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Err(error) => Some(error),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(error) = append_error {
|
||||||
|
tracing::error!(%id, %error, "structured event log append failed");
|
||||||
|
if let Some(acp) = weak_acp.upgrade() {
|
||||||
|
acp.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
if let Some(inner) = weak_inner.upgrade() {
|
||||||
|
mark_session_dead(&inner, id).await;
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
Err(error) => Some(error),
|
|
||||||
}
|
}
|
||||||
};
|
maestro_acp::AcpUpdate::TurnDone {
|
||||||
if let Some(error) = append_error {
|
turn: _,
|
||||||
tracing::error!(%id, %error, "structured event log append failed");
|
result,
|
||||||
if let Some(acp) = weak_acp.upgrade() {
|
done,
|
||||||
acp.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
|
} => {
|
||||||
.await;
|
// 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);
|
||||||
}
|
}
|
||||||
if let Some(inner) = weak_inner.upgrade() {
|
|
||||||
mark_session_dead(&inner, id).await;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let _ = done_tx.send(true);
|
let _ = done_tx.send(true);
|
||||||
@@ -2439,7 +2600,7 @@ fn start_acp_permission_pump(
|
|||||||
let outcome = record_permission_request(&mut events, request, response);
|
let outcome = record_permission_request(&mut events, request, response);
|
||||||
let fatal = matches!(outcome, RecordOutcome::Fatal);
|
let fatal = matches!(outcome, RecordOutcome::Fatal);
|
||||||
if let RecordOutcome::Live(update) = outcome {
|
if let RecordOutcome::Live(update) = outcome {
|
||||||
let _ = events.projected_live.send(*update);
|
events.broadcast(*update);
|
||||||
}
|
}
|
||||||
drop(events);
|
drop(events);
|
||||||
if fatal {
|
if fatal {
|
||||||
@@ -2532,7 +2693,7 @@ fn cancel_pending_approvals(events: &mut AcpEventStream) {
|
|||||||
.send(maestro_acp::schema::RequestPermissionOutcome::Cancelled);
|
.send(maestro_acp::schema::RequestPermissionOutcome::Cancelled);
|
||||||
}
|
}
|
||||||
if let Some(update) = events.projection.clear_approval() {
|
if let Some(update) = events.projection.clear_approval() {
|
||||||
let _ = events.projected_live.send(update);
|
events.broadcast(update);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2882,6 +3043,18 @@ async fn run_blocked_work_pass(inner: &Arc<Inner>) {
|
|||||||
/// Terminal sessions that die naturally do not survive a daemon restart:
|
/// Terminal sessions that die naturally do not survive a daemon restart:
|
||||||
/// [`SessionManager::recover`] adopts only what tmux still holds. Structured
|
/// [`SessionManager::recover`] adopts only what tmux still holds. Structured
|
||||||
/// sessions stopped for daemon shutdown retain their sidecars for ACP recovery.
|
/// sessions stopped for daemon shutdown retain their sidecars for ACP recovery.
|
||||||
|
/// Spawn an independent task to dispatch the next prompt from the drained
|
||||||
|
/// queue. Free function with an explicit `Box::pin` future to break the
|
||||||
|
/// compiler's async-cycle check: `queue_acp_prompt` → spawn →
|
||||||
|
/// `finish_queued_acp_turn` → here → `queue_acp_prompt`.
|
||||||
|
fn drain_prompt_queue(manager: SessionManager, id: Uuid, text: String) {
|
||||||
|
let fut: std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> =
|
||||||
|
Box::pin(async move {
|
||||||
|
let _ = manager.queue_acp_prompt(id, text, None).await;
|
||||||
|
});
|
||||||
|
tokio::spawn(fut);
|
||||||
|
}
|
||||||
|
|
||||||
async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
|
async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
|
||||||
if inner.shutting_down.load(Ordering::Acquire) {
|
if inner.shutting_down.load(Ordering::Acquire) {
|
||||||
return;
|
return;
|
||||||
@@ -2916,17 +3089,16 @@ async fn mark_session_dead_with_worktree_settlement(
|
|||||||
// Release anyone attached; the scrollback survives for a later
|
// Release anyone attached; the scrollback survives for a later
|
||||||
// attach.
|
// attach.
|
||||||
record.bridge.seal();
|
record.bridge.seal();
|
||||||
let session = record.session.clone();
|
|
||||||
let worktree = record
|
let worktree = record
|
||||||
.worktree_base_repo
|
.worktree_base_repo
|
||||||
.clone()
|
.clone()
|
||||||
.zip(record.worktree_path.clone());
|
.zip(record.worktree_path.clone());
|
||||||
Some((session, worktree))
|
Some(worktree)
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let Some((mut session, worktree)) = flipped else {
|
let Some(worktree) = flipped else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
let stream = inner
|
let stream = inner
|
||||||
@@ -2936,16 +3108,15 @@ async fn mark_session_dead_with_worktree_settlement(
|
|||||||
.get(&id)
|
.get(&id)
|
||||||
.and_then(|record| record.acp_events.clone());
|
.and_then(|record| record.acp_events.clone());
|
||||||
if let Some(stream) = stream {
|
if let Some(stream) = stream {
|
||||||
cancel_pending_approvals(&mut *stream.lock().await);
|
let mut stream = stream.lock().await;
|
||||||
|
cancel_pending_approvals(&mut stream);
|
||||||
|
stream.prompt_queue.clear();
|
||||||
}
|
}
|
||||||
tracing::info!(%id, "session process gone — marking dead");
|
tracing::info!(%id, "session process gone — marking dead");
|
||||||
|
|
||||||
if settle_worktree {
|
if settle_worktree {
|
||||||
if let Some(wt) = &worktree {
|
if let Some(wt) = &worktree {
|
||||||
if settle_worktree_on_exit(inner, id, wt).await {
|
if settle_worktree_on_exit(inner, id, wt).await {
|
||||||
// The tree is settled (removed); a stale "in the way" would
|
|
||||||
// describe something the session no longer holds.
|
|
||||||
session.blocked_work = None;
|
|
||||||
let mut sessions = inner.sessions.lock().await;
|
let mut sessions = inner.sessions.lock().await;
|
||||||
if let Some(record) = sessions.get_mut(&id) {
|
if let Some(record) = sessions.get_mut(&id) {
|
||||||
record.session.worktree = None;
|
record.session.worktree = None;
|
||||||
@@ -2957,13 +3128,18 @@ async fn mark_session_dead_with_worktree_settlement(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Settle has now been applied or refused either way (#115): from here on
|
// Settle has now been applied or refused either way (#115): from here on
|
||||||
// its outcomes are safe to observe.
|
// its outcomes are safe to observe. Re-read the session from the record
|
||||||
{
|
// so the sidecar write includes any updates (e.g. a haiku rename) that
|
||||||
|
// landed between the initial clone and now.
|
||||||
|
let session = {
|
||||||
let mut sessions = inner.sessions.lock().await;
|
let mut sessions = inner.sessions.lock().await;
|
||||||
if let Some(record) = sessions.get_mut(&id) {
|
let Some(record) = sessions.get_mut(&id) else {
|
||||||
record.settled = true;
|
return false;
|
||||||
}
|
};
|
||||||
}
|
record.settled = true;
|
||||||
|
record.session.queued_prompts.clear();
|
||||||
|
record.session.clone()
|
||||||
|
};
|
||||||
|
|
||||||
let _ = inner.sidecar.write(&session);
|
let _ = inner.sidecar.write(&session);
|
||||||
let _ = inner.events.send(CatalogEvent::SessionUpdated { session });
|
let _ = inner.events.send(CatalogEvent::SessionUpdated { session });
|
||||||
@@ -2972,8 +3148,8 @@ async fn mark_session_dead_with_worktree_settlement(
|
|||||||
|
|
||||||
/// Apply the exit rule (#82) to a vanished session's worktree: probe what
|
/// Apply the exit rule (#82) to a vanished session's worktree: probe what
|
||||||
/// would be lost through the kill's own predicate, then remove a pristine
|
/// would be lost through the kill's own predicate, then remove a pristine
|
||||||
/// tree. Anything holding unsaved work is left exactly where it is — the
|
/// tree. Anything holding unsaved work is left on disk — there is no
|
||||||
/// same thing a forced kill does with a guarded tree (#81).
|
/// operator confirmation on the natural-exit path.
|
||||||
///
|
///
|
||||||
/// Returns whether the worktree was fully accounted for. When it wasn't,
|
/// Returns whether the worktree was fully accounted for. When it wasn't,
|
||||||
/// nothing was destroyed and the caller keeps the reference on the record.
|
/// nothing was destroyed and the caller keeps the reference on the record.
|
||||||
@@ -3028,17 +3204,7 @@ async fn settle_worktree_on_exit(
|
|||||||
/// gone from the authoritative in-memory map, so a stray FIFO or sidecar can't
|
/// gone from the authoritative in-memory map, so a stray FIFO or sidecar can't
|
||||||
/// resurrect it. `kill_tmux` is `true` for an explicit kill and `false` for the
|
/// resurrect it. `kill_tmux` is `true` for an explicit kill and `false` for the
|
||||||
/// liveness reaper (tmux already lost the session).
|
/// liveness reaper (tmux already lost the session).
|
||||||
///
|
async fn cleanup_record(inner: &Arc<Inner>, id: Uuid, record: SessionRecord, kill_tmux: bool) {
|
||||||
/// `keep_worktree` is `true` for a forced kill (#81) over a guarded tree: the
|
|
||||||
/// session ends but the worktree is left on disk, so only the removal is
|
|
||||||
/// skipped.
|
|
||||||
async fn cleanup_record(
|
|
||||||
inner: &Arc<Inner>,
|
|
||||||
id: Uuid,
|
|
||||||
record: SessionRecord,
|
|
||||||
kill_tmux: bool,
|
|
||||||
keep_worktree: bool,
|
|
||||||
) {
|
|
||||||
if kill_tmux {
|
if kill_tmux {
|
||||||
let tmux = inner.tmux.clone();
|
let tmux = inner.tmux.clone();
|
||||||
let _ = tmux_blocking(move || tmux.kill_session(id)).await;
|
let _ = tmux_blocking(move || tmux.kill_session(id)).await;
|
||||||
@@ -3059,17 +3225,7 @@ async fn cleanup_record(
|
|||||||
tracing::warn!(error = ?e, %id, "cleanup: failed to delete sidecar");
|
tracing::warn!(error = ?e, %id, "cleanup: failed to delete sidecar");
|
||||||
}
|
}
|
||||||
if let (Some(base), Some(path)) = (&record.worktree_base_repo, &record.worktree_path) {
|
if let (Some(base), Some(path)) = (&record.worktree_base_repo, &record.worktree_path) {
|
||||||
if keep_worktree {
|
if let Err(e) = inner.worktree_mgr.remove(base, path) {
|
||||||
tracing::info!(
|
|
||||||
%id,
|
|
||||||
path = %path.display(),
|
|
||||||
"forced kill: worktree left on disk"
|
|
||||||
);
|
|
||||||
} else if let Err(e) = inner.worktree_mgr.remove(base, path) {
|
|
||||||
// The kill already passed the unsaved-work guard (#79), so this
|
|
||||||
// removal is unconditional; the manager's own nested-worktree
|
|
||||||
// refusal is all that can still leave a tree on disk, and it
|
|
||||||
// says where.
|
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
%id,
|
%id,
|
||||||
path = %path.display(),
|
path = %path.display(),
|
||||||
@@ -3145,6 +3301,7 @@ mod tests {
|
|||||||
pending_approvals: std::collections::HashMap::new(),
|
pending_approvals: std::collections::HashMap::new(),
|
||||||
operation: std::sync::Arc::new(tokio::sync::Mutex::new(())),
|
operation: std::sync::Arc::new(tokio::sync::Mutex::new(())),
|
||||||
dispatch: AcpDispatchState::default(),
|
dispatch: AcpDispatchState::default(),
|
||||||
|
prompt_queue: std::collections::VecDeque::new(),
|
||||||
activity_sequence: 0,
|
activity_sequence: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,15 +84,27 @@ impl Fake {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn event_text(event: &maestro_daemon::sidecar::LoggedEvent) -> &str {
|
fn event_text(event: &maestro_daemon::sidecar::LoggedEvent) -> &str {
|
||||||
let maestro_daemon::sidecar::PersistedEvent::Notification(notification) = &event.event else {
|
match &event.event {
|
||||||
panic!("unexpected daemon event")
|
maestro_daemon::sidecar::PersistedEvent::Notification(notification) => {
|
||||||
};
|
match ¬ification.update {
|
||||||
match ¬ification.update {
|
SessionUpdate::AgentMessageChunk(chunk) => match &chunk.content {
|
||||||
SessionUpdate::AgentMessageChunk(chunk) => match &chunk.content {
|
ContentBlock::Text(text) => &text.text,
|
||||||
ContentBlock::Text(text) => &text.text,
|
other => panic!("unexpected content {other:?}"),
|
||||||
other => panic!("unexpected content {other:?}"),
|
},
|
||||||
},
|
other => panic!("unexpected update {other:?}"),
|
||||||
other => panic!("unexpected update {other:?}"),
|
}
|
||||||
|
}
|
||||||
|
maestro_daemon::sidecar::PersistedEvent::User { row } => row
|
||||||
|
.data
|
||||||
|
.pointer("/content/text")
|
||||||
|
.and_then(serde_json::Value::as_str)
|
||||||
|
.expect("user row text"),
|
||||||
|
maestro_daemon::sidecar::PersistedEvent::Turn { row } => row
|
||||||
|
.data
|
||||||
|
.pointer("/maestroTurnMeta/outcome")
|
||||||
|
.and_then(serde_json::Value::as_str)
|
||||||
|
.unwrap_or("[turn]"),
|
||||||
|
maestro_daemon::sidecar::PersistedEvent::Approval { .. } => "[approval]",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,10 +271,10 @@ async fn structured_activity_tracks_turn_completion_and_failure() {
|
|||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
fx.manager
|
fx.manager
|
||||||
.queue_acp_prompt(session.id, "fail".into())
|
.queue_acp_prompt(session.id, "fail".into(), None)
|
||||||
.await
|
.await
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
maestro_daemon::session::QueueAcpPrompt::Queued
|
maestro_daemon::session::QueueAcpPrompt::Dispatched
|
||||||
));
|
));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
fx.manager
|
fx.manager
|
||||||
@@ -286,10 +298,10 @@ async fn structured_activity_tracks_turn_completion_and_failure() {
|
|||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
fx.manager
|
fx.manager
|
||||||
.queue_acp_prompt(session.id, "succeed".into())
|
.queue_acp_prompt(session.id, "succeed".into(), None)
|
||||||
.await
|
.await
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
maestro_daemon::session::QueueAcpPrompt::Queued
|
maestro_daemon::session::QueueAcpPrompt::Dispatched
|
||||||
));
|
));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
fx.manager
|
fx.manager
|
||||||
@@ -426,10 +438,10 @@ async fn queued_prompt_is_a_durable_user_transcript_row() {
|
|||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
fx.manager
|
fx.manager
|
||||||
.queue_acp_prompt(session.id, "show my prompt".into())
|
.queue_acp_prompt(session.id, "show my prompt".into(), None)
|
||||||
.await
|
.await
|
||||||
.expect("queue prompt"),
|
.expect("queue prompt"),
|
||||||
maestro_daemon::session::QueueAcpPrompt::Queued
|
maestro_daemon::session::QueueAcpPrompt::Dispatched
|
||||||
));
|
));
|
||||||
|
|
||||||
let (snapshot, _, _) = fx
|
let (snapshot, _, _) = fx
|
||||||
@@ -516,15 +528,15 @@ async fn cancelled_prompt_that_never_settles_fails_the_session() {
|
|||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
fx.manager
|
fx.manager
|
||||||
.queue_acp_prompt(session.id, "first".into())
|
.queue_acp_prompt(session.id, "first".into(), None)
|
||||||
.await
|
.await
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
maestro_daemon::session::QueueAcpPrompt::Queued
|
maestro_daemon::session::QueueAcpPrompt::Dispatched
|
||||||
));
|
));
|
||||||
assert!(fx.manager.cancel_acp_turn(session.id).await.unwrap());
|
assert!(fx.manager.cancel_acp_turn(session.id).await.unwrap());
|
||||||
let second = fx
|
let second = fx
|
||||||
.manager
|
.manager
|
||||||
.queue_acp_prompt(session.id, "second".into())
|
.queue_acp_prompt(session.id, "second".into(), None)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
@@ -555,18 +567,18 @@ async fn cancellation_settlement_allows_the_next_prompt_permission() {
|
|||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
fx.manager
|
fx.manager
|
||||||
.queue_acp_prompt(session.id, "first".into())
|
.queue_acp_prompt(session.id, "first".into(), None)
|
||||||
.await
|
.await
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
maestro_daemon::session::QueueAcpPrompt::Queued
|
maestro_daemon::session::QueueAcpPrompt::Dispatched
|
||||||
));
|
));
|
||||||
assert!(fx.manager.cancel_acp_turn(session.id).await.unwrap());
|
assert!(fx.manager.cancel_acp_turn(session.id).await.unwrap());
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
fx.manager
|
fx.manager
|
||||||
.queue_acp_prompt(session.id, "second".into())
|
.queue_acp_prompt(session.id, "second".into(), None)
|
||||||
.await
|
.await
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
maestro_daemon::session::QueueAcpPrompt::Queued
|
maestro_daemon::session::QueueAcpPrompt::Dispatched
|
||||||
));
|
));
|
||||||
|
|
||||||
let nonce = tokio::time::timeout(Duration::from_secs(2), async {
|
let nonce = tokio::time::timeout(Duration::from_secs(2), async {
|
||||||
@@ -655,25 +667,18 @@ async fn recovery_loads_the_acp_session_without_duplicating_history() {
|
|||||||
revived.status(session.id).await,
|
revived.status(session.id).await,
|
||||||
Some(SessionStatus::Running)
|
Some(SessionStatus::Running)
|
||||||
);
|
);
|
||||||
assert_eq!(
|
|
||||||
revived
|
|
||||||
.snapshot()
|
|
||||||
.await
|
|
||||||
.into_iter()
|
|
||||||
.find(|candidate| candidate.id == session.id)
|
|
||||||
.unwrap()
|
|
||||||
.activity,
|
|
||||||
ActivityState::Idle
|
|
||||||
);
|
|
||||||
let log = fake.log();
|
let log = fake.log();
|
||||||
assert!(
|
assert!(
|
||||||
log.contains(&format!("recv session/load sessionId={acp_id}")),
|
log.contains(&format!("recv session/load sessionId={acp_id}")),
|
||||||
"{log}"
|
"{log}"
|
||||||
);
|
);
|
||||||
|
// Recovery adds: a continue-prompt User row, the adapter's post-load
|
||||||
|
// notification, and a turn-completed footer — 5 events total before the
|
||||||
|
// next manual tick.
|
||||||
wait_for_async(Duration::from_secs(5), || async {
|
wait_for_async(Duration::from_secs(5), || async {
|
||||||
revived
|
revived
|
||||||
.read_acp_events(session.id, EventCursor::Since(0), 10)
|
.read_acp_events(session.id, EventCursor::Since(0), 10)
|
||||||
.is_ok_and(|events| events.len() == 3)
|
.is_ok_and(|events| events.len() == 5)
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -682,7 +687,7 @@ async fn recovery_loads_the_acp_session_without_duplicating_history() {
|
|||||||
.await
|
.await
|
||||||
.expect("replay read")
|
.expect("replay read")
|
||||||
.expect("recovered event subscription");
|
.expect("recovered event subscription");
|
||||||
assert_eq!(replay.len(), 3);
|
assert_eq!(replay.len(), 5);
|
||||||
assert!(live.try_recv().is_err(), "replay duplicated onto live");
|
assert!(live.try_recv().is_err(), "replay duplicated onto live");
|
||||||
let new_pid = fake.pid();
|
let new_pid = fake.pid();
|
||||||
assert_ne!(new_pid, old_pid);
|
assert_ne!(new_pid, old_pid);
|
||||||
@@ -693,16 +698,23 @@ async fn recovery_loads_the_acp_session_without_duplicating_history() {
|
|||||||
.await
|
.await
|
||||||
.expect("live event after recovery")
|
.expect("live event after recovery")
|
||||||
.expect("broadcast open");
|
.expect("broadcast open");
|
||||||
assert_eq!(event.seq, 4);
|
assert_eq!(event.seq, 6);
|
||||||
assert_eq!(event_text(&event), "tick 1");
|
assert_eq!(event_text(&event), "tick 1");
|
||||||
|
|
||||||
let stored = revived
|
let stored = revived
|
||||||
.read_acp_events(session.id, EventCursor::Since(0), 10)
|
.read_acp_events(session.id, EventCursor::Since(0), 10)
|
||||||
.expect("recovered log");
|
.expect("recovered log");
|
||||||
assert_eq!(stored.len(), 4, "session/load replay must not be appended");
|
assert_eq!(stored.len(), 6, "session/load replay must not be appended");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
stored.iter().map(event_text).collect::<Vec<_>>(),
|
stored.iter().map(event_text).collect::<Vec<_>>(),
|
||||||
["hello from fake", "tick 1", "hello after load", "tick 1"]
|
[
|
||||||
|
"hello from fake",
|
||||||
|
"tick 1",
|
||||||
|
"The daemon restarted. Continue where you left off.",
|
||||||
|
"hello after load",
|
||||||
|
"completed",
|
||||||
|
"tick 1",
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
revived.kill(session.id, false).await.expect("kill");
|
revived.kill(session.id, false).await.expect("kill");
|
||||||
|
|||||||
@@ -11,14 +11,14 @@ use common::TmuxFixture;
|
|||||||
use maestro_daemon::sidecar::SidecarStore;
|
use maestro_daemon::sidecar::SidecarStore;
|
||||||
use maestro_daemon::worktree::WorktreeManager;
|
use maestro_daemon::worktree::WorktreeManager;
|
||||||
use maestro_daemon::SessionManager;
|
use maestro_daemon::SessionManager;
|
||||||
use maestro_protocol::{display, CatalogEvent, CreateSessionRequest, NameSource};
|
use maestro_protocol::{display, CatalogEvent, CreateSessionRequest, NameSource, SessionStatus};
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
struct Fixture {
|
struct Fixture {
|
||||||
manager: SessionManager,
|
manager: SessionManager,
|
||||||
sidecar: SidecarStore,
|
sidecar: SidecarStore,
|
||||||
_tmux: TmuxFixture,
|
tmux: TmuxFixture,
|
||||||
_state_dir: TempDir,
|
_state_dir: TempDir,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ fn fixture() -> Fixture {
|
|||||||
Fixture {
|
Fixture {
|
||||||
manager,
|
manager,
|
||||||
sidecar,
|
sidecar,
|
||||||
_tmux: tmux,
|
tmux,
|
||||||
_state_dir: state_dir,
|
_state_dir: state_dir,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -171,3 +171,37 @@ async fn a_blank_name_changes_nothing() {
|
|||||||
let live = fx.sidecar.read(id).unwrap().unwrap();
|
let live = fx.sidecar.read(id).unwrap().unwrap();
|
||||||
assert_eq!(display::name_source(&live), NameSource::Generated);
|
assert_eq!(display::name_source(&live), NameSource::Generated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A session that dies naturally (liveness watcher marks it dead) must not
|
||||||
|
/// overwrite a haiku rename that landed before the death. Regression test for
|
||||||
|
/// the stale-clone sidecar write in `mark_session_dead`.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn session_death_preserves_the_haiku_name() {
|
||||||
|
let fx = fixture();
|
||||||
|
let id = spawn(&fx.manager, None).await;
|
||||||
|
|
||||||
|
fx.manager.claim_autoname(id).await.unwrap();
|
||||||
|
fx.manager.apply_autoname(id, "haiku-name").await.unwrap();
|
||||||
|
|
||||||
|
// Verify the sidecar has Haiku before the session dies.
|
||||||
|
let before = fx.sidecar.read(id).unwrap().unwrap();
|
||||||
|
assert_eq!(display::name_source(&before), NameSource::Haiku);
|
||||||
|
|
||||||
|
// Kill the tmux session directly so the liveness watcher picks it up.
|
||||||
|
fx.tmux.tmux.kill_session(id).expect("kill tmux directly");
|
||||||
|
fx.manager
|
||||||
|
.start_liveness_watcher(std::time::Duration::from_millis(25));
|
||||||
|
|
||||||
|
common::wait_for_async(std::time::Duration::from_secs(3), || async {
|
||||||
|
fx.manager.status(id).await == Some(SessionStatus::Dead)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let after = fx.sidecar.read(id).unwrap().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
display::name_source(&after),
|
||||||
|
NameSource::Haiku,
|
||||||
|
"mark_session_dead must not overwrite a haiku rename in the sidecar"
|
||||||
|
);
|
||||||
|
assert_eq!(display::custom_name(&after), Some("haiku-name"));
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,14 +13,14 @@ async fn embedded_index_is_real_spa_not_placeholder() {
|
|||||||
let html = String::from_utf8(body.to_vec()).unwrap();
|
let html = String::from_utf8(body.to_vec()).unwrap();
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
html.contains(r#"id="app""#),
|
html.contains(r#"id="root""#),
|
||||||
"served index is missing the SPA mount point; got:\n{html}"
|
"served index is missing the SPA mount point; got:\n{html}"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!html.contains("maestro is running"),
|
!html.contains("maestro is running"),
|
||||||
"served index is the placeholder, not the built bundle"
|
"served index is the placeholder, not the built bundle"
|
||||||
);
|
);
|
||||||
// Stronger signal than `id="app"` (which appears in both source and built
|
// Stronger signal than `id="root"` (which appears in both source and built
|
||||||
// HTML): the vite build rewrites the entry to a hashed `/assets/index-<hash>`
|
// HTML): the vite build rewrites the entry to a hashed `/assets/index-<hash>`
|
||||||
// bundle, whereas the un-built source references `/src/main.ts`. This catches
|
// bundle, whereas the un-built source references `/src/main.ts`. This catches
|
||||||
// both an empty/placeholder embed and the staleness case.
|
// both an empty/placeholder embed and the staleness case.
|
||||||
|
|||||||
@@ -293,7 +293,7 @@ async fn structured_attachments_preserve_bytes_and_reject_paths() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn structured_prompt_rejects_a_second_in_flight_turn_and_cancel_clears_it() {
|
async fn structured_prompt_queues_during_active_turn_and_cancel_clears_it() {
|
||||||
let h = spawn("naps-laptop").await;
|
let h = spawn("naps-laptop").await;
|
||||||
let fake = FakeAcp::new();
|
let fake = FakeAcp::new();
|
||||||
let mut env = fake.env();
|
let mut env = fake.env();
|
||||||
@@ -310,6 +310,7 @@ async fn structured_prompt_rejects_a_second_in_flight_turn_and_cancel_clears_it(
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(first.status(), reqwest::StatusCode::NO_CONTENT);
|
assert_eq!(first.status(), reqwest::StatusCode::NO_CONTENT);
|
||||||
|
// A second prompt while a turn is active is accepted into the queue.
|
||||||
let second = client
|
let second = client
|
||||||
.post(url(
|
.post(url(
|
||||||
h.addr,
|
h.addr,
|
||||||
@@ -319,9 +320,7 @@ async fn structured_prompt_rejects_a_second_in_flight_turn_and_cancel_clears_it(
|
|||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(second.status(), reqwest::StatusCode::CONFLICT);
|
assert_eq!(second.status(), reqwest::StatusCode::ACCEPTED);
|
||||||
let error: ApiError = second.json().await.unwrap();
|
|
||||||
assert_eq!(error.error.code, "turn_in_flight");
|
|
||||||
|
|
||||||
let cancel = client
|
let cancel = client
|
||||||
.post(url(
|
.post(url(
|
||||||
@@ -332,16 +331,8 @@ async fn structured_prompt_rejects_a_second_in_flight_turn_and_cancel_clears_it(
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(cancel.status(), reqwest::StatusCode::NO_CONTENT);
|
assert_eq!(cancel.status(), reqwest::StatusCode::NO_CONTENT);
|
||||||
let next = client
|
// After cancel + settlement, the queued "second" prompt drains. Wait for
|
||||||
.post(url(
|
// both the first and the drained prompt to reach ACP.
|
||||||
h.addr,
|
|
||||||
&format!("/api/sessions/{id}/structured/prompt"),
|
|
||||||
))
|
|
||||||
.json(&serde_json::json!({"prompt": "next"}))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(next.status(), reqwest::StatusCode::NO_CONTENT);
|
|
||||||
tokio::time::timeout(Duration::from_secs(2), async {
|
tokio::time::timeout(Duration::from_secs(2), async {
|
||||||
loop {
|
loop {
|
||||||
if fake.log().matches("recv session/prompt").count() == 2 {
|
if fake.log().matches("recv session/prompt").count() == 2 {
|
||||||
@@ -351,7 +342,7 @@ async fn structured_prompt_rejects_a_second_in_flight_turn_and_cancel_clears_it(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("both prompt commands reach ACP");
|
.expect("first and drained queued prompt both reach ACP");
|
||||||
let log = fake.log();
|
let log = fake.log();
|
||||||
let first_prompt = log.find("recv session/prompt").unwrap();
|
let first_prompt = log.find("recv session/prompt").unwrap();
|
||||||
let cancel = log.find("recv session/cancel").unwrap();
|
let cancel = log.find("recv session/cancel").unwrap();
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ fn sample(id: Uuid) -> Session {
|
|||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
acp_session_id: None,
|
acp_session_id: None,
|
||||||
view: SessionView::Terminal,
|
view: SessionView::Terminal,
|
||||||
|
auto_approve: false,
|
||||||
|
queued_prompts: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ async fn root_serves_the_spa_shell() {
|
|||||||
.map(|v| v.starts_with("text/html")),
|
.map(|v| v.starts_with("text/html")),
|
||||||
Some(true)
|
Some(true)
|
||||||
);
|
);
|
||||||
assert!(resp.text().await.unwrap().contains(r#"id="app""#));
|
assert!(resp.text().await.unwrap().contains(r#"id="root""#));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -69,7 +69,7 @@ async fn client_side_route_falls_back_to_the_shell() {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(resp.status(), 200);
|
assert_eq!(resp.status(), 200);
|
||||||
assert!(resp.text().await.unwrap().contains(r#"id="app""#));
|
assert!(resp.text().await.unwrap().contains(r#"id="root""#));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An unmatched API path must 404 rather than fall through to the SPA shell:
|
/// An unmatched API path must 404 rather than fall through to the SPA shell:
|
||||||
|
|||||||
@@ -777,7 +777,7 @@ async fn unpushed_commits_block_kill_and_force_keeps_the_tree() {
|
|||||||
other => panic!("expected a kill_guarded refusal, got {other:?}"),
|
other => panic!("expected a kill_guarded refusal, got {other:?}"),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force overrides the refusal and nothing else: session gone, tree kept.
|
// Force overrides the refusal: session gone, tree removed.
|
||||||
client.kill_session_forced(s.id).await.expect("forced kill");
|
client.kill_session_forced(s.id).await.expect("forced kill");
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
@@ -789,12 +789,12 @@ async fn unpushed_commits_block_kill_and_force_keeps_the_tree() {
|
|||||||
.any(|x| x.id == s.id),
|
.any(|x| x.id == s.id),
|
||||||
"the forced kill ends the session"
|
"the forced kill ends the session"
|
||||||
);
|
);
|
||||||
assert!(wt.is_dir(), "a guarded worktree must stay on disk");
|
assert!(!wt.is_dir(), "a forced kill removes the worktree");
|
||||||
assert!(
|
assert!(
|
||||||
git_worktree_list(base.path())
|
!git_worktree_list(base.path())
|
||||||
.iter()
|
.iter()
|
||||||
.any(|l| l.contains("feature/local")),
|
.any(|l| l.contains("feature/local")),
|
||||||
"the kept tree stays registered in git"
|
"the removed tree is unregistered from git"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -744,6 +744,8 @@ mod tests {
|
|||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
acp_session_id: None,
|
acp_session_id: None,
|
||||||
|
auto_approve: false,
|
||||||
|
queued_prompts: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -132,6 +132,16 @@ pub struct Session {
|
|||||||
/// the two are minted by different processes with different lifetimes.
|
/// the two are minted by different processes with different lifetimes.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub acp_session_id: Option<String>,
|
pub acp_session_id: Option<String>,
|
||||||
|
/// When `true` the daemon auto-approves every ACP permission request for
|
||||||
|
/// this session instead of surfacing them to the client. Set at spawn time
|
||||||
|
/// from the preset or the `CreateSessionRequest`; persisted so it survives
|
||||||
|
/// a daemon restart.
|
||||||
|
#[serde(default)]
|
||||||
|
pub auto_approve: bool,
|
||||||
|
/// Prompts queued on the daemon while a turn was in flight or the adapter
|
||||||
|
/// was still booting. Persisted so the queue survives a daemon restart.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub queued_prompts: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A session's process shape (#133): a plain terminal, or a structured agent
|
/// A session's process shape (#133): a plain terminal, or a structured agent
|
||||||
@@ -872,6 +882,10 @@ pub struct ReducedState {
|
|||||||
pub mode: Option<String>,
|
pub mode: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub config_options: Option<Value>,
|
pub config_options: Option<Value>,
|
||||||
|
/// Prompts queued on the daemon while a turn is in flight. Drained as
|
||||||
|
/// a single concatenated prompt when the current turn completes.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub queued_prompts: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Server messages sent over a structured-session WebSocket.
|
/// Server messages sent over a structured-session WebSocket.
|
||||||
@@ -1054,6 +1068,8 @@ mod tests {
|
|||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
view: SessionView::Terminal,
|
view: SessionView::Terminal,
|
||||||
acp_session_id: None,
|
acp_session_id: None,
|
||||||
|
auto_approve: false,
|
||||||
|
queued_prompts: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ pub fn session(id: Uuid, cwd: &str, command: &str) -> Session {
|
|||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
acp_session_id: None,
|
acp_session_id: None,
|
||||||
|
auto_approve: false,
|
||||||
|
queued_prompts: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2216,6 +2216,8 @@ mod tests {
|
|||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
acp_session_id: None,
|
acp_session_id: None,
|
||||||
|
auto_approve: false,
|
||||||
|
queued_prompts: Vec::new(),
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
});
|
});
|
||||||
@@ -2399,6 +2401,8 @@ mod tests {
|
|||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
acp_session_id: None,
|
acp_session_id: None,
|
||||||
|
auto_approve: false,
|
||||||
|
queued_prompts: Vec::new(),
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
app.dispatch(Action::SidebarNext);
|
app.dispatch(Action::SidebarNext);
|
||||||
@@ -2428,6 +2432,8 @@ mod tests {
|
|||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
acp_session_id: None,
|
acp_session_id: None,
|
||||||
|
auto_approve: false,
|
||||||
|
queued_prompts: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3590,6 +3596,8 @@ mod tests {
|
|||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: focused,
|
last_focused_at: focused,
|
||||||
acp_session_id: None,
|
acp_session_id: None,
|
||||||
|
auto_approve: false,
|
||||||
|
queued_prompts: Vec::new(),
|
||||||
};
|
};
|
||||||
let older = Utc::now() - chrono::Duration::seconds(60);
|
let older = Utc::now() - chrono::Duration::seconds(60);
|
||||||
app.catalog.apply_event(CatalogEvent::Snapshot {
|
app.catalog.apply_event(CatalogEvent::Snapshot {
|
||||||
@@ -3870,6 +3878,8 @@ mod tests {
|
|||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
acp_session_id: None,
|
acp_session_id: None,
|
||||||
|
auto_approve: false,
|
||||||
|
queued_prompts: Vec::new(),
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
app.dispatch(Action::KillHighlighted);
|
app.dispatch(Action::KillHighlighted);
|
||||||
@@ -3907,6 +3917,8 @@ mod tests {
|
|||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
acp_session_id: None,
|
acp_session_id: None,
|
||||||
|
auto_approve: false,
|
||||||
|
queued_prompts: Vec::new(),
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -225,6 +225,8 @@ mod tests {
|
|||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
acp_session_id: None,
|
acp_session_id: None,
|
||||||
|
auto_approve: false,
|
||||||
|
queued_prompts: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -811,6 +811,8 @@ mod tests {
|
|||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
acp_session_id: None,
|
acp_session_id: None,
|
||||||
|
auto_approve: false,
|
||||||
|
queued_prompts: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -502,6 +502,8 @@ mod tests {
|
|||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
acp_session_id: None,
|
acp_session_id: None,
|
||||||
|
auto_approve: false,
|
||||||
|
queued_prompts: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1272,6 +1274,8 @@ mod tests {
|
|||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
acp_session_id: None,
|
acp_session_id: None,
|
||||||
|
auto_approve: false,
|
||||||
|
queued_prompts: Vec::new(),
|
||||||
};
|
};
|
||||||
let sb = Sidebar {
|
let sb = Sidebar {
|
||||||
sessions: vec![s],
|
sessions: vec![s],
|
||||||
|
|||||||
+1
-1
@@ -70,7 +70,7 @@ command. `<duration>` follows in the rail's colour when shown. There is no
|
|||||||
state word and no second badge: the rail exists so the state is still legible
|
state word and no second badge: the rail exists so the state is still legible
|
||||||
when a row is dimmed or the terminal renders the duration in the default fg.
|
when a row is dimmed or the terminal renders the duration in the default fg.
|
||||||
|
|
||||||
In the **web client** the rail is a 5px bar pinned to the row's own left
|
In the **web client** the rail is a 7px bar pinned to the row's own left
|
||||||
edge, square corners, spanning the full card height edge to edge — it
|
edge, square corners, spanning the full card height edge to edge — it
|
||||||
shifts outward with nesting depth along with the row's own padding, since
|
shifts outward with nesting depth along with the row's own padding, since
|
||||||
it is positioned relative to the row. It carries motion only for states that
|
it is positioned relative to the row. It carries motion only for states that
|
||||||
|
|||||||
+28
-23
@@ -189,17 +189,17 @@ choices, not daemon ones.
|
|||||||
presentation for `Notice`. `Plan` is the exception: both clients omit it
|
presentation for `Notice`. `Plan` is the exception: both clients omit it
|
||||||
from the transcript; the TUI may show the current plan in its status line,
|
from the transcript; the TUI may show the current plan in its status line,
|
||||||
while the web client deliberately has no stats or plan rail.
|
while the web client deliberately has no stats or plan rail.
|
||||||
- The web client groups two or more tool rows after the same `User` row into
|
- The web client groups each contiguous run of two or more tool rows into a
|
||||||
one collapsed turn activity block. `AgentText` and `AgentThought` rows that
|
collapsed activity block. An `AgentText` or `AgentThought` row between
|
||||||
appear *between* tool rows in the same turn are absorbed into the group
|
tools splits them into separate groups, so the timeline reads
|
||||||
(intermediate narration); the last `AgentText` with no subsequent tool stays
|
text → group → text → group rather than lumping every tool in a turn into
|
||||||
outside as the prominent final answer. While a turn is still active
|
one box. Text and thought rows stay outside groups as standalone transcript
|
||||||
(responding or thinking), the most recent intermediate `AgentText` or
|
items — the operator sees all reasoning without expanding. Resolved
|
||||||
`AgentThought` in the group is kept outside as visible narration — replaced
|
approval rows between tools within a run are absorbed into the group. The
|
||||||
by the next text row when one arrives, and absorbed into the group when the
|
collapsed header reads "N tool calls · Label 'arg'" where the label and
|
||||||
turn ends. The header still reads "N tool calls" (counting only tool rows).
|
argument come from the last tool row's `splitToolTitle` — the operator
|
||||||
Opening the group reveals tools and intermediate text in chronological
|
sees which command is current at a glance. Opening the group reveals tool
|
||||||
order, with the text rendered in a subdued style.
|
rows in chronological order.
|
||||||
- The web client's user, agent, thought, tool-detail, and notice text blocks
|
- The web client's user, agent, thought, tool-detail, and notice text blocks
|
||||||
copy their complete source text when clicked or keyboard-activated.
|
copy their complete source text when clicked or keyboard-activated.
|
||||||
- The web client consumes `available_commands_update` notices as the live
|
- The web client consumes `available_commands_update` notices as the live
|
||||||
@@ -353,21 +353,26 @@ thing a callback can be attributed to, so at most one turn may ever be
|
|||||||
in flight, and a cancelled turn must fully settle before the next prompt is
|
in flight, and a cancelled turn must fully settle before the next prompt is
|
||||||
allowed to claim the daemon's idea of "the active turn."
|
allowed to claim the daemon's idea of "the active turn."
|
||||||
|
|
||||||
- Web clients may collect a visible FIFO of operator messages while a turn is
|
- Prompts are accepted at any time: if the session is idle the daemon dispatches
|
||||||
active. They submit each queued message only after the projected turn becomes
|
immediately (`204`); if a turn is active the prompt is appended to a
|
||||||
idle; the queue is client-local and unsent messages do not survive a reload.
|
server-side FIFO (`202 Accepted`, capped at 20 entries, `429` when full).
|
||||||
|
When the active turn completes, all queued prompts are concatenated with
|
||||||
|
`\n\n` and dispatched as the next turn. The queue is persisted in the session
|
||||||
|
sidecar and survives daemon restarts — clients never queue locally.
|
||||||
|
`ReducedState.queued_prompts` contains the current queue so every connected
|
||||||
|
client can render the pending messages.
|
||||||
- A submitted prompt is appended as a durable `User` row before it claims the turn and
|
- A submitted prompt is appended as a durable `User` row before it claims the turn and
|
||||||
dispatches to ACP, so the transcript never depends on an adapter echoing the
|
dispatches to ACP, so the transcript never depends on an adapter echoing the
|
||||||
operator's input. If that append fails, the prompt is not dispatched. While a
|
operator's input. If that append fails, the prompt is not dispatched.
|
||||||
turn is active, a second prompt gets `409 turn_in_flight` rather than
|
|
||||||
queuing in the daemon — one active turn per session, never a hidden
|
|
||||||
server-side queue of prompts.
|
|
||||||
- Cancel sends `session/cancel`, clears any pending approval as `Cancelled`,
|
- Cancel sends `session/cancel`, clears any pending approval as `Cancelled`,
|
||||||
and ends the turn's client-visible lifecycle immediately. If the cancelled
|
and ends the turn's client-visible lifecycle immediately. The prompt queue
|
||||||
turn was still *dispatched* to ACP (waiting on its response), the daemon
|
is preserved: after the cancelled turn settles, the queued prompts drain as
|
||||||
cannot simply forget it: ACP has not confirmed the cancel yet, and a new
|
the next turn. If the session dies instead (settlement timeout), the queue
|
||||||
prompt claiming "the active turn" while the old one is still outstanding
|
is cleared. If the cancelled turn was still *dispatched* to ACP (waiting on
|
||||||
would make the next permission callback ambiguous between the two.
|
its response), the daemon cannot simply forget it: ACP has not confirmed the
|
||||||
|
cancel yet, and a new prompt claiming "the active turn" while the old one is
|
||||||
|
still outstanding would make the next permission callback ambiguous between
|
||||||
|
the two.
|
||||||
- So a cancelled-but-still-dispatched turn must **settle within
|
- So a cancelled-but-still-dispatched turn must **settle within
|
||||||
`CANCEL_GRACE` (500ms)** — either ACP acknowledges the prompt (however it
|
`CANCEL_GRACE` (500ms)** — either ACP acknowledges the prompt (however it
|
||||||
resolves) or that grace period runs out. If it settles in time, the next
|
resolves) or that grace period runs out. If it settles in time, the next
|
||||||
|
|||||||
+10
-25
@@ -75,27 +75,17 @@ The point of refusing rather than cleaning up is that the session stays as the
|
|||||||
operator's handle on their own unfinished work. A session that is killed while
|
operator's handle on their own unfinished work. A session that is killed while
|
||||||
its worktree still matters leaves a directory nobody is looking at.
|
its worktree still matters leaves a directory nobody is looking at.
|
||||||
|
|
||||||
A consequence, accepted deliberately: a feature branch almost always has unpushed
|
|
||||||
commits, so `--force` is the ordinary way to end a session, not an exception.
|
|
||||||
That is only safe because force never removes what blocked.
|
|
||||||
|
|
||||||
### `--force`
|
### `--force`
|
||||||
|
|
||||||
`--force` overrides the refusal and nothing else. It ends the session and
|
`--force` overrides the refusal and **removes the worktree**, even when it holds
|
||||||
**leaves the worktree on disk**, untouched. Force never removes a tree that
|
unsaved work. The operator sees the breakdown in the confirmation prompt and
|
||||||
would have blocked. A tree whose guard is clear never needed the override, so a
|
decides; once confirmed, the tree is gone.
|
||||||
forced kill removes it as usual — force changes what happens to blocked work,
|
|
||||||
not ordinary cleanup.
|
|
||||||
|
|
||||||
In the web client the kill button sends the unforced `DELETE` straight away —
|
In the web client the kill button sends the unforced `DELETE` straight away —
|
||||||
there is no pre-confirmation, because the daemon is the one that decides. Only
|
there is no pre-confirmation, because the daemon is the one that decides. Only
|
||||||
the refusal raises a prompt, and that prompt names what is in the way and offers
|
the refusal raises a prompt, and that prompt names what is in the way and offers
|
||||||
"Force kill", which repeats the request with `force=true`.
|
"Force kill", which repeats the request with `force=true`.
|
||||||
|
|
||||||
Ending a session and destroying its work are two separate acts. Maestro only
|
|
||||||
performs the first one. Removing a tree it left behind is the operator's own
|
|
||||||
`git worktree remove`, chosen while looking at exactly what would be lost.
|
|
||||||
|
|
||||||
### Exiting on its own
|
### Exiting on its own
|
||||||
|
|
||||||
A session that ends by itself — the command finished, the operator typed `exit` —
|
A session that ends by itself — the command finished, the operator typed `exit` —
|
||||||
@@ -113,17 +103,12 @@ once. A removed tree drops off the record, so dismissing the session afterwards
|
|||||||
does not refuse: there is no guard left to trip.
|
does not refuse: there is no guard left to trip.
|
||||||
|
|
||||||
A tree that was left alone stays attached to the dead record, where dismissing it
|
A tree that was left alone stays attached to the dead record, where dismissing it
|
||||||
later goes through `--force`, which leaves it on disk too. The same holds when
|
later goes through `--force`, which removes it. The same holds when the probe
|
||||||
the probe itself fails: nothing is destroyed.
|
itself fails: nothing is destroyed.
|
||||||
|
|
||||||
## Nothing is removed on the operator's behalf
|
## Trees left behind by natural exit
|
||||||
|
|
||||||
There is no store of worktrees the daemon is holding, no list endpoint, and no
|
A forced kill always removes the worktree. A natural exit with unsaved work
|
||||||
reclaim command. A tree left behind by a forced kill or a dirty exit is an
|
leaves the tree on disk — there is no operator confirmation in that path. Those
|
||||||
ordinary git worktree on disk; `git worktree list` finds it and
|
trees are ordinary git worktrees; `git worktree list` finds them and
|
||||||
`git worktree remove` removes it.
|
`git worktree remove` removes them.
|
||||||
|
|
||||||
That is deliberate. A tracked list of trees pending deletion is a second place
|
|
||||||
the truth can live, and it goes stale the moment somebody removes a directory by
|
|
||||||
hand. Git already knows which worktrees exist, and removing one is not a
|
|
||||||
decision Maestro is better placed to make.
|
|
||||||
|
|||||||
@@ -148,7 +148,7 @@
|
|||||||
pnpmDeps = (pkgs.fetchPnpmDeps.override { pnpm = pkgs.pnpm_10; }) {
|
pnpmDeps = (pkgs.fetchPnpmDeps.override { pnpm = pkgs.pnpm_10; }) {
|
||||||
inherit (finalAttrs) pname version src;
|
inherit (finalAttrs) pname version src;
|
||||||
fetcherVersion = 3;
|
fetcherVersion = 3;
|
||||||
hash = "sha256-AzF/rrhqbvl613hNsC18YSylBSVxtatg7fEiUEF1u2o=";
|
hash = "sha256-FEWOAtSFvOWHeCNR0RK8JQ/ssW31qmShu1YD2GuqioQ=";
|
||||||
};
|
};
|
||||||
|
|
||||||
buildPhase = ''
|
buildPhase = ''
|
||||||
|
|||||||
+1
-2
@@ -23,8 +23,7 @@
|
|||||||
<meta name="apple-mobile-web-app-title" content="maestro" />
|
<meta name="apple-mobile-web-app-title" content="maestro" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/index.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+452
-2
@@ -1,9 +1,459 @@
|
|||||||
import { ThemeProvider } from "./contexts/ThemeContext";
|
// The app shell (#253): everything main.ts used to wire by hand, as one React
|
||||||
|
// tree. The stores are the state — a catalog store fed by the events socket, a
|
||||||
|
// layout store holding the tab/split tree — and the hash route is a deep link
|
||||||
|
// into the latter.
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useStore } from "zustand";
|
||||||
|
|
||||||
|
import "@fontsource/jetbrains-mono/400.css";
|
||||||
|
import "@fontsource/jetbrains-mono/500.css";
|
||||||
|
import "@fontsource/jetbrains-mono/600.css";
|
||||||
|
import "@fontsource/jetbrains-mono/700.css";
|
||||||
|
|
||||||
|
import { createSession, fetchNames, getDaemonInfo, gitRoot, projects as fetchProjects } from "./api.js";
|
||||||
|
import {
|
||||||
|
CommandSet,
|
||||||
|
DEFAULT_LEADER,
|
||||||
|
controlByte,
|
||||||
|
defineCommands,
|
||||||
|
isBarTypingTarget,
|
||||||
|
} from "./commands.js";
|
||||||
|
import { ThemeProvider } from "./contexts/ThemeContext.js";
|
||||||
|
import { CommandBar, SPAWN_DIRECTORY_EVENT, useCommandBar } from "./components/commandBar/index.js";
|
||||||
|
import { DaemonLink, createDaemonLinkStore } from "./components/daemon/DaemonLink.js";
|
||||||
|
import { RestartOverlay } from "./components/daemon/RestartOverlay.js";
|
||||||
|
import { HelpOverlay } from "./components/dialogs/index.js";
|
||||||
|
import { useViewport } from "./components/hooks/useViewport.js";
|
||||||
|
import { Drawer } from "./components/layout/Drawer.js";
|
||||||
|
import { MultiView, type MultiViewHandle } from "./components/layout/MultiView.js";
|
||||||
|
import { Sidebar } from "./components/sidebar/index.js";
|
||||||
|
import {
|
||||||
|
SpawnModal,
|
||||||
|
spawnSimilarProjectSeed,
|
||||||
|
spawnSimilarSeed,
|
||||||
|
useFastForward,
|
||||||
|
type SpawnFormProps,
|
||||||
|
} from "./components/spawn/index.js";
|
||||||
|
import { buildFastForwardRequest, fastForwardSeed, resolveFastForward } from "./fastForward.js";
|
||||||
|
import { useHashRouter } from "./hooks/useHashRouter.js";
|
||||||
|
import { usePreview } from "./hooks/usePreview.js";
|
||||||
|
import { activeTab, findLeaf, openSessions } from "./layout.js";
|
||||||
|
import { createCatalogStore, type CatalogStore } from "./stores/catalogStore.js";
|
||||||
|
import { createLayoutStore, type LayoutStore } from "./stores/layoutStore.js";
|
||||||
|
import type { Project, Session, SpawnPreset, Uuid } from "./types.js";
|
||||||
|
import {
|
||||||
|
FOCUS_PANE_EVENT,
|
||||||
|
OPEN_SPAWN_EVENT,
|
||||||
|
SESSION_SELECT_EVENT,
|
||||||
|
SPAWN_SIMILAR_EVENT,
|
||||||
|
requestCloseDrawer,
|
||||||
|
requestOpenSpawn,
|
||||||
|
requestToggleDrawer,
|
||||||
|
} from "./uiEvents.js";
|
||||||
|
|
||||||
|
type SpawnSeed = SpawnFormProps["seed"];
|
||||||
|
|
||||||
|
/** Open modal state: `seed` is what the form starts from (undefined for a
|
||||||
|
* plain spawn), `newTab` where the created session lands. */
|
||||||
|
interface SpawnState {
|
||||||
|
seed: SpawnSeed;
|
||||||
|
newTab: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<div id="react-root" />
|
<Shell />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Shell() {
|
||||||
|
useViewport();
|
||||||
|
|
||||||
|
// One instance each, for the life of the page.
|
||||||
|
const catalogStore = useMemo(() => createCatalogStore(), []);
|
||||||
|
const layoutStore = useMemo(() => createLayoutStore(), []);
|
||||||
|
const linkStore = useMemo(() => createDaemonLinkStore(), []);
|
||||||
|
|
||||||
|
const sessions = useStore(catalogStore, (s: CatalogStore) => s.sessions);
|
||||||
|
const connected = useStore(catalogStore, (s: CatalogStore) => s.connected);
|
||||||
|
const loaded = useStore(catalogStore, (s: CatalogStore) => s.loaded);
|
||||||
|
const layout = useStore(layoutStore, (s: LayoutStore) => s.layout);
|
||||||
|
|
||||||
|
const [homeDir, setHomeDir] = useState<string | undefined>(undefined);
|
||||||
|
const [leader, setLeader] = useState(DEFAULT_LEADER);
|
||||||
|
const [spawn, setSpawn] = useState<SpawnState | null>(null);
|
||||||
|
|
||||||
|
const { route, navigate } = useHashRouter();
|
||||||
|
const { preview, previewedSessionId, commit: commitPreview, end: endPreview } = usePreview({
|
||||||
|
store: layoutStore,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
catalogStore.getState().start();
|
||||||
|
return () => catalogStore.getState().stop();
|
||||||
|
}, [catalogStore]);
|
||||||
|
|
||||||
|
// The daemon's home dir abbreviates cwds to `~/…`; its leader rebinds the
|
||||||
|
// chords hanging off it (#159), so the `?` overlay and the command bar show
|
||||||
|
// the configured chord rather than the default. Absolute paths and the
|
||||||
|
// default leader are fine fallbacks.
|
||||||
|
useEffect(() => {
|
||||||
|
void getDaemonInfo()
|
||||||
|
.then((info) => {
|
||||||
|
setHomeDir(info.home_dir);
|
||||||
|
if (info.leader) setLeader(info.leader);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Installed mobile browsers suspend timers and WebSockets in the background.
|
||||||
|
// Replacing the socket on return avoids leaving the restart overlay up after
|
||||||
|
// a transient lost connection has already recovered.
|
||||||
|
useEffect(() => {
|
||||||
|
const recover = () => {
|
||||||
|
void linkStore.getState().probe();
|
||||||
|
catalogStore.getState().reconnect();
|
||||||
|
};
|
||||||
|
// visibilitychange fires reliably on tab/app switch; guard it so we don't
|
||||||
|
// probe while the page is being hidden.
|
||||||
|
const onVisibility = () => {
|
||||||
|
if (document.visibilityState !== "visible") return;
|
||||||
|
recover();
|
||||||
|
};
|
||||||
|
// pageshow (bfcache restore) and online (network back) indicate the page
|
||||||
|
// is active — fire unconditionally since visibilityState may lag behind on
|
||||||
|
// some mobile browsers.
|
||||||
|
document.addEventListener("visibilitychange", onVisibility);
|
||||||
|
window.addEventListener("pageshow", recover);
|
||||||
|
window.addEventListener("online", recover);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("visibilitychange", onVisibility);
|
||||||
|
window.removeEventListener("pageshow", recover);
|
||||||
|
window.removeEventListener("online", recover);
|
||||||
|
};
|
||||||
|
}, [catalogStore, linkStore]);
|
||||||
|
|
||||||
|
// The catalog is the authority on which sessions exist: a pane for a session
|
||||||
|
// that is no longer listed gets closed here, rather than waiting on its own
|
||||||
|
// socket to notice. Only once a list has actually landed — before that,
|
||||||
|
// "not listed" means "not known yet" and would close every restored pane.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loaded) return;
|
||||||
|
for (const id of openSessions(layout)) {
|
||||||
|
if (!sessions.has(id)) layoutStore.getState().closeSession(id);
|
||||||
|
}
|
||||||
|
}, [loaded, sessions, layout, layoutStore]);
|
||||||
|
|
||||||
|
const tab = activeTab(layout);
|
||||||
|
const focusedSessionId =
|
||||||
|
(tab.focusedLeafId && findLeaf(tab.root, tab.focusedLeafId)?.sessionId) || null;
|
||||||
|
const focusedSession = focusedSessionId ? sessions.get(focusedSessionId) : undefined;
|
||||||
|
|
||||||
|
/** Open/focus a session, settling any preview of it in place. Shared by the
|
||||||
|
* hash router and the sidebar's click-driven path (#133), which can't rely
|
||||||
|
* on `hashchange` alone — a split or drag can move focus without the hash
|
||||||
|
* changing, leaving a click back onto that row silently ignored. */
|
||||||
|
const focusSession = useCallback(
|
||||||
|
(sessionId: Uuid) => {
|
||||||
|
// `commit` cancels a pending preview and opens the session; the layout
|
||||||
|
// store settles a matching preview in place rather than moving it twice.
|
||||||
|
commitPreview(sessionId);
|
||||||
|
navigate({ kind: "attach", sessionId });
|
||||||
|
},
|
||||||
|
[commitPreview, navigate],
|
||||||
|
);
|
||||||
|
|
||||||
|
// A deep link (`#/attach/:id`) opens the session it names. Only the route
|
||||||
|
// *changing* does this: focusing a pane rewrites the hash, and re-opening
|
||||||
|
// from that would fight the layout the operator just arranged.
|
||||||
|
const lastRouteRef = useRef<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const key = route.kind === "attach" ? route.sessionId : "catalog";
|
||||||
|
if (lastRouteRef.current === key) return;
|
||||||
|
lastRouteRef.current = key;
|
||||||
|
if (route.kind === "attach") layoutStore.getState().openSession(route.sessionId);
|
||||||
|
}, [route, layoutStore]);
|
||||||
|
|
||||||
|
/** Show a freshly spawned session: it gets its own pane rather than evicting
|
||||||
|
* whatever the operator was reading. */
|
||||||
|
const mountSpawned = useCallback(
|
||||||
|
(session: Session, opts?: { newTab?: boolean }) => {
|
||||||
|
layoutStore.getState().openSessionBeside(session.id, opts);
|
||||||
|
navigate({ kind: "attach", sessionId: session.id });
|
||||||
|
requestCloseDrawer();
|
||||||
|
},
|
||||||
|
[layoutStore, navigate],
|
||||||
|
);
|
||||||
|
|
||||||
|
const openSpawn = useCallback(
|
||||||
|
(seed: SpawnSeed, newTab: boolean) => {
|
||||||
|
endPreview();
|
||||||
|
setSpawn({ seed, newTab });
|
||||||
|
},
|
||||||
|
[endPreview],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** The plain spawn: the session you were last on preselects its project, so
|
||||||
|
* `n` then Enter spawns another session there without touching the picker.
|
||||||
|
* It lands in a new tab, not a split of the pane you were reading (#100). */
|
||||||
|
const openPlainSpawn = useCallback(() => {
|
||||||
|
openSpawn(focusedSession ? spawnSimilarProjectSeed(focusedSession) : undefined, true);
|
||||||
|
}, [focusedSession, openSpawn]);
|
||||||
|
|
||||||
|
/** The focused session's project — its base repo for a worktree session, its
|
||||||
|
* cwd otherwise (the rule spawnSimilarProjectSeed uses). */
|
||||||
|
const focusedProjectCwd = focusedSession
|
||||||
|
? (focusedSession.worktree?.base_repo ?? focusedSession.cwd)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const fastForward = useFastForward({
|
||||||
|
focusedCwd: focusedProjectCwd,
|
||||||
|
onSpawned: (session) => mountSpawned(session),
|
||||||
|
onFallback: (seed) => openSpawn(seed, false),
|
||||||
|
});
|
||||||
|
|
||||||
|
const bar = useCommandBar({ onOpen: endPreview });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onOpenSpawnEvent = () => openPlainSpawn();
|
||||||
|
const onSpawnSimilar = (e: Event) => {
|
||||||
|
const s = (e as CustomEvent<Session>).detail;
|
||||||
|
if (s) openSpawn(spawnSimilarSeed(s), false);
|
||||||
|
};
|
||||||
|
const onSessionSelect = (e: Event) => focusSession((e as CustomEvent<Uuid>).detail);
|
||||||
|
document.addEventListener(OPEN_SPAWN_EVENT, onOpenSpawnEvent);
|
||||||
|
document.addEventListener(SPAWN_SIMILAR_EVENT, onSpawnSimilar);
|
||||||
|
document.addEventListener(SESSION_SELECT_EVENT, onSessionSelect);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener(OPEN_SPAWN_EVENT, onOpenSpawnEvent);
|
||||||
|
document.removeEventListener(SPAWN_SIMILAR_EVENT, onSpawnSimilar);
|
||||||
|
document.removeEventListener(SESSION_SELECT_EVENT, onSessionSelect);
|
||||||
|
};
|
||||||
|
}, [openPlainSpawn, openSpawn, focusSession]);
|
||||||
|
|
||||||
|
// Enter on a `~` row in the command bar (docs/COMMAND-BAR.md): the same
|
||||||
|
// fast-forward spawn, aimed at the chosen directory instead of the most
|
||||||
|
// recent project.
|
||||||
|
useEffect(() => {
|
||||||
|
const onSpawnDirectory = (e: Event) => {
|
||||||
|
void spawnInDirectory((e as CustomEvent<string>).detail, mountSpawned, (seed) =>
|
||||||
|
openSpawn(seed, false),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
document.addEventListener(SPAWN_DIRECTORY_EVENT, onSpawnDirectory);
|
||||||
|
return () => document.removeEventListener(SPAWN_DIRECTORY_EVENT, onSpawnDirectory);
|
||||||
|
}, [mountSpawned, openSpawn]);
|
||||||
|
|
||||||
|
// Tab in the session list hands the keyboard to the pane; leader-h hands it
|
||||||
|
// back the other way. Handing it to a previewed pane is choosing it: the
|
||||||
|
// preview settles rather than snapping back when the list blurs.
|
||||||
|
const sidebarRef = useRef<HTMLDivElement>(null);
|
||||||
|
const viewRef = useRef<MultiViewHandle>(null);
|
||||||
|
const focusPane = useCallback(() => {
|
||||||
|
if (previewedSessionId) commitPreview(previewedSessionId);
|
||||||
|
viewRef.current?.focusPane();
|
||||||
|
}, [previewedSessionId, commitPreview]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.addEventListener(FOCUS_PANE_EVENT, focusPane);
|
||||||
|
return () => document.removeEventListener(FOCUS_PANE_EVENT, focusPane);
|
||||||
|
}, [focusPane]);
|
||||||
|
|
||||||
|
// The global half of the command registry (#148). `n` opens the spawn modal
|
||||||
|
// and `N` fast-forwards a spawn (#67); both are plain keys, so the set's own
|
||||||
|
// `isTypingTarget` keeps them out of form fields and the terminal's hidden
|
||||||
|
// textarea. Leader-h moves the keyboard between the list and the pane — a
|
||||||
|
// chord, because an attached terminal owns every plain keystroke
|
||||||
|
// (docs/COMMAND-BAR.md).
|
||||||
|
useEffect(() => {
|
||||||
|
if (bar.open || spawn) return;
|
||||||
|
const spawnKeys = new CommandSet(
|
||||||
|
defineCommands({
|
||||||
|
"spawn.open": openPlainSpawn,
|
||||||
|
"spawn.fastForward": () => void fastForward(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const focusKeys = new CommandSet(
|
||||||
|
defineCommands(
|
||||||
|
{
|
||||||
|
"focus.toggle": () => {
|
||||||
|
if (sidebarRef.current?.contains(document.activeElement)) {
|
||||||
|
focusPane();
|
||||||
|
} else {
|
||||||
|
// No-op on desktop; on mobile the list is off-canvas until the
|
||||||
|
// drawer opens, so the focus has somewhere to land.
|
||||||
|
requestToggleDrawer();
|
||||||
|
sidebarRef.current?.querySelector<HTMLElement>("[tabindex]")?.focus();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// The escape hatch: binding ctrl-k takes readline's
|
||||||
|
// kill-to-end-of-line away from every pane, and leader ctrl-k gives
|
||||||
|
// it back as the literal 0x0B byte (docs/COMMAND-BAR.md).
|
||||||
|
"pane.literalCtrlK": () => viewRef.current?.sendInput("\x0b"),
|
||||||
|
// The leader pressed twice, tmux-style: one literal leader byte,
|
||||||
|
// built from the leader itself so a rebound one sends its own (#158).
|
||||||
|
"pane.literalLeader": () => viewRef.current?.sendInput(controlByte(leader)),
|
||||||
|
},
|
||||||
|
leader,
|
||||||
|
),
|
||||||
|
{ leader },
|
||||||
|
);
|
||||||
|
const onKeydown = (e: KeyboardEvent) => {
|
||||||
|
if (focusKeys.handle(e)) return;
|
||||||
|
if (isBarTypingTarget(e.target)) return;
|
||||||
|
spawnKeys.handle(e);
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKeydown, true);
|
||||||
|
return () => window.removeEventListener("keydown", onKeydown, true);
|
||||||
|
}, [bar.open, spawn, openPlainSpawn, fastForward, focusPane, leader]);
|
||||||
|
|
||||||
|
const openIds = useMemo(() => openSessions(layout), [layout]);
|
||||||
|
const barSessions = useMemo(() => [...sessions.values()], [sessions]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Drawer
|
||||||
|
sidebar={
|
||||||
|
<div ref={sidebarRef} className="contents">
|
||||||
|
<Sidebar
|
||||||
|
store={catalogStore}
|
||||||
|
homeDir={homeDir}
|
||||||
|
activeId={focusedSessionId}
|
||||||
|
openIds={openIds}
|
||||||
|
previewId={previewedSessionId}
|
||||||
|
linkStore={linkStore}
|
||||||
|
onSelect={(id) => focusSession(id)}
|
||||||
|
onPreview={preview}
|
||||||
|
onSpawn={openPlainSpawn}
|
||||||
|
onSpawnDefaults={() => void fastForward()}
|
||||||
|
onSpawnSimilar={(s) => openSpawn(spawnSimilarSeed(s), false)}
|
||||||
|
onFocusPane={focusPane}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MultiView
|
||||||
|
ref={viewRef}
|
||||||
|
store={layoutStore}
|
||||||
|
// Undefined until the first list lands: MultiView's boot
|
||||||
|
// reconciliation prunes against it, and an empty map would take
|
||||||
|
// every restored pane with it.
|
||||||
|
sessions={loaded ? sessions : undefined}
|
||||||
|
onDisconnect={(id) => layoutStore.getState().closeSession(id)}
|
||||||
|
emptyState={<Welcome onSpawn={requestOpenSpawn} />}
|
||||||
|
/>
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
<DaemonLink store={linkStore} connected={connected} />
|
||||||
|
<RestartOverlay store={linkStore} />
|
||||||
|
<HelpOverlay />
|
||||||
|
|
||||||
|
{bar.open && (
|
||||||
|
<CommandBar
|
||||||
|
sessions={barSessions}
|
||||||
|
homeDir={homeDir}
|
||||||
|
onOpenSession={(id) => {
|
||||||
|
bar.closeBar();
|
||||||
|
focusSession(id);
|
||||||
|
}}
|
||||||
|
onClose={bar.closeBar}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{spawn && (
|
||||||
|
<SpawnModal
|
||||||
|
seed={spawn.seed}
|
||||||
|
onSuccess={(session) => mountSpawned(session, { newTab: spawn.newTab })}
|
||||||
|
onClose={() => setSpawn(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function spawnPresets(): Promise<SpawnPreset[]> {
|
||||||
|
try {
|
||||||
|
const info = await getDaemonInfo();
|
||||||
|
if (info.presets?.length) return info.presets;
|
||||||
|
} catch {
|
||||||
|
// keep fallbacks
|
||||||
|
}
|
||||||
|
const { FALLBACK_PRESETS } = await import("./components/spawn/model.js");
|
||||||
|
return FALLBACK_PRESETS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A directory the project scan never saw stands in as its own project; one
|
||||||
|
* outside any repo spawns without a worktree, since there is no repo to cut a
|
||||||
|
* branch from. Anything still unresolved opens the pre-filled form. */
|
||||||
|
async function spawnInDirectory(
|
||||||
|
path: string,
|
||||||
|
onSpawned: (session: Session) => void,
|
||||||
|
onFallback: (seed: SpawnSeed) => void,
|
||||||
|
): Promise<void> {
|
||||||
|
const [discovered, handle] = await Promise.all([
|
||||||
|
fetchProjects().catch(() => [] as Project[]),
|
||||||
|
fetchNames(1)
|
||||||
|
.then((batch) => batch.names[0])
|
||||||
|
.catch(() => undefined),
|
||||||
|
]);
|
||||||
|
const presets = await spawnPresets();
|
||||||
|
const known = discovered.find((p) => p.path === path);
|
||||||
|
const project: Project = known ?? { path, name: path.split("/").filter(Boolean).pop() ?? path };
|
||||||
|
const resolved = resolveFastForward([project], presets, handle, path);
|
||||||
|
const req = buildFastForwardRequest(resolved);
|
||||||
|
if (req) {
|
||||||
|
const inRepo =
|
||||||
|
known !== undefined ||
|
||||||
|
(await gitRoot(path)
|
||||||
|
.then((r) => !!r.root)
|
||||||
|
.catch(() => false));
|
||||||
|
if (!inRepo) delete req.worktree;
|
||||||
|
try {
|
||||||
|
onSpawned(await createSession(req));
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// A failed POST degrades to the pre-filled form, never a dead row.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onFallback(fastForwardSeed(resolved));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The content pane with nothing open. The ☰ is mobile-only: with no pane
|
||||||
|
* there is no pane header to carry it, so the drawer would be swipe-only. */
|
||||||
|
function Welcome({ onSpawn }: { onSpawn(): void }) {
|
||||||
|
return (
|
||||||
|
<div className="empty content-welcome">
|
||||||
|
<div className="empty-glyph" aria-hidden="true">
|
||||||
|
⌘
|
||||||
|
</div>
|
||||||
|
<div className="empty-title">No session open</div>
|
||||||
|
<p className="empty-hint">
|
||||||
|
Pick a session from the list, or start a new one. Open sessions keep streaming in the
|
||||||
|
background — switching back is instant.
|
||||||
|
</p>
|
||||||
|
<p className="empty-hint">
|
||||||
|
Press <kbd>?</kbd> for keyboard shortcuts.
|
||||||
|
</p>
|
||||||
|
<div className="empty-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost welcome-sessions"
|
||||||
|
onClick={() => requestToggleDrawer()}
|
||||||
|
>
|
||||||
|
☰ Sessions
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
title="Draft pane: n — fast-forward spawn: N"
|
||||||
|
onClick={onSpawn}
|
||||||
|
>
|
||||||
|
+ Spawn session
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,659 +0,0 @@
|
|||||||
import { FitAddon } from "@xterm/addon-fit";
|
|
||||||
import { WebglAddon } from "@xterm/addon-webgl";
|
|
||||||
import { Terminal } from "@xterm/xterm";
|
|
||||||
import "@xterm/xterm/css/xterm.css";
|
|
||||||
|
|
||||||
import { attachWsUrl } from "./routes.js";
|
|
||||||
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js";
|
|
||||||
import { applyModsToChar, cursorSeq, tildeSeq } from "./keys.js";
|
|
||||||
import { requestToggleDrawer } from "./drawer.js";
|
|
||||||
import { paneTitleAttr, paneTitleHtml, type AttachInfo } from "./paneTitle.js";
|
|
||||||
import type { PaneConnection } from "./pane.js";
|
|
||||||
import type { ClientControl, Uuid } from "./types.js";
|
|
||||||
import { WebglPolicy } from "./webglRenderer.js";
|
|
||||||
|
|
||||||
export type { AttachInfo } from "./paneTitle.js";
|
|
||||||
|
|
||||||
/** One budget for the whole page — see webglRenderer.ts for why contexts are
|
|
||||||
* rationed rather than handed to every connection. */
|
|
||||||
const webglPolicy = new WebglPolicy();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A single live attach: an xterm terminal bound to the daemon's attach WebSocket.
|
|
||||||
*
|
|
||||||
* The connection is **decoupled from the UI**: it owns a long-lived WS that
|
|
||||||
* keeps streaming PTY output into the terminal buffer whether or not the view
|
|
||||||
* is currently mounted. The shell moves `el` between the visible content pane
|
|
||||||
* and a hidden cache; `onMount`/`onUnmount` only adjust focus + sizing, never
|
|
||||||
* the connection. Only `dispose()` (eviction, an explicit disconnect, or the
|
|
||||||
* session ending) tears the socket down — so switching away and back is
|
|
||||||
* instant, with scrollback already in place.
|
|
||||||
*/
|
|
||||||
export interface AttachConnection extends PaneConnection {}
|
|
||||||
|
|
||||||
export interface AttachOptions {
|
|
||||||
/** Invoked when the user clicks the view's explicit "disconnect" control. */
|
|
||||||
onRequestClose?: (sessionId: Uuid) => void;
|
|
||||||
/** Invoked when the socket closes on its own — the session ended or the link
|
|
||||||
* dropped — but NOT on an intentional `dispose()` (eviction / user close). The
|
|
||||||
* shell uses this to auto-close the pane rather than leave a dead one. */
|
|
||||||
onClosed?: (sessionId: Uuid) => void;
|
|
||||||
/** Asked on every close: should the socket be retried? The shell answers
|
|
||||||
* from the catalog — see `shouldRetryClose`. */
|
|
||||||
shouldReconnect?: (sessionId: Uuid, code: number) => boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createAttachConnection(
|
|
||||||
sessionId: Uuid,
|
|
||||||
opts: AttachOptions = {},
|
|
||||||
info?: AttachInfo,
|
|
||||||
): AttachConnection {
|
|
||||||
const el = document.createElement("div");
|
|
||||||
el.className = "attach";
|
|
||||||
// Useful identity over a bare UUID: name · command. The full id stays in the
|
|
||||||
// tooltip for when you really need it.
|
|
||||||
const titleHtml = paneTitleHtml(sessionId, info);
|
|
||||||
const titleAttr = paneTitleAttr(sessionId, info);
|
|
||||||
el.innerHTML = `
|
|
||||||
<header class="attach-header">
|
|
||||||
<button type="button" class="btn btn-ghost btn-icon sessions-btn" aria-label="Show sessions" title="Show sessions">☰</button>
|
|
||||||
<span class="attach-title" title="${titleAttr}">${titleHtml}</span>
|
|
||||||
<span class="attach-state" data-state="connecting">connecting…</span>
|
|
||||||
<button class="btn btn-ghost btn-icon disconnect" type="button" aria-label="Disconnect" title="Disconnect this session">⏻</button>
|
|
||||||
</header>
|
|
||||||
<div class="attach-body"></div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const titleEl = el.querySelector(".attach-title") as HTMLSpanElement;
|
|
||||||
const body = el.querySelector(".attach-body") as HTMLDivElement;
|
|
||||||
const stateEl = el.querySelector(".attach-state") as HTMLSpanElement;
|
|
||||||
const disconnectBtn = el.querySelector(".disconnect") as HTMLButtonElement;
|
|
||||||
|
|
||||||
const keybar = buildKeyBar((data) => sendInput(data));
|
|
||||||
el.appendChild(keybar.el);
|
|
||||||
|
|
||||||
const setState = (s: string, kind: "connecting" | "open" | "closed" | "error") => {
|
|
||||||
stateEl.textContent = s;
|
|
||||||
stateEl.dataset.state = kind;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mobile-only (CSS hides it on desktop, where the sidebar is always in view):
|
|
||||||
// the session list is an off-canvas drawer and this is what opens it.
|
|
||||||
(el.querySelector(".sessions-btn") as HTMLElement).addEventListener("click", () =>
|
|
||||||
requestToggleDrawer(),
|
|
||||||
);
|
|
||||||
|
|
||||||
disconnectBtn.addEventListener("click", () => {
|
|
||||||
opts.onRequestClose?.(sessionId);
|
|
||||||
});
|
|
||||||
|
|
||||||
const term = new Terminal({
|
|
||||||
cursorBlink: true,
|
|
||||||
fontFamily:
|
|
||||||
'"JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
|
|
||||||
fontSize: 13,
|
|
||||||
convertEol: false,
|
|
||||||
theme: {
|
|
||||||
// The terminal stays dark in both app themes (convention); colors are
|
|
||||||
// tuned to the maestro palette — signal-green cursor, slate background.
|
|
||||||
background: "#121419",
|
|
||||||
foreground: "#e6e8ec",
|
|
||||||
cursor: "#5fe3a1",
|
|
||||||
cursorAccent: "#121419",
|
|
||||||
selectionBackground: "#2b6b4f",
|
|
||||||
},
|
|
||||||
scrollback: 5000,
|
|
||||||
allowProposedApi: true,
|
|
||||||
});
|
|
||||||
const fit = new FitAddon();
|
|
||||||
term.loadAddon(fit);
|
|
||||||
term.open(body);
|
|
||||||
|
|
||||||
// GPU rendering is scoped to the mounted view, not the connection: acquired
|
|
||||||
// in onMount, released in onUnmount. See webglRenderer.ts.
|
|
||||||
let webgl: WebglAddon | null = null;
|
|
||||||
let dprMedia: MediaQueryList | null = null;
|
|
||||||
|
|
||||||
// The glyph atlas is rasterised for one device pixel ratio. Dragging the
|
|
||||||
// window to a display with a different one (or an Android browser-zoom
|
|
||||||
// change) leaves every cell blurry until the atlas is rebuilt.
|
|
||||||
const onDprChange = () => {
|
|
||||||
try {
|
|
||||||
term.clearTextureAtlas();
|
|
||||||
} catch {
|
|
||||||
// ignore — nothing to rebuild if the renderer already went away
|
|
||||||
}
|
|
||||||
watchDpr();
|
|
||||||
};
|
|
||||||
|
|
||||||
function watchDpr(): void {
|
|
||||||
dprMedia?.removeEventListener("change", onDprChange);
|
|
||||||
dprMedia = null;
|
|
||||||
try {
|
|
||||||
dprMedia = window.matchMedia?.(`(resolution: ${window.devicePixelRatio}dppx)`) ?? null;
|
|
||||||
dprMedia?.addEventListener("change", onDprChange);
|
|
||||||
} catch {
|
|
||||||
// ignore — a browser without `resolution` media queries just keeps the
|
|
||||||
// atlas it has, which is the pre-WebGL behaviour anyway
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function releaseWebgl(): void {
|
|
||||||
dprMedia?.removeEventListener("change", onDprChange);
|
|
||||||
dprMedia = null;
|
|
||||||
if (!webgl) return;
|
|
||||||
const addon = webgl;
|
|
||||||
webgl = null;
|
|
||||||
try {
|
|
||||||
addon.dispose(); // xterm falls back to the DOM renderer
|
|
||||||
} catch {
|
|
||||||
// ignore — a lost context can throw on the way out
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function acquireWebgl(): void {
|
|
||||||
if (webgl || disposed || !webglPolicy.enabled()) return;
|
|
||||||
try {
|
|
||||||
const addon = new WebglAddon();
|
|
||||||
// Context loss is routine on Android — backgrounding the tab is enough.
|
|
||||||
// Drop back to the DOM renderer, then retry only while the shared budget
|
|
||||||
// allows and this view is still the one on screen.
|
|
||||||
addon.onContextLoss(() => {
|
|
||||||
webglPolicy.noteContextLoss();
|
|
||||||
releaseWebgl();
|
|
||||||
if (mounted) acquireWebgl();
|
|
||||||
});
|
|
||||||
term.loadAddon(addon);
|
|
||||||
webgl = addon;
|
|
||||||
watchDpr();
|
|
||||||
} catch {
|
|
||||||
// Context creation failed outright (no GPU, or the browser is already at
|
|
||||||
// its context cap). Count it like a loss so we stop asking, and stay on
|
|
||||||
// the DOM renderer.
|
|
||||||
webglPolicy.noteContextLoss();
|
|
||||||
webgl = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Touch scrolling: xterm's built-in touch handling is jittery (drags crawl a
|
|
||||||
// line at a time and stall), so map finger drags ourselves. Two routes:
|
|
||||||
// • normal buffer → term.scrollLines walks the scrollback.
|
|
||||||
// • mouse-tracking apps (tmux, Claude Code on the alt screen) → synthesize
|
|
||||||
// wheel events so xterm encodes them as mouse-wheel reports the app reads,
|
|
||||||
// scrolling *its* content (the only scroll that exists on the alt screen).
|
|
||||||
// • alt screen with no mouse tracking → nothing to do; the app owns input.
|
|
||||||
// `touch-action: none` (CSS) keeps the browser from cancelling the custom
|
|
||||||
// gesture stream; only the terminal surface opts out of native page panning.
|
|
||||||
// Listeners MUST sit on `.xterm`, not `.xterm-viewport`: `.xterm-screen` is a
|
|
||||||
// later sibling of the viewport, so it paints over it and takes every touch.
|
|
||||||
{
|
|
||||||
const root = term.element as HTMLElement | undefined;
|
|
||||||
const screen = el.querySelector(".xterm-screen") as HTMLElement | null;
|
|
||||||
const cellH = () => {
|
|
||||||
const core = (term as unknown as {
|
|
||||||
_core?: { _renderService?: { dimensions?: { css?: { cell?: { height: number } } } } };
|
|
||||||
})._core;
|
|
||||||
return core?._renderService?.dimensions?.css?.cell?.height || 16;
|
|
||||||
};
|
|
||||||
if (root) {
|
|
||||||
let lastY = 0;
|
|
||||||
let lastX = 0;
|
|
||||||
let remainder = 0;
|
|
||||||
root.addEventListener(
|
|
||||||
"touchstart",
|
|
||||||
(e) => {
|
|
||||||
if (e.touches.length !== 1) return;
|
|
||||||
lastY = e.touches[0].clientY;
|
|
||||||
lastX = e.touches[0].clientX;
|
|
||||||
remainder = 0;
|
|
||||||
},
|
|
||||||
{ passive: true },
|
|
||||||
);
|
|
||||||
root.addEventListener(
|
|
||||||
"touchmove",
|
|
||||||
(e) => {
|
|
||||||
if (e.touches.length !== 1) return;
|
|
||||||
const ch = cellH();
|
|
||||||
const y = e.touches[0].clientY;
|
|
||||||
const px = lastY - y + remainder; // finger up → scroll toward newer
|
|
||||||
lastY = y;
|
|
||||||
const lines = Math.trunc(px / ch);
|
|
||||||
remainder = px - lines * ch;
|
|
||||||
if (lines === 0) return;
|
|
||||||
// term.modes.mouseTrackingMode is "none" unless the app turned mouse
|
|
||||||
// reporting on — that's the signal to forward instead of scrollback.
|
|
||||||
const mouseTracked = term.modes.mouseTrackingMode !== "none";
|
|
||||||
if (mouseTracked && screen) {
|
|
||||||
// One wheel event per crossed cell, dispatched at the touch point so
|
|
||||||
// xterm computes valid cell coords for the mouse report.
|
|
||||||
const dir = lines > 0 ? 1 : -1;
|
|
||||||
for (let i = 0; i < Math.abs(lines); i++) {
|
|
||||||
screen.dispatchEvent(
|
|
||||||
new WheelEvent("wheel", {
|
|
||||||
deltaY: dir * ch,
|
|
||||||
deltaMode: 0,
|
|
||||||
clientX: lastX,
|
|
||||||
clientY: y,
|
|
||||||
bubbles: true,
|
|
||||||
cancelable: true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
e.preventDefault();
|
|
||||||
} else if (term.buffer.active.type !== "alternate") {
|
|
||||||
term.scrollLines(lines);
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ passive: false },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mobile: the special-key bar is only useful while the soft keyboard is up,
|
|
||||||
// so reveal it only when the terminal's hidden textarea is focused (CSS still
|
|
||||||
// gates it to coarse pointers). Tapping a keycap keeps focus via pointerdown
|
|
||||||
// preventDefault, so the bar stays put during use. Re-fit on the toggle since
|
|
||||||
// showing/hiding the bar changes the terminal's available height.
|
|
||||||
const ta = term.textarea;
|
|
||||||
if (ta) {
|
|
||||||
ta.addEventListener("focus", () => {
|
|
||||||
keybar.el.classList.add("keybar-active");
|
|
||||||
requestAnimationFrame(tryFit);
|
|
||||||
});
|
|
||||||
ta.addEventListener("blur", () => {
|
|
||||||
keybar.el.classList.remove("keybar-active");
|
|
||||||
requestAnimationFrame(tryFit);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let mounted = false;
|
|
||||||
// A previewed pane is visible but must not steal the keyboard from the
|
|
||||||
// sidebar, so mounting can opt out of the focus grab (and out of the one
|
|
||||||
// the socket does when it finishes connecting).
|
|
||||||
let focusOnMount = true;
|
|
||||||
// We compute the fit ourselves instead of FitAddon.fit(): the addon subtracts
|
|
||||||
// a scrollbar width that xterm reports as `offsetWidth - scrollArea || 15`,
|
|
||||||
// so with the scrollbar hidden it falls back to a phantom 15px and the grid
|
|
||||||
// stops ~1–2 columns short of the edge. Same math, minus that subtraction.
|
|
||||||
const tryFit = () => {
|
|
||||||
if (!mounted) return; // parked views have no meaningful size to fit to
|
|
||||||
try {
|
|
||||||
const el2 = term.element;
|
|
||||||
const parent = el2?.parentElement;
|
|
||||||
const core = (term as unknown as {
|
|
||||||
_core?: { _renderService?: { dimensions?: { css?: { cell?: { width: number; height: number } } } } };
|
|
||||||
})._core;
|
|
||||||
const cell = core?._renderService?.dimensions?.css?.cell;
|
|
||||||
if (!el2 || !parent || !cell || !cell.width || !cell.height) {
|
|
||||||
fit.fit(); // fall back if internals aren't ready yet
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Measure the viewport's real inner width (clientWidth excludes any
|
|
||||||
// scrollbar), not the container's — so columns always fit inside the
|
|
||||||
// viewport whether or not xterm reserves a scrollbar. Falls back to the
|
|
||||||
// padded container box before the viewport exists.
|
|
||||||
const vp = el2.querySelector(".xterm-viewport") as HTMLElement | null;
|
|
||||||
let availW: number;
|
|
||||||
let availH: number;
|
|
||||||
if (vp && vp.clientWidth > 0) {
|
|
||||||
availW = vp.clientWidth;
|
|
||||||
availH = vp.clientHeight;
|
|
||||||
} else {
|
|
||||||
const ps = getComputedStyle(parent);
|
|
||||||
const ts = getComputedStyle(el2);
|
|
||||||
availW =
|
|
||||||
parseInt(ps.width) -
|
|
||||||
(parseInt(ts.paddingLeft) + parseInt(ts.paddingRight) || 0);
|
|
||||||
availH =
|
|
||||||
parseInt(ps.height) -
|
|
||||||
(parseInt(ts.paddingTop) + parseInt(ts.paddingBottom) || 0);
|
|
||||||
}
|
|
||||||
const cols = Math.max(2, Math.floor(availW / cell.width));
|
|
||||||
const rows = Math.max(1, Math.floor(availH / cell.height));
|
|
||||||
if (cols !== term.cols || rows !== term.rows) term.resize(cols, rows);
|
|
||||||
} catch {
|
|
||||||
try {
|
|
||||||
fit.fit();
|
|
||||||
} catch {
|
|
||||||
// ignore — fit fails if the container is detached or zero-sized
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let ws: WebSocket;
|
|
||||||
let reconnectMs = INITIAL_RECONNECT_MS;
|
|
||||||
let reconnectTimer: number | null = null;
|
|
||||||
// Snapshot replay starts at row zero, which can leave Codex at its oldest line.
|
|
||||||
// Follow it once per socket; later scrolling remains under the operator's control.
|
|
||||||
let awaitingReplay = true;
|
|
||||||
|
|
||||||
const sendControl = (msg: ClientControl) => {
|
|
||||||
if (ws.readyState !== WebSocket.OPEN) return;
|
|
||||||
ws.send(JSON.stringify(msg));
|
|
||||||
};
|
|
||||||
|
|
||||||
/** PTY input: raw bytes out as a binary frame. Shared by keystrokes and the
|
|
||||||
* mobile key bar so both flow through the same path. */
|
|
||||||
const sendInput = (data: string) => {
|
|
||||||
if (ws.readyState !== WebSocket.OPEN) return;
|
|
||||||
ws.send(new TextEncoder().encode(data));
|
|
||||||
};
|
|
||||||
|
|
||||||
const onResize = () => {
|
|
||||||
tryFit();
|
|
||||||
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
|
|
||||||
};
|
|
||||||
|
|
||||||
function connect(): void {
|
|
||||||
awaitingReplay = true;
|
|
||||||
ws = new WebSocket(attachWsUrl(sessionId));
|
|
||||||
ws.binaryType = "arraybuffer";
|
|
||||||
|
|
||||||
ws.onopen = () => {
|
|
||||||
reconnectMs = INITIAL_RECONNECT_MS;
|
|
||||||
setState("connected", "open");
|
|
||||||
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
|
|
||||||
if (mounted && focusOnMount) term.focus();
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onmessage = (ev) => {
|
|
||||||
if (typeof ev.data === "string") {
|
|
||||||
try {
|
|
||||||
JSON.parse(ev.data) as ClientControl;
|
|
||||||
} catch {
|
|
||||||
// ignore unknown control frames
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (ev.data instanceof ArrayBuffer) {
|
|
||||||
const replay = awaitingReplay;
|
|
||||||
awaitingReplay = false;
|
|
||||||
term.write(new Uint8Array(ev.data), () => {
|
|
||||||
if (replay) term.scrollToBottom();
|
|
||||||
});
|
|
||||||
} else if (ev.data instanceof Blob) {
|
|
||||||
// Some browsers may default to Blob despite binaryType="arraybuffer".
|
|
||||||
void ev.data.arrayBuffer().then((b) => {
|
|
||||||
const replay = awaitingReplay;
|
|
||||||
awaitingReplay = false;
|
|
||||||
term.write(new Uint8Array(b), () => {
|
|
||||||
if (replay) term.scrollToBottom();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onerror = () => {
|
|
||||||
setState("error", "error");
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onclose = (ev) => {
|
|
||||||
// The web proxy relays a daemon rejection (unknown session) as an
|
|
||||||
// application close code (4000+) carrying a human reason. Show it in the
|
|
||||||
// pane — both the header and the terminal body — and KEEP the pane up so
|
|
||||||
// the user can read why, instead of letting it flash and vanish (#39).
|
|
||||||
const reason = ev.reason?.trim();
|
|
||||||
if (ev.code >= 4000 && reason && !disposed) {
|
|
||||||
setState(reason, "error");
|
|
||||||
term.write(`\r\n\x1b[31m⚠ cannot attach: ${reason}\x1b[0m\r\n`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// An intentional dispose has already set `disposed`; eviction and user
|
|
||||||
// close handle their own pane teardown.
|
|
||||||
if (disposed) return;
|
|
||||||
|
|
||||||
if (opts.shouldReconnect?.(sessionId, ev.code)) {
|
|
||||||
const delay = Math.min(reconnectMs, MAX_RECONNECT_MS);
|
|
||||||
setState("reconnecting…", "connecting");
|
|
||||||
reconnectTimer = window.setTimeout(() => {
|
|
||||||
reconnectTimer = null;
|
|
||||||
reconnectMs = Math.min(reconnectMs * 2, MAX_RECONNECT_MS);
|
|
||||||
connect();
|
|
||||||
}, delay);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setState(`closed (${ev.code})`, "closed");
|
|
||||||
opts.onClosed?.(sessionId);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
connect();
|
|
||||||
|
|
||||||
// PTY input: every keystroke goes out as a binary frame. A pending key-bar
|
|
||||||
// Ctrl modifier (mobile) folds into the next typed character here.
|
|
||||||
const dataDisp = term.onData((data) => {
|
|
||||||
sendInput(keybar.consume(data));
|
|
||||||
});
|
|
||||||
const resizeDisp = term.onResize(({ cols, rows }) => {
|
|
||||||
sendControl({ type: "Resize", cols, rows });
|
|
||||||
});
|
|
||||||
// xterm has no built-in copy-on-select; mirror the selection to the
|
|
||||||
// clipboard ourselves (best-effort — insecure contexts lack the API).
|
|
||||||
const selectionDisp = term.onSelectionChange(() => {
|
|
||||||
const text = term.getSelection();
|
|
||||||
if (text) void navigator.clipboard?.writeText(text).catch(() => {});
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener("resize", onResize);
|
|
||||||
const visual = window.visualViewport ?? null;
|
|
||||||
visual?.addEventListener("resize", onResize);
|
|
||||||
|
|
||||||
// FitAddon only recomputes on the events we call it from — a window resize
|
|
||||||
// misses container-driven changes (sidebar drag, mobile layout settling,
|
|
||||||
// orientation, late font load). Observe the body directly so columns always
|
|
||||||
// match the real rendered width and the terminal fills edge to edge.
|
|
||||||
const ro = new ResizeObserver(() => tryFit());
|
|
||||||
ro.observe(body);
|
|
||||||
// Re-fit once the monospace font is actually loaded — cell width changes when
|
|
||||||
// the fallback is swapped out, which otherwise leaves stale columns.
|
|
||||||
document.fonts?.ready?.then(() => tryFit()).catch(() => {});
|
|
||||||
|
|
||||||
let disposed = false;
|
|
||||||
|
|
||||||
return {
|
|
||||||
sessionId,
|
|
||||||
el,
|
|
||||||
onMount(mountOpts?: { focus?: boolean }) {
|
|
||||||
mounted = true;
|
|
||||||
focusOnMount = mountOpts?.focus !== false;
|
|
||||||
acquireWebgl();
|
|
||||||
// Defer a tick so the element has its content-pane dimensions before fit.
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
tryFit();
|
|
||||||
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
|
|
||||||
if (focusOnMount && ws.readyState === WebSocket.OPEN) term.focus();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onUnmount() {
|
|
||||||
mounted = false;
|
|
||||||
// Parked views hand their GPU context back: the browser's cap is small
|
|
||||||
// (smaller still on Android) and MAX_PARKED connections outlive the pane.
|
|
||||||
releaseWebgl();
|
|
||||||
},
|
|
||||||
focusTerminal() {
|
|
||||||
term.focus();
|
|
||||||
},
|
|
||||||
// The closure above: the same bytes-out path keystrokes and the mobile key
|
|
||||||
// bar already take.
|
|
||||||
sendInput,
|
|
||||||
updateInfo(next: AttachInfo) {
|
|
||||||
titleEl.innerHTML = paneTitleHtml(sessionId, next);
|
|
||||||
titleEl.title = paneTitleAttr(sessionId, next);
|
|
||||||
},
|
|
||||||
dispose() {
|
|
||||||
if (disposed) return;
|
|
||||||
disposed = true;
|
|
||||||
if (reconnectTimer !== null) {
|
|
||||||
window.clearTimeout(reconnectTimer);
|
|
||||||
reconnectTimer = null;
|
|
||||||
}
|
|
||||||
ro.disconnect();
|
|
||||||
window.removeEventListener("resize", onResize);
|
|
||||||
visual?.removeEventListener("resize", onResize);
|
|
||||||
dataDisp.dispose();
|
|
||||||
resizeDisp.dispose();
|
|
||||||
selectionDisp.dispose();
|
|
||||||
releaseWebgl();
|
|
||||||
try {
|
|
||||||
ws.close();
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
term.dispose();
|
|
||||||
el.remove();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One key in the mobile key bar.
|
|
||||||
* - `mod` is a sticky one-shot modifier toggle (Ctrl / Alt).
|
|
||||||
* - `cursor` is a cursor/navigation key, identified by its CSI final byte, so a
|
|
||||||
* pending modifier can promote it to the `\x1b[1;<n><final>` form.
|
|
||||||
* - `tilde` is a `~`-terminated navigation key (PgUp/PgDn), identified by its
|
|
||||||
* CSI parameter, whose modified form is `\x1b[<code>;<n>~`.
|
|
||||||
* - `char` is a literal character (the symbols the soft keyboard buries behind
|
|
||||||
* a page switch), folded through a pending modifier like typed input.
|
|
||||||
* - `seq` is a plain key (Esc, Tab) sent verbatim.
|
|
||||||
*/
|
|
||||||
type KeyDef =
|
|
||||||
| { label: string; mod: Modifier }
|
|
||||||
| { label: string; cursor: CursorFinal }
|
|
||||||
| { label: string; tilde: number }
|
|
||||||
| { label: string; char: string }
|
|
||||||
| { label: string; seq: string };
|
|
||||||
|
|
||||||
type Modifier = "ctrl" | "alt";
|
|
||||||
/** CSI final bytes: arrows A–D, Home `H`, End `F`. */
|
|
||||||
type CursorFinal = "A" | "B" | "C" | "D" | "H" | "F";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The keys the mobile soft keyboard hides, ordered to flow row-major into an
|
|
||||||
* 8-column / 2-row grid (#7):
|
|
||||||
*
|
|
||||||
* Esc Tab Home ↑ End PgUp - /
|
|
||||||
* Ctrl Alt ← ↓ → PgDn | ~
|
|
||||||
*
|
|
||||||
* — Esc/Tab + the Ctrl/Alt sticky modifiers on the left, the cursor keys as a
|
|
||||||
* real-keyboard inverted-T in the middle with Home/End filling its top corners,
|
|
||||||
* then the paging pair and the shell symbols that cost a soft-keyboard page
|
|
||||||
* switch to reach. Narrower screens scroll the bar sideways rather than
|
|
||||||
* shrinking the caps below a thumb's width.
|
|
||||||
*/
|
|
||||||
const KEY_DEFS: KeyDef[] = [
|
|
||||||
{ label: "Esc", seq: "\x1b" },
|
|
||||||
{ label: "Tab", seq: "\t" },
|
|
||||||
{ label: "Home", cursor: "H" },
|
|
||||||
{ label: "↑", cursor: "A" },
|
|
||||||
{ label: "End", cursor: "F" },
|
|
||||||
{ label: "PgUp", tilde: 5 },
|
|
||||||
{ label: "-", char: "-" },
|
|
||||||
{ label: "/", char: "/" },
|
|
||||||
{ label: "Ctrl", mod: "ctrl" },
|
|
||||||
{ label: "Alt", mod: "alt" },
|
|
||||||
{ label: "←", cursor: "D" },
|
|
||||||
{ label: "↓", cursor: "B" },
|
|
||||||
{ label: "→", cursor: "C" },
|
|
||||||
{ label: "PgDn", tilde: 6 },
|
|
||||||
{ label: "|", char: "|" },
|
|
||||||
{ label: "~", char: "~" },
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fold a single character into its control code: letters and `@[\]^_` map to
|
|
||||||
* 0x00–0x1f, space → NUL, `?` → DEL. Anything else is returned unchanged.
|
|
||||||
*/
|
|
||||||
interface KeyBar {
|
|
||||||
readonly el: HTMLElement;
|
|
||||||
/** Fold pending modifiers into typed input (soft keyboard), consuming them. */
|
|
||||||
consume(data: string): string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the Termux-style special-key bar. Hidden by default; CSS reveals it on
|
|
||||||
* coarse-pointer (touch) devices. `send` delivers bytes to the PTY.
|
|
||||||
*
|
|
||||||
* Ctrl and Alt are one-shot sticky modifiers: arm them and the very next key —
|
|
||||||
* tapped here or typed on the soft keyboard — carries the modifier, then they
|
|
||||||
* disarm. Taps `preventDefault` on pointerdown so the terminal keeps focus and
|
|
||||||
* the keyboard stays open.
|
|
||||||
*/
|
|
||||||
function buildKeyBar(send: (data: string) => void): KeyBar {
|
|
||||||
const el = document.createElement("div");
|
|
||||||
el.className = "attach-keybar";
|
|
||||||
el.setAttribute("role", "toolbar");
|
|
||||||
el.setAttribute("aria-label", "Special keys");
|
|
||||||
|
|
||||||
const armed: Record<Modifier, boolean> = { ctrl: false, alt: false };
|
|
||||||
const modBtns: Partial<Record<Modifier, HTMLButtonElement>> = {};
|
|
||||||
|
|
||||||
const setMod = (mod: Modifier, on: boolean) => {
|
|
||||||
armed[mod] = on;
|
|
||||||
modBtns[mod]?.setAttribute("aria-pressed", String(on));
|
|
||||||
};
|
|
||||||
const clearMods = () => {
|
|
||||||
setMod("ctrl", false);
|
|
||||||
setMod("alt", false);
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const def of KEY_DEFS) {
|
|
||||||
const btn = document.createElement("button");
|
|
||||||
btn.type = "button";
|
|
||||||
btn.className = "keycap";
|
|
||||||
btn.textContent = def.label;
|
|
||||||
|
|
||||||
if ("mod" in def) {
|
|
||||||
btn.classList.add("keycap-mod");
|
|
||||||
btn.setAttribute("aria-pressed", "false");
|
|
||||||
btn.setAttribute("aria-label", `${def.label} modifier`);
|
|
||||||
modBtns[def.mod] = btn;
|
|
||||||
btn.addEventListener("click", () => setMod(def.mod, !armed[def.mod]));
|
|
||||||
} else if ("cursor" in def) {
|
|
||||||
btn.addEventListener("click", () => {
|
|
||||||
send(cursorSeq(def.cursor, armed.ctrl, armed.alt));
|
|
||||||
clearMods();
|
|
||||||
});
|
|
||||||
} else if ("tilde" in def) {
|
|
||||||
btn.addEventListener("click", () => {
|
|
||||||
send(tildeSeq(def.tilde, armed.ctrl, armed.alt));
|
|
||||||
clearMods();
|
|
||||||
});
|
|
||||||
} else if ("char" in def) {
|
|
||||||
btn.addEventListener("click", () => {
|
|
||||||
send(applyModsToChar(def.char, armed.ctrl, armed.alt));
|
|
||||||
clearMods();
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
btn.addEventListener("click", () => {
|
|
||||||
send(def.seq);
|
|
||||||
clearMods();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep the terminal focused (soft keyboard up) — don't let the tap steal it.
|
|
||||||
// preventDefault suppresses the native `:active` style, so flash an explicit
|
|
||||||
// press animation here for tactile feedback.
|
|
||||||
btn.addEventListener("pointerdown", (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
btn.classList.remove("is-pressed");
|
|
||||||
void btn.offsetWidth; // restart the animation if re-tapped mid-flash
|
|
||||||
btn.classList.add("is-pressed");
|
|
||||||
});
|
|
||||||
btn.addEventListener("animationend", () => btn.classList.remove("is-pressed"));
|
|
||||||
el.appendChild(btn);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
el,
|
|
||||||
consume(data: string): string {
|
|
||||||
if ((armed.ctrl || armed.alt) && data.length === 1) {
|
|
||||||
const out = applyModsToChar(data, armed.ctrl, armed.alt);
|
|
||||||
clearMods();
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
// @vitest-environment happy-dom
|
|
||||||
//
|
|
||||||
// The sidebar's half of the command bar's action page (#151): the bar picks
|
|
||||||
// kill or rename and raises the flow the card already has, so what the page
|
|
||||||
// runs is what the ✕ button and the `r` key run.
|
|
||||||
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import { KILL_SESSION_EVENT, RENAME_SESSION_EVENT, renderSidebar } from "./catalog.js";
|
|
||||||
import type { CatalogListener, CatalogStore } from "./events.js";
|
|
||||||
import type { Session } from "./types.js";
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
Object.defineProperty(window, "localStorage", {
|
|
||||||
value: { getItem: () => null, setItem: () => {} },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
let mounted: { el: HTMLElement; dispose: () => void } | null = null;
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
document.querySelector(".kill-dialog")?.remove();
|
|
||||||
mounted?.dispose();
|
|
||||||
mounted = null;
|
|
||||||
document.body.replaceChildren();
|
|
||||||
});
|
|
||||||
|
|
||||||
function session(overrides: Partial<Session> = {}): Session {
|
|
||||||
return {
|
|
||||||
id: "aaaaaaaa-0000-0000-0000-0000000000ff",
|
|
||||||
cwd: "/home/naps/tea/maestro",
|
|
||||||
command: "claude",
|
|
||||||
env: {},
|
|
||||||
metadata: { name: "amber-falcon" },
|
|
||||||
status: "Running",
|
|
||||||
created_at: "2026-08-24T10:00:00Z",
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function storeWith(sessions: Session[]): CatalogStore {
|
|
||||||
const state = { sessions: new Map(sessions.map((s) => [s.id, s])) };
|
|
||||||
return {
|
|
||||||
snapshot: () => state,
|
|
||||||
subscribe: (fn: CatalogListener) => {
|
|
||||||
fn(state);
|
|
||||||
return () => {};
|
|
||||||
},
|
|
||||||
} as unknown as CatalogStore;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mount(s: Session): HTMLElement {
|
|
||||||
const handle = renderSidebar(storeWith([s]));
|
|
||||||
mounted = handle;
|
|
||||||
document.body.appendChild(handle.el);
|
|
||||||
return handle.el;
|
|
||||||
}
|
|
||||||
|
|
||||||
function raise(name: string, s: Session): void {
|
|
||||||
document.dispatchEvent(new CustomEvent(name, { detail: s }));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Let the kill chain's awaits (fetch, its json body) settle. */
|
|
||||||
async function flush(): Promise<void> {
|
|
||||||
for (let i = 0; i < 8; i++) await Promise.resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("kill from the action page", () => {
|
|
||||||
it("goes through the same confirmation the sidebar raises (#160)", async () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
window.fetch = ((url: string, init?: RequestInit) => {
|
|
||||||
calls.push(`${init?.method ?? "GET"} ${url}`);
|
|
||||||
return Promise.resolve(new Response(null, { status: 204 }));
|
|
||||||
}) as typeof fetch;
|
|
||||||
const s = session();
|
|
||||||
mount(s);
|
|
||||||
raise(KILL_SESSION_EVENT, s);
|
|
||||||
await flush();
|
|
||||||
expect(document.querySelector(".kill-dialog")?.getAttribute("aria-label")).toBe(
|
|
||||||
"Kill amber-falcon",
|
|
||||||
);
|
|
||||||
expect(calls.filter((c) => c.startsWith("DELETE"))).toHaveLength(0);
|
|
||||||
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
|
|
||||||
await flush();
|
|
||||||
expect(calls).toContain(`DELETE /api/sessions/${s.id}`);
|
|
||||||
expect(document.querySelector(".kill-dialog")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("raises the existing confirm-and-blockers dialog on a guarded refusal", async () => {
|
|
||||||
window.fetch = ((url: string) => {
|
|
||||||
if (String(url).includes("force=true")) {
|
|
||||||
return Promise.resolve(new Response(null, { status: 204 }));
|
|
||||||
}
|
|
||||||
return Promise.resolve(
|
|
||||||
new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
error: {
|
|
||||||
code: "kill_guarded",
|
|
||||||
message: "worktree has unsaved work",
|
|
||||||
unsaved_work: { untracked_files: 1 },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
{ status: 409, headers: { "content-type": "application/json" } },
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}) as typeof fetch;
|
|
||||||
const s = session({ blocked_work: { untracked_files: 1 } });
|
|
||||||
mount(s);
|
|
||||||
raise(KILL_SESSION_EVENT, s);
|
|
||||||
await flush();
|
|
||||||
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
|
|
||||||
await flush();
|
|
||||||
const dialog = document.querySelector(".kill-dialog");
|
|
||||||
expect(dialog?.getAttribute("aria-label")).toBe("Kill amber-falcon");
|
|
||||||
expect(dialog?.textContent).toContain("1 untracked file");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("rename from the action page", () => {
|
|
||||||
it("expands the folder hiding the card, since the prompt lives in the card", () => {
|
|
||||||
window.fetch = (() => Promise.reject(new Error("offline"))) as typeof fetch;
|
|
||||||
const s = session();
|
|
||||||
const el = mount(s);
|
|
||||||
const folder = el.querySelector<HTMLElement>(".tree-folder-header");
|
|
||||||
folder?.click();
|
|
||||||
expect(el.querySelector(`[data-session-id="${s.id}"]`)).toBeNull();
|
|
||||||
raise(RENAME_SESSION_EVENT, s);
|
|
||||||
expect(el.querySelector<HTMLInputElement>(".leaf-rename")?.value).toBe("amber-falcon");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("raises the card's own prompt, pre-filled with the session's name", () => {
|
|
||||||
window.fetch = (() => Promise.reject(new Error("offline"))) as typeof fetch;
|
|
||||||
const s = session();
|
|
||||||
const el = mount(s);
|
|
||||||
raise(RENAME_SESSION_EVENT, s);
|
|
||||||
const input = el.querySelector<HTMLInputElement>(".leaf-rename");
|
|
||||||
expect(input?.value).toBe("amber-falcon");
|
|
||||||
expect(input?.getAttribute("aria-label")).toBe("Rename amber-falcon");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,440 +0,0 @@
|
|||||||
// @vitest-environment happy-dom
|
|
||||||
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import { renderSidebar } from "./catalog.js";
|
|
||||||
import type { CatalogListener, CatalogStore } from "./events.js";
|
|
||||||
import type { InTheWay, Session } from "./types.js";
|
|
||||||
|
|
||||||
// happy-dom runs without a backing store; renderSidebar reads the persisted
|
|
||||||
// sidebar width on mount.
|
|
||||||
beforeAll(() => {
|
|
||||||
Object.defineProperty(window, "localStorage", {
|
|
||||||
value: { getItem: () => null, setItem: () => {} },
|
|
||||||
});
|
|
||||||
// No daemon here; renderSidebar treats a failed info fetch as "absolute
|
|
||||||
// paths are a fine fallback".
|
|
||||||
window.fetch = () => Promise.reject(new Error("offline"));
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
document.querySelector(".kill-dialog")?.remove();
|
|
||||||
});
|
|
||||||
|
|
||||||
function session(overrides: Partial<Session>): Session {
|
|
||||||
return {
|
|
||||||
id: "11111111-2222-3333-4444-555555555555",
|
|
||||||
cwd: "/home/naps/tea/maestro",
|
|
||||||
command: "claude",
|
|
||||||
env: {},
|
|
||||||
metadata: {},
|
|
||||||
status: "Running",
|
|
||||||
created_at: "2026-08-24T10:00:00Z",
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function storeWith(sessions: Session[]): CatalogStore {
|
|
||||||
const state = { sessions: new Map(sessions.map((s) => [s.id, s])) };
|
|
||||||
return {
|
|
||||||
snapshot: () => state,
|
|
||||||
// Like the real store, deliver an immediate snapshot so the sidebar
|
|
||||||
// renders its rows during construction.
|
|
||||||
subscribe: (fn: CatalogListener) => {
|
|
||||||
fn(state);
|
|
||||||
return () => {};
|
|
||||||
},
|
|
||||||
} as unknown as CatalogStore;
|
|
||||||
}
|
|
||||||
|
|
||||||
function rowFor(el: HTMLElement, id: string): HTMLElement | null {
|
|
||||||
return el.querySelector<HTMLElement>(`[data-session-id="${id}"]`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Let the kill chain's awaits (fetch, its json body) settle. */
|
|
||||||
async function flush(): Promise<void> {
|
|
||||||
for (let i = 0; i < 8; i++) await Promise.resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("blocked-work indicator (#83)", () => {
|
|
||||||
it("marks the row of a blocked session in diff notation", () => {
|
|
||||||
const blocked = session({
|
|
||||||
id: "aaaaaaaa-0000-0000-0000-000000000001",
|
|
||||||
blocked_work: {
|
|
||||||
uncommitted_files: 3,
|
|
||||||
unpushed_commits: 2,
|
|
||||||
insertions: 124,
|
|
||||||
deletions: 18,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const el = renderSidebar(storeWith([blocked])).el;
|
|
||||||
const badge = rowFor(el, blocked.id)?.querySelector(".leaf-blocked");
|
|
||||||
expect(badge?.textContent).toBe("+124-18↑2");
|
|
||||||
// The spelled-out breakdown survives in the tooltip.
|
|
||||||
expect(badge?.getAttribute("title")).toContain(
|
|
||||||
"3 uncommitted files, 2 unpushed commits",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to the file count when tracked changes carry no lines", () => {
|
|
||||||
const blocked = session({
|
|
||||||
id: "aaaaaaaa-0000-0000-0000-00000000001a",
|
|
||||||
blocked_work: { uncommitted_files: 2, untracked_files: 1 },
|
|
||||||
});
|
|
||||||
const el = renderSidebar(storeWith([blocked])).el;
|
|
||||||
const badge = rowFor(el, blocked.id)?.querySelector(".leaf-blocked");
|
|
||||||
expect(badge?.textContent).toBe("~2?1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not mark clear or unprobed sessions", () => {
|
|
||||||
const clear = session({
|
|
||||||
id: "aaaaaaaa-0000-0000-0000-000000000002",
|
|
||||||
blocked_work: {} as InTheWay,
|
|
||||||
});
|
|
||||||
const unprobed = session({
|
|
||||||
id: "aaaaaaaa-0000-0000-0000-000000000003",
|
|
||||||
});
|
|
||||||
const el = renderSidebar(storeWith([clear, unprobed])).el;
|
|
||||||
expect(rowFor(el, clear.id)?.querySelector(".leaf-blocked")).toBeNull();
|
|
||||||
expect(rowFor(el, unprobed.id)?.querySelector(".leaf-blocked")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("confirms before an unguarded kill (#160)", async () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
window.fetch = ((url: string, init?: RequestInit) => {
|
|
||||||
calls.push(`${init?.method ?? "GET"} ${url}`);
|
|
||||||
return Promise.resolve(new Response(null, { status: 204 }));
|
|
||||||
}) as typeof fetch;
|
|
||||||
const plain = session({ id: "aaaaaaaa-0000-0000-0000-000000000005" });
|
|
||||||
const el = renderSidebar(storeWith([plain])).el;
|
|
||||||
rowFor(el, plain.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
|
||||||
await flush();
|
|
||||||
const dialog = document.querySelector(".kill-dialog");
|
|
||||||
expect(dialog?.getAttribute("aria-label")).toBe("Kill claude");
|
|
||||||
expect(calls.filter((c) => c.startsWith("DELETE"))).toHaveLength(0);
|
|
||||||
dialog?.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
|
|
||||||
await flush();
|
|
||||||
expect(calls).toContain(`DELETE /api/sessions/${plain.id}`);
|
|
||||||
expect(document.querySelector(".kill-dialog")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("kills nothing when the confirmation is cancelled (#160)", async () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
window.fetch = ((url: string, init?: RequestInit) => {
|
|
||||||
calls.push(`${init?.method ?? "GET"} ${url}`);
|
|
||||||
return Promise.resolve(new Response(null, { status: 204 }));
|
|
||||||
}) as typeof fetch;
|
|
||||||
const plain = session({ id: "aaaaaaaa-0000-0000-0000-000000000007" });
|
|
||||||
const el = renderSidebar(storeWith([plain])).el;
|
|
||||||
rowFor(el, plain.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
|
||||||
await flush();
|
|
||||||
document.querySelector<HTMLButtonElement>(".kill-dialog-cancel")?.click();
|
|
||||||
await flush();
|
|
||||||
expect(document.querySelector(".kill-dialog")).toBeNull();
|
|
||||||
expect(calls.filter((c) => c.startsWith("DELETE"))).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("names the blockers in the confirmation, before the daemon refuses (#160)", async () => {
|
|
||||||
window.fetch = (() => Promise.reject(new Error("never called"))) as typeof fetch;
|
|
||||||
const blocked = session({
|
|
||||||
id: "aaaaaaaa-0000-0000-0000-000000000008",
|
|
||||||
blocked_work: { untracked_files: 1 },
|
|
||||||
});
|
|
||||||
const el = renderSidebar(storeWith([blocked])).el;
|
|
||||||
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
|
||||||
await flush();
|
|
||||||
const dialog = document.querySelector(".kill-dialog");
|
|
||||||
expect(dialog?.textContent).toContain("1 untracked file");
|
|
||||||
expect(dialog?.textContent).toContain("refused unless it is forced");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("offers the override when the daemon refuses a guarded kill", async () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
window.fetch = ((url: string, init?: RequestInit) => {
|
|
||||||
calls.push(`${init?.method ?? "GET"} ${url}`);
|
|
||||||
if (url.includes("force=true")) return Promise.resolve(new Response(null, { status: 204 }));
|
|
||||||
return Promise.resolve(
|
|
||||||
new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
error: {
|
|
||||||
code: "kill_guarded",
|
|
||||||
message: "worktree has unsaved work",
|
|
||||||
unsaved_work: { untracked_files: 1 },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
{ status: 409, headers: { "content-type": "application/json" } },
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}) as typeof fetch;
|
|
||||||
const blocked = session({
|
|
||||||
id: "aaaaaaaa-0000-0000-0000-000000000004",
|
|
||||||
command: "bash",
|
|
||||||
blocked_work: { untracked_files: 1 },
|
|
||||||
});
|
|
||||||
const el = renderSidebar(storeWith([blocked])).el;
|
|
||||||
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
|
||||||
await flush();
|
|
||||||
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
|
|
||||||
await flush();
|
|
||||||
const dialog = document.querySelector(".kill-dialog");
|
|
||||||
expect(dialog?.textContent).toContain("1 untracked file");
|
|
||||||
dialog?.querySelector<HTMLButtonElement>(".kill-dialog-force")?.click();
|
|
||||||
await flush();
|
|
||||||
expect(calls).toContain(`DELETE /api/sessions/${blocked.id}?force=true`);
|
|
||||||
expect(document.querySelector(".kill-dialog")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("closes the override prompt without killing on cancel", async () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
window.fetch = ((url: string, init?: RequestInit) => {
|
|
||||||
calls.push(`${init?.method ?? "GET"} ${url}`);
|
|
||||||
return Promise.resolve(
|
|
||||||
new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
error: { code: "kill_guarded", message: "worktree has unsaved work" },
|
|
||||||
}),
|
|
||||||
{ status: 409, headers: { "content-type": "application/json" } },
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}) as typeof fetch;
|
|
||||||
const blocked = session({
|
|
||||||
id: "aaaaaaaa-0000-0000-0000-000000000006",
|
|
||||||
blocked_work: { unpushed_commits: 2 },
|
|
||||||
});
|
|
||||||
const el = renderSidebar(storeWith([blocked])).el;
|
|
||||||
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
|
||||||
await flush();
|
|
||||||
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
|
|
||||||
await flush();
|
|
||||||
document.querySelector<HTMLButtonElement>(".kill-dialog-cancel")?.click();
|
|
||||||
expect(document.querySelector(".kill-dialog")).toBeNull();
|
|
||||||
expect(calls.filter((c) => c.includes("force=true"))).toHaveLength(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("agent-reported detail (#112)", () => {
|
|
||||||
const ago = (ms: number): string => new Date(Date.now() - ms).toISOString();
|
|
||||||
|
|
||||||
it("renders the reported detail right after the duration on line 2", () => {
|
|
||||||
const s = session({
|
|
||||||
id: "aaaaaaaa-0000-0000-0000-000000000020",
|
|
||||||
status: "Dead",
|
|
||||||
activity: "Error",
|
|
||||||
activity_since: ago(90_000),
|
|
||||||
activity_detail: "cargo build failed",
|
|
||||||
});
|
|
||||||
const el = renderSidebar(storeWith([s])).el;
|
|
||||||
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
|
|
||||||
expect(line2?.querySelector(".activity-detail")?.textContent).toBe("cargo build failed");
|
|
||||||
const order = [...line2!.children].map((c) => c.className);
|
|
||||||
expect(order.indexOf("activity-detail")).toBeGreaterThan(order.indexOf("activity-duration"));
|
|
||||||
expect(order.indexOf("activity-detail")).toBeLessThan(
|
|
||||||
order.findIndex((c) => c.startsWith("card-flag")),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("omits the detail when no report is in force", () => {
|
|
||||||
const s = session({
|
|
||||||
id: "aaaaaaaa-0000-0000-0000-000000000021",
|
|
||||||
activity: "Active",
|
|
||||||
activity_since: ago(90_000),
|
|
||||||
});
|
|
||||||
const el = renderSidebar(storeWith([s])).el;
|
|
||||||
expect(rowFor(el, s.id)?.querySelector(".activity-detail")).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("session cards (#107)", () => {
|
|
||||||
const ago = (ms: number): string => new Date(Date.now() - ms).toISOString();
|
|
||||||
|
|
||||||
it("keeps the name and the actions in the same flow line, actions reserving their width", () => {
|
|
||||||
const s = session({ id: "aaaaaaaa-0000-0000-0000-000000000010" });
|
|
||||||
const el = renderSidebar(storeWith([s])).el;
|
|
||||||
const line1 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line1");
|
|
||||||
expect(line1).not.toBeNull();
|
|
||||||
// Same flex line ⇒ the name truncates before it can ever reach the buttons.
|
|
||||||
expect(line1?.querySelector(".leaf-name")).not.toBeNull();
|
|
||||||
expect(line1?.querySelector(".leaf-actions")).toBe(line1?.lastElementChild);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("always renders line 2 for a freshly spawned session", () => {
|
|
||||||
const s = session({
|
|
||||||
id: "aaaaaaaa-0000-0000-0000-000000000011",
|
|
||||||
activity: "Active",
|
|
||||||
activity_since: ago(5_000),
|
|
||||||
});
|
|
||||||
const el = renderSidebar(storeWith([s])).el;
|
|
||||||
const row = rowFor(el, s.id);
|
|
||||||
const line2 = row?.querySelector<HTMLElement>(".card-line2");
|
|
||||||
expect(line2).not.toBeNull();
|
|
||||||
expect(row?.querySelector(".activity-rail")).not.toBeNull();
|
|
||||||
expect(line2?.querySelector(".activity-duration")).toBeNull();
|
|
||||||
expect(line2?.querySelector(".leaf-cmd")).not.toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows dot + floored duration once the state has been held past 30s", () => {
|
|
||||||
const s = session({
|
|
||||||
id: "aaaaaaaa-0000-0000-0000-000000000012",
|
|
||||||
activity: "Active",
|
|
||||||
activity_since: ago(4 * 60_000 + 30_000),
|
|
||||||
});
|
|
||||||
const el = renderSidebar(storeWith([s])).el;
|
|
||||||
const row = rowFor(el, s.id);
|
|
||||||
const line2 = row?.querySelector<HTMLElement>(".card-line2");
|
|
||||||
expect(line2?.dataset.activity).toBe("active");
|
|
||||||
expect(row?.querySelector(".activity-rail")).not.toBeNull();
|
|
||||||
expect(line2?.querySelector(".activity-duration")?.textContent).toBe("4m");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("dims a name the daemon generated, until it settles (#131)", () => {
|
|
||||||
const generated = session({
|
|
||||||
id: "aaaaaaaa-0000-0000-0000-0000000000a1",
|
|
||||||
metadata: { name: "amber-falcon", name_source: "Generated" },
|
|
||||||
});
|
|
||||||
const haiku = session({
|
|
||||||
id: "aaaaaaaa-0000-0000-0000-0000000000a2",
|
|
||||||
metadata: { name: "sidebar-rail", name_source: "Haiku" },
|
|
||||||
});
|
|
||||||
const chosen = session({
|
|
||||||
id: "aaaaaaaa-0000-0000-0000-0000000000a3",
|
|
||||||
metadata: { name: "my-session", name_source: "User" },
|
|
||||||
});
|
|
||||||
// Named before `name_source` existed: settled, never dimmed.
|
|
||||||
const legacy = session({
|
|
||||||
id: "aaaaaaaa-0000-0000-0000-0000000000a4",
|
|
||||||
metadata: { name: "older-session" },
|
|
||||||
});
|
|
||||||
|
|
||||||
const el = renderSidebar(storeWith([generated, haiku, chosen, legacy])).el;
|
|
||||||
const provisional = (s: Session) =>
|
|
||||||
rowFor(el, s.id)?.querySelector(".leaf-name")?.classList.contains("is-provisional");
|
|
||||||
expect(provisional(generated)).toBe(true);
|
|
||||||
expect(provisional(haiku)).toBe(false);
|
|
||||||
expect(provisional(chosen)).toBe(false);
|
|
||||||
expect(provisional(legacy)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders the command as its tool icon when the tool is known", () => {
|
|
||||||
const s = session({
|
|
||||||
id: "aaaaaaaa-0000-0000-0000-00000000001b",
|
|
||||||
command: "claude",
|
|
||||||
metadata: { name: "claude" },
|
|
||||||
});
|
|
||||||
const el = renderSidebar(storeWith([s])).el;
|
|
||||||
const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd");
|
|
||||||
expect(cmd?.querySelector(".tool-icon")?.getAttribute("data-tool")).toBe("claude");
|
|
||||||
expect(cmd?.getAttribute("title")).toBe("claude");
|
|
||||||
// The icon said it all: no text left over.
|
|
||||||
expect(cmd?.querySelector(".leaf-cmd-rest")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("drops the command's arguments, keeping them in the tooltip", () => {
|
|
||||||
const s = session({
|
|
||||||
id: "aaaaaaaa-0000-0000-0000-00000000001c",
|
|
||||||
command: "claude --dangerously-skip-permissions",
|
|
||||||
});
|
|
||||||
const el = renderSidebar(storeWith([s])).el;
|
|
||||||
const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd");
|
|
||||||
expect(cmd?.querySelector(".tool-icon")?.getAttribute("data-tool")).toBe("claude");
|
|
||||||
expect(cmd?.querySelector(".leaf-cmd-rest")).toBeNull();
|
|
||||||
expect(cmd?.getAttribute("title")).toBe("claude --dangerously-skip-permissions");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps an unknown command as plain text", () => {
|
|
||||||
const s = session({
|
|
||||||
id: "aaaaaaaa-0000-0000-0000-00000000001d",
|
|
||||||
command: "hx src/main.rs",
|
|
||||||
});
|
|
||||||
const el = renderSidebar(storeWith([s])).el;
|
|
||||||
const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd");
|
|
||||||
expect(cmd?.querySelector(".tool-icon")).toBeNull();
|
|
||||||
expect(cmd?.querySelector(".leaf-cmd-rest")?.textContent).toBe("hx");
|
|
||||||
expect(cmd?.getAttribute("title")).toBe("hx src/main.rs");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("maps each activity state to its contract slug", () => {
|
|
||||||
const mk = (id: string, activity: string) =>
|
|
||||||
session({
|
|
||||||
id,
|
|
||||||
activity: activity as Session["activity"],
|
|
||||||
status: "Running",
|
|
||||||
activity_since: ago(90_000),
|
|
||||||
});
|
|
||||||
const idle = mk("aaaaaaaa-0000-0000-0000-000000000013", "Idle");
|
|
||||||
const wait = mk("aaaaaaaa-0000-0000-0000-000000000014", "AwaitingInput");
|
|
||||||
const error = mk("aaaaaaaa-0000-0000-0000-000000000015", "Error");
|
|
||||||
const el = renderSidebar(storeWith([idle, wait, error])).el;
|
|
||||||
expect(rowFor(el, idle.id)?.querySelector(".card-line2")?.getAttribute("data-activity")).toBe(
|
|
||||||
"idle",
|
|
||||||
);
|
|
||||||
expect(rowFor(el, wait.id)?.querySelector(".card-line2")?.getAttribute("data-activity")).toBe(
|
|
||||||
"awaiting-input",
|
|
||||||
);
|
|
||||||
expect(rowFor(el, error.id)?.querySelector(".card-line2")?.getAttribute("data-activity")).toBe(
|
|
||||||
"error",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats an unknown activity variant as idle instead of failing to render", () => {
|
|
||||||
const s = session({
|
|
||||||
id: "aaaaaaaa-0000-0000-0000-000000000016",
|
|
||||||
activity: "Teleported" as Session["activity"],
|
|
||||||
activity_since: ago(120_000),
|
|
||||||
});
|
|
||||||
const el = renderSidebar(storeWith([s])).el;
|
|
||||||
const line2 = rowFor(el, s.id)?.querySelector(".card-line2");
|
|
||||||
expect(line2?.getAttribute("data-activity")).toBe("idle");
|
|
||||||
expect(line2?.querySelector(".activity-duration")?.textContent).toBe("2m");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("moves blocked work onto line 2 as diff notation", () => {
|
|
||||||
const s = session({
|
|
||||||
id: "aaaaaaaa-0000-0000-0000-000000000017",
|
|
||||||
blocked_work: {
|
|
||||||
uncommitted_files: 3,
|
|
||||||
unpushed_commits: 2,
|
|
||||||
insertions: 124,
|
|
||||||
deletions: 18,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const el = renderSidebar(storeWith([s])).el;
|
|
||||||
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
|
|
||||||
expect(line2?.textContent).toContain("+124");
|
|
||||||
expect(line2?.textContent).toContain("-18");
|
|
||||||
// Line 1 carries no badges.
|
|
||||||
expect(rowFor(el, s.id)?.querySelector(".leaf-badge")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows exited on line 2 for dead sessions", () => {
|
|
||||||
const s = session({
|
|
||||||
id: "aaaaaaaa-0000-0000-0000-000000000018",
|
|
||||||
status: "Dead",
|
|
||||||
});
|
|
||||||
const el = renderSidebar(storeWith([s])).el;
|
|
||||||
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
|
|
||||||
expect(line2?.textContent).toContain("exited");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("carries the state word only in tooltips and aria labels, never as text", () => {
|
|
||||||
const s = session({
|
|
||||||
id: "aaaaaaaa-0000-0000-0000-000000000019",
|
|
||||||
command: "claude",
|
|
||||||
metadata: { name: "worker" },
|
|
||||||
activity: "AwaitingInput",
|
|
||||||
activity_since: ago(90_000),
|
|
||||||
});
|
|
||||||
const el = renderSidebar(storeWith([s])).el;
|
|
||||||
const row = rowFor(el, s.id) as HTMLElement;
|
|
||||||
const rail = row.querySelector<HTMLElement>(".activity-rail");
|
|
||||||
expect(rail?.getAttribute("title")).toContain("Awaiting input");
|
|
||||||
expect(rail?.getAttribute("aria-label")).toContain("Awaiting input");
|
|
||||||
expect(row.textContent).not.toMatch(/awaiting.?input/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the attach navigation and depth indentation", () => {
|
|
||||||
const s = session({ id: "aaaaaaaa-0000-0000-0000-00000000001a" });
|
|
||||||
const el = renderSidebar(storeWith([s])).el;
|
|
||||||
const row = rowFor(el, s.id) as HTMLAnchorElement;
|
|
||||||
expect(row.getAttribute("href")).toBe("#/attach/aaaaaaaa-0000-0000-0000-00000000001a");
|
|
||||||
expect(row.style.paddingLeft).toMatch(/^\d+(\.\d+)?px$/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,872 +0,0 @@
|
|||||||
import { escapeHtml } from "./html.js";
|
|
||||||
import { ApiRequestError, getDaemonInfo, killSession, renameSession } from "./api.js";
|
|
||||||
import { openForceKillDialog, openKillConfirmDialog } from "./killDialog.js";
|
|
||||||
import { openContextMenu } from "./contextMenu.js";
|
|
||||||
import { toggleHelp } from "./help.js";
|
|
||||||
import { CommandSet, defineCommands } from "./commands.js";
|
|
||||||
import { CatalogStore } from "./events.js";
|
|
||||||
import type { InTheWay, Session } from "./types.js";
|
|
||||||
import {
|
|
||||||
abbreviateCwd,
|
|
||||||
abbreviateFolderName,
|
|
||||||
blockerStats,
|
|
||||||
customSessionName,
|
|
||||||
formatInTheWay,
|
|
||||||
MAX_SESSION_NAME_LEN,
|
|
||||||
normalizeSessionName,
|
|
||||||
sessionBlockers,
|
|
||||||
isProvisionalName,
|
|
||||||
sessionName,
|
|
||||||
sessionTreePath,
|
|
||||||
} from "./types.js";
|
|
||||||
import { splitCommand, toolIconSvg } from "./toolIcon.js";
|
|
||||||
import {
|
|
||||||
activityColorSlug,
|
|
||||||
activityDurationLabel,
|
|
||||||
activityHeldMs,
|
|
||||||
META_LINE_MIN_HOLD_MS,
|
|
||||||
} from "./activity.js";
|
|
||||||
import type { TreeNode } from "./tree.js";
|
|
||||||
import { buildTree } from "./tree.js";
|
|
||||||
import { createPaletteSelect, createThemeToggle } from "./theme.js";
|
|
||||||
import type { DaemonLink } from "./daemonLink.js";
|
|
||||||
import { createStatusIndicator } from "./statusIndicator.js";
|
|
||||||
|
|
||||||
/** Ask the shell to open the spawn modal. Handled in main.ts, where the
|
|
||||||
* multi-view controller lives; dispatched from every Spawn affordance. */
|
|
||||||
export const OPEN_SPAWN_EVENT = "catalog-open-spawn";
|
|
||||||
export function requestOpenSpawn(): void {
|
|
||||||
document.dispatchEvent(new CustomEvent(OPEN_SPAWN_EVENT));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ask the shell to open the command bar with an empty query. Dispatched by
|
|
||||||
* the sidebar's search field, which is the touch entry point to the bar
|
|
||||||
* (#157, docs/COMMAND-BAR.md "Touch entry"): there is no `ctrl-k` on a phone.
|
|
||||||
* Handled in main.ts, which owns the session snapshot the bar needs. */
|
|
||||||
export const OPEN_COMMAND_BAR_EVENT = "catalog-open-command-bar";
|
|
||||||
export function requestOpenCommandBar(): void {
|
|
||||||
document.dispatchEvent(new CustomEvent(OPEN_COMMAND_BAR_EVENT));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ask the shell to hand the keyboard back to the focused pane's terminal —
|
|
||||||
* what Tab in the sidebar list does. Handled in main.ts, which owns the
|
|
||||||
* connection pool. */
|
|
||||||
export const FOCUS_PANE_EVENT = "catalog-focus-pane";
|
|
||||||
|
|
||||||
/** Ask the shell to open the spawn modal seeded from an existing session
|
|
||||||
* ("Spawn similar"). Detail is the session being cloned. */
|
|
||||||
export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar";
|
|
||||||
|
|
||||||
/** Ask the sidebar to kill a session — the command bar's action page picking
|
|
||||||
* `kill` (#151). Detail is the session. Handled here, so the kill goes
|
|
||||||
* through the same confirm-and-blockers flow the card's ✕ does
|
|
||||||
* (`docs/WORKTREES.md`); the bar never reimplements it. */
|
|
||||||
export const KILL_SESSION_EVENT = "catalog-kill-session";
|
|
||||||
|
|
||||||
/** Ask the sidebar to rename a session — the command bar's action page picking
|
|
||||||
* `rename` (#151). Detail is the session. The prompt is the card's own inline
|
|
||||||
* field, pre-filled with the session's name (`docs/SIDEBAR.md`). */
|
|
||||||
export const RENAME_SESSION_EVENT = "catalog-rename-session";
|
|
||||||
|
|
||||||
/** A session row was clicked (issue #133). The row's `href` drives normal
|
|
||||||
* hash navigation, but a split/drag can move focus without ever touching
|
|
||||||
* `location.hash` — so a click back onto a session whose hash still
|
|
||||||
* matches fires no `hashchange` and the app never hears about it. This
|
|
||||||
* event is the click-driven path that doesn't depend on the hash actually
|
|
||||||
* changing. */
|
|
||||||
export const SESSION_SELECT_EVENT = "catalog-session-select";
|
|
||||||
|
|
||||||
/** The rows a keyboard walk stops on, in visual (document) order. Expanded
|
|
||||||
* folder headers are skipped: a directory is not something to focus, and the
|
|
||||||
* cards under it are one row away anyway. A *collapsed* folder still counts —
|
|
||||||
* its header is the only row it has, so skipping it would leave the folder
|
|
||||||
* unreachable. Same rule as the TUI sidebar. */
|
|
||||||
const NAV_SELECTOR = '.tree-leaf, .tree-folder-header[aria-expanded="false"]';
|
|
||||||
|
|
||||||
const collapsed = new Set<string>();
|
|
||||||
|
|
||||||
/** Session whose card is currently being renamed inline (#125). The tree
|
|
||||||
* re-renders on every catalog delta, and an activity update arriving mid-edit
|
|
||||||
* would otherwise replace the field the user is typing into. */
|
|
||||||
let renamingId: string | null = null;
|
|
||||||
|
|
||||||
// docs/SIDEBAR.md: the state word is never rendered — the rail's colour
|
|
||||||
// carries it. It survives only in tooltips/aria labels so meaning is not
|
|
||||||
// colour-only.
|
|
||||||
const STATE_WORDS: Record<string, string> = {
|
|
||||||
active: "Active",
|
|
||||||
idle: "Idle",
|
|
||||||
"awaiting-input": "Awaiting input",
|
|
||||||
error: "Error",
|
|
||||||
};
|
|
||||||
|
|
||||||
/** docs/SIDEBAR.md: the activity rail is pinned to the row's own left edge,
|
|
||||||
* full height. The row's own padding (`renderNode`) shifts outward with
|
|
||||||
* nesting depth, carrying the rail with it. */
|
|
||||||
function activityRailMarkup(slug: string, stateWord: string): string {
|
|
||||||
return `<span class="activity-rail" data-activity="${slug}" role="img" title="${escapeHtml(stateWord)}" aria-label="${escapeHtml(stateWord)}"></span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function metaLine(s: Session, now: number): string {
|
|
||||||
const dead = s.status === "Dead";
|
|
||||||
const blockers = sessionBlockers(s);
|
|
||||||
const slug = activityColorSlug(s.activity ?? "Idle");
|
|
||||||
const held = activityHeldMs(s.activity_since, now);
|
|
||||||
const duration =
|
|
||||||
held !== null && held >= META_LINE_MIN_HOLD_MS ? activityDurationLabel(held) : null;
|
|
||||||
|
|
||||||
const fragments = [
|
|
||||||
duration ? `<span class="activity-duration">${duration}</span>` : "",
|
|
||||||
s.activity_detail
|
|
||||||
? `<span class="activity-detail">${escapeHtml(s.activity_detail)}</span>`
|
|
||||||
: "",
|
|
||||||
commandFragment(s.command),
|
|
||||||
blockers ? blockedFragment(blockers) : "",
|
|
||||||
dead ? `<span class="card-flag">exited</span>` : "",
|
|
||||||
];
|
|
||||||
return `<span class="card-line2" data-activity="${slug}">${fragments.join("")}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** docs/SIDEBAR.md: blocked work renders as diff notation — `+124 -18 ?3 ↑2`
|
|
||||||
* — with the spelled-out breakdown kept in the tooltip. */
|
|
||||||
function blockedFragment(blockers: InTheWay): string {
|
|
||||||
const stats = blockerStats(blockers)
|
|
||||||
.map((st) => `<span class="blocked-stat" data-stat="${st.kind}">${escapeHtml(st.text)}</span>`)
|
|
||||||
.join("");
|
|
||||||
const title = `Kill will be refused unless forced — ${formatInTheWay(blockers)}`;
|
|
||||||
return `<span class="card-flag leaf-blocked" title="${escapeHtml(title)}">${stats}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** docs/SIDEBAR.md: the card names the program only — arguments never reach
|
|
||||||
* the card. The program becomes its tool icon when we have a mark for it, and
|
|
||||||
* stays as its bare name when we do not. The full command line, arguments and
|
|
||||||
* all, stays in the tooltip either way. */
|
|
||||||
function commandFragment(command: string): string {
|
|
||||||
const { tool, program } = splitCommand(command);
|
|
||||||
const icon = tool ? toolIconSvg(tool) : null;
|
|
||||||
const text = icon ? "" : program;
|
|
||||||
return `<span class="card-flag leaf-cmd" title="${escapeHtml(command)}">${icon ?? ""}${text ? `<span class="leaf-cmd-rest">${escapeHtml(text)}</span>` : ""}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderNode(node: TreeNode, depth: number): HTMLElement {
|
|
||||||
if (node.kind === "folder") {
|
|
||||||
const wrap = document.createElement("div");
|
|
||||||
wrap.className = "tree-folder";
|
|
||||||
|
|
||||||
if (node.name) {
|
|
||||||
const header = document.createElement("button");
|
|
||||||
header.type = "button";
|
|
||||||
header.className = "tree-folder-header";
|
|
||||||
header.style.paddingLeft = `${depth * 16}px`;
|
|
||||||
const isCollapsed = collapsed.has(node.path);
|
|
||||||
header.setAttribute("aria-expanded", String(!isCollapsed));
|
|
||||||
header.dataset.navKey = `folder:${node.path}`;
|
|
||||||
// Single down-chevron; CSS rotates it -90° (→ right) when collapsed, so the
|
|
||||||
// glyph and the rotation never fight each other.
|
|
||||||
// A deep path (`~/git.naps.pt/naps62/maestro`) collapses its leading
|
|
||||||
// segments to initials (`~/g/n/maestro`) so the dir name stays readable in
|
|
||||||
// a narrow sidebar; the full path lives in the tooltip.
|
|
||||||
header.innerHTML = `<span class="chevron">▾</span><span class="folder-name" title="${escapeHtml(node.name)}">${abbreviateFolderName(node.name)}</span>`;
|
|
||||||
header.addEventListener("click", () => {
|
|
||||||
if (collapsed.has(node.path)) collapsed.delete(node.path);
|
|
||||||
else collapsed.add(node.path);
|
|
||||||
// re-render handled by caller observing the store; trigger a refresh:
|
|
||||||
document.dispatchEvent(new CustomEvent("catalog-rerender"));
|
|
||||||
});
|
|
||||||
wrap.appendChild(header);
|
|
||||||
if (isCollapsed) return wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const child of node.children) {
|
|
||||||
wrap.appendChild(renderNode(child, node.name ? depth + 1 : depth));
|
|
||||||
}
|
|
||||||
return wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The row outlives the snapshot it was built from: a refresh that changes
|
|
||||||
// nothing structural patches it in place rather than replacing it, so the
|
|
||||||
// handlers below have to read the current session, not the one captured here.
|
|
||||||
let cur = node.session;
|
|
||||||
const s = node.session;
|
|
||||||
const now = Date.now();
|
|
||||||
const row = document.createElement("a");
|
|
||||||
row.className = "tree-leaf session-card";
|
|
||||||
row.href = `#/attach/${encodeURIComponent(s.id)}`;
|
|
||||||
row.style.paddingLeft = `${depth * 16 + 8}px`;
|
|
||||||
row.dataset.sessionId = s.id;
|
|
||||||
row.dataset.navKey = `session:${s.id}`;
|
|
||||||
const running = s.status === "Running";
|
|
||||||
row.dataset.status = running ? "running" : "dead";
|
|
||||||
const name = sessionName(s);
|
|
||||||
const slug = activityColorSlug(s.activity ?? "Idle");
|
|
||||||
const stateWord = STATE_WORDS[slug] ?? STATE_WORDS.idle;
|
|
||||||
row.setAttribute(
|
|
||||||
"aria-label",
|
|
||||||
`${name}, ${running ? "running" : "exited"}, ${stateWord}`,
|
|
||||||
);
|
|
||||||
// docs/SIDEBAR.md: line 1 reserves action width, and line 2 is unconditional.
|
|
||||||
row.innerHTML = `
|
|
||||||
${activityRailMarkup(slug, stateWord)}
|
|
||||||
<span class="card-line1">
|
|
||||||
<span class="leaf-name${isProvisionalName(s) ? " is-provisional" : ""}">${escapeHtml(name)}</span>
|
|
||||||
<span class="leaf-actions">
|
|
||||||
<button class="leaf-act leaf-clone" type="button" title="Spawn similar" aria-label="Spawn similar to ${escapeHtml(name)}">⧉</button>
|
|
||||||
<button class="leaf-act leaf-kill" type="button" title="Kill session" aria-label="Kill ${escapeHtml(name)}">✕</button>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
${metaLine(s, now)}
|
|
||||||
`;
|
|
||||||
const similarBtn = row.querySelector(".leaf-clone") as HTMLButtonElement;
|
|
||||||
similarBtn.addEventListener("click", (ev) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
ev.stopPropagation();
|
|
||||||
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: cur }));
|
|
||||||
});
|
|
||||||
const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement;
|
|
||||||
killBtn.addEventListener("click", (ev) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
ev.stopPropagation();
|
|
||||||
requestKill(cur, sessionName(cur));
|
|
||||||
});
|
|
||||||
row.addEventListener("click", () => {
|
|
||||||
document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: cur.id }));
|
|
||||||
});
|
|
||||||
row.addEventListener("contextmenu", (ev) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
ev.stopPropagation();
|
|
||||||
openContextMenu(ev.clientX, ev.clientY, [
|
|
||||||
{ label: "Rename…", onSelect: () => beginRename(row, cur) },
|
|
||||||
{
|
|
||||||
label: "Spawn similar",
|
|
||||||
onSelect: () =>
|
|
||||||
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: cur })),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Kill session",
|
|
||||||
danger: true,
|
|
||||||
onSelect: () => requestKill(cur, sessionName(cur)),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
leafPatches.set(row, (next) => {
|
|
||||||
cur = next;
|
|
||||||
const nextSlug = activityColorSlug(next.activity ?? "Idle");
|
|
||||||
const nextWord = STATE_WORDS[nextSlug] ?? STATE_WORDS.idle;
|
|
||||||
row.setAttribute(
|
|
||||||
"aria-label",
|
|
||||||
`${sessionName(next)}, ${next.status === "Running" ? "running" : "exited"}, ${nextWord}`,
|
|
||||||
);
|
|
||||||
const rail = row.querySelector(".activity-rail");
|
|
||||||
if (rail) rail.outerHTML = activityRailMarkup(nextSlug, nextWord);
|
|
||||||
const meta = row.querySelector(".card-line2");
|
|
||||||
if (meta) meta.outerHTML = metaLine(next, Date.now());
|
|
||||||
});
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Refresh a standing card from a newer snapshot of its session, keeping the
|
|
||||||
* element (and so the pointer's `:hover`) exactly where it is. Registered by
|
|
||||||
* `renderNode`; only the bits `treeSignature` leaves out are rewritten. */
|
|
||||||
const leafPatches = new WeakMap<HTMLElement, (s: Session) => void>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Everything about the tree that a patch cannot fix up.
|
|
||||||
*
|
|
||||||
* The daemon re-sends a session on every activity, foreground or cwd shift —
|
|
||||||
* a couple of times a minute per live session — and rebuilding the sidebar for
|
|
||||||
* each one drops `:hover` from the row under the pointer. Refreshes whose
|
|
||||||
* signature is unchanged are patched into the standing rows instead; anything
|
|
||||||
* that moves, renames, appears or dies changes the signature and rebuilds.
|
|
||||||
*/
|
|
||||||
function treeSignature(node: TreeNode): string {
|
|
||||||
if (node.kind === "folder") {
|
|
||||||
const head = `F:${node.name}:${node.path}:${collapsed.has(node.path)}`;
|
|
||||||
if (node.name && collapsed.has(node.path)) return head;
|
|
||||||
return `${head}(${node.children.map(treeSignature).join(",")})`;
|
|
||||||
}
|
|
||||||
const s = node.session;
|
|
||||||
return `L:${s.id}:${sessionName(s)}:${s.status}:${isProvisionalName(s)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Kill behind a confirmation (#160): a kill is destructive and unattended
|
|
||||||
* agents live in these sessions, so the web asks first, the way the TUI always
|
|
||||||
* has. The daemon decides the rest — it refuses a kill over a worktree holding
|
|
||||||
* unsaved work, and that refusal needs a prompt the confirmation cannot give,
|
|
||||||
* a forced kill. Anything else surfaces in the dialog that asked.
|
|
||||||
*/
|
|
||||||
function requestKill(s: Session, name: string): void {
|
|
||||||
openKillConfirmDialog({
|
|
||||||
name,
|
|
||||||
blockers: sessionBlockers(s),
|
|
||||||
onKill: async () => {
|
|
||||||
try {
|
|
||||||
await killSession(s.id);
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof ApiRequestError && e.code === "kill_guarded") {
|
|
||||||
openForceKillDialog({
|
|
||||||
name,
|
|
||||||
blockers: e.unsavedWork ?? sessionBlockers(s),
|
|
||||||
reason: e.detail,
|
|
||||||
onForce: () => killSession(s.id, true),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Swap the card's name for a text field (#125). Enter commits, Escape and
|
|
||||||
* blur cancel. An emptied field clears the name, so the card falls back to
|
|
||||||
* the command — the same rule the spawn form's blank name field follows.
|
|
||||||
*/
|
|
||||||
function beginRename(row: HTMLElement, s: Session): void {
|
|
||||||
const nameEl = row.querySelector<HTMLElement>(".leaf-name");
|
|
||||||
if (!nameEl) return;
|
|
||||||
const input = document.createElement("input");
|
|
||||||
input.type = "text";
|
|
||||||
input.className = "leaf-name leaf-rename";
|
|
||||||
input.value = customSessionName(s) ?? "";
|
|
||||||
input.maxLength = MAX_SESSION_NAME_LEN;
|
|
||||||
input.setAttribute("aria-label", `Rename ${sessionName(s)}`);
|
|
||||||
// The card is an <a>, and the tree starts a drag on pointerdown: without
|
|
||||||
// these, clicking into the field navigates away or begins dragging the row.
|
|
||||||
for (const type of ["click", "pointerdown", "dblclick"]) {
|
|
||||||
input.addEventListener(type, (ev) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
ev.stopPropagation();
|
|
||||||
input.focus();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
renamingId = s.id;
|
|
||||||
let settled = false;
|
|
||||||
const finish = () => {
|
|
||||||
if (settled) return false;
|
|
||||||
settled = true;
|
|
||||||
renamingId = null;
|
|
||||||
document.dispatchEvent(new CustomEvent("catalog-rerender"));
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
input.addEventListener("keydown", (ev) => {
|
|
||||||
if (ev.key === "Enter") {
|
|
||||||
ev.preventDefault();
|
|
||||||
// Normalised here so what is sent is what the daemon will store.
|
|
||||||
const name = normalizeSessionName(input.value);
|
|
||||||
if (!finish()) return;
|
|
||||||
void renameSession(s.id, name).catch((e: Error) => {
|
|
||||||
alert(`Failed to rename: ${e.message}`);
|
|
||||||
});
|
|
||||||
} else if (ev.key === "Escape") {
|
|
||||||
ev.preventDefault();
|
|
||||||
finish();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
input.addEventListener("blur", () => finish());
|
|
||||||
|
|
||||||
nameEl.replaceWith(input);
|
|
||||||
input.focus();
|
|
||||||
input.select();
|
|
||||||
}
|
|
||||||
|
|
||||||
// The tree has one empty state: no sessions at all. The old "No matches" one
|
|
||||||
// belonged to the inline substring filter, which the command bar replaced
|
|
||||||
// (#157) — the bar's own no-results state took over with it.
|
|
||||||
function renderEmpty(onSpawn: () => void): HTMLElement {
|
|
||||||
const el = document.createElement("div");
|
|
||||||
el.className = "empty";
|
|
||||||
el.innerHTML = `
|
|
||||||
<div class="empty-glyph" aria-hidden="true">+</div>
|
|
||||||
<div class="empty-title">No sessions yet</div>
|
|
||||||
<p class="empty-hint">Start your first session — it'll show up here.</p>
|
|
||||||
<div class="empty-actions"></div>`;
|
|
||||||
const spawn = document.createElement("button");
|
|
||||||
spawn.type = "button";
|
|
||||||
spawn.className = "btn btn-primary";
|
|
||||||
spawn.textContent = "+ Spawn session";
|
|
||||||
spawn.addEventListener("click", onSpawn);
|
|
||||||
(el.querySelector(".empty-actions") as HTMLElement).appendChild(spawn);
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Two-tier sidebar highlight state (issue #33): which sessions are open in some
|
|
||||||
* pane (quiet), and which one is focused (loud). */
|
|
||||||
export interface SidebarHighlights {
|
|
||||||
open: Set<string>;
|
|
||||||
focused: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Live handle to the persistent sidebar (built once, survives navigation). */
|
|
||||||
export interface SidebarHandle {
|
|
||||||
/** The `<aside>` element to mount into the shell. */
|
|
||||||
el: HTMLElement;
|
|
||||||
/** Highlight the row for `id` (or clear it with `null`) as the focused session.
|
|
||||||
* Back-compat shim over {@link setHighlights}. */
|
|
||||||
setActive(id: string | null): void;
|
|
||||||
/** Apply the full open/focused highlight state. */
|
|
||||||
setHighlights(h: SidebarHighlights): void;
|
|
||||||
/** Mark the row the content pane is previewing (docs/PREVIEW.md), or clear it
|
|
||||||
* with `null`. A preview is a view, not state, so this only paints. */
|
|
||||||
setPreview(sessionId: string | null): void;
|
|
||||||
/** Hand the keyboard to the session list, highlighting a row to walk from. */
|
|
||||||
focusList(): void;
|
|
||||||
/** Whether the session list currently holds DOM focus. */
|
|
||||||
hasListFocus(): boolean;
|
|
||||||
/** Tear down the store subscription + listeners. */
|
|
||||||
dispose(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the persistent session sidebar: brand, search, spawn, theme toggle, a
|
|
||||||
* live session status line, and the folder-grouped session tree. Unlike
|
|
||||||
* the old full-screen catalog this is mounted once by the shell and stays put
|
|
||||||
* while the content pane swaps between sessions — so the store subscription and
|
|
||||||
* scroll position survive navigation. The active session is highlighted.
|
|
||||||
*/
|
|
||||||
export interface SidebarOptions {
|
|
||||||
/** Pointerdown on a session row — the multi-view controller decides drag vs
|
|
||||||
* tap (issue #32). Tap falls through to the row's normal click (open). */
|
|
||||||
onLeafDrag?: (sessionId: string, e: PointerEvent) => void;
|
|
||||||
/** Daemon link behind the footer's status dot. Omitted in tests and in the
|
|
||||||
* design kit, which render the sidebar without a daemon. */
|
|
||||||
link?: DaemonLink;
|
|
||||||
/** The highlight moved onto a session, or off every session (`null`): the
|
|
||||||
* shell shows it in the content pane without opening it (docs/PREVIEW.md).
|
|
||||||
* `source` is there so a mouse sweep can be debounced differently from a
|
|
||||||
* keyboard step. */
|
|
||||||
onPreview?: (sessionId: string | null) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): SidebarHandle {
|
|
||||||
const el = document.createElement("aside");
|
|
||||||
el.className = "sidebar";
|
|
||||||
|
|
||||||
// The daemon's home dir abbreviates cwds to `~/…`. Fetched once; the tree
|
|
||||||
// renders with absolute paths until it lands.
|
|
||||||
let homeDir: string | undefined;
|
|
||||||
|
|
||||||
// The field never holds a query: it is the touch entry point to the command
|
|
||||||
// bar (#157), so it is `readonly` — a tap opens the bar instead of raising an
|
|
||||||
// on-screen keyboard over a field nothing is typed into.
|
|
||||||
const searchHeader = document.createElement("header");
|
|
||||||
searchHeader.className = "sidebar-search";
|
|
||||||
searchHeader.innerHTML = `
|
|
||||||
<input
|
|
||||||
type="search"
|
|
||||||
class="search"
|
|
||||||
readonly
|
|
||||||
placeholder="Search sessions…"
|
|
||||||
aria-label="Search sessions"
|
|
||||||
autocomplete="off"
|
|
||||||
autocorrect="off"
|
|
||||||
autocapitalize="off"
|
|
||||||
spellcheck="false"
|
|
||||||
/>
|
|
||||||
`;
|
|
||||||
el.appendChild(searchHeader);
|
|
||||||
|
|
||||||
const treeHost = document.createElement("div");
|
|
||||||
treeHost.className = "tree";
|
|
||||||
el.appendChild(treeHost);
|
|
||||||
|
|
||||||
// App-level actions (help, theme, spawn) live pinned to the sidebar's foot
|
|
||||||
// rather than the sidebar's own scrolling content, so they stay reachable
|
|
||||||
// regardless of tree scroll position.
|
|
||||||
const footer = document.createElement("div");
|
|
||||||
footer.className = "sidebar-footer";
|
|
||||||
const helpBtn = document.createElement("button");
|
|
||||||
helpBtn.type = "button";
|
|
||||||
helpBtn.className = "btn btn-ghost btn-icon help-btn";
|
|
||||||
helpBtn.textContent = "?";
|
|
||||||
helpBtn.title = "Keyboard shortcuts (?)";
|
|
||||||
helpBtn.setAttribute("aria-label", "Keyboard shortcuts");
|
|
||||||
helpBtn.addEventListener("click", () => toggleHelp());
|
|
||||||
footer.appendChild(helpBtn);
|
|
||||||
footer.appendChild(createThemeToggle());
|
|
||||||
footer.appendChild(createPaletteSelect());
|
|
||||||
// DEV-only shortcut to the standalone design kit (?kit). Lets you reach it by
|
|
||||||
// clicking inside a proxied preview (e.g. crit live) that has no address bar.
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
const kit = document.createElement("a");
|
|
||||||
kit.className = "btn btn-ghost";
|
|
||||||
kit.href = "?kit";
|
|
||||||
kit.textContent = "kit";
|
|
||||||
kit.title = "Open the design kit (DEV)";
|
|
||||||
footer.appendChild(kit);
|
|
||||||
}
|
|
||||||
// Daemon status sits beside the spawn button: colour for the link state,
|
|
||||||
// hover for the release the daemon is running.
|
|
||||||
const status = opts.link ? createStatusIndicator(opts.link) : null;
|
|
||||||
if (status) footer.appendChild(status.el);
|
|
||||||
const spawnBtn = document.createElement("button");
|
|
||||||
spawnBtn.type = "button";
|
|
||||||
spawnBtn.className = "btn btn-primary spawn-btn";
|
|
||||||
spawnBtn.textContent = "+ Spawn";
|
|
||||||
// The keyboard pair sits right here in the tooltip (#67).
|
|
||||||
spawnBtn.title = "Spawn form: n — fast-forward spawn: N";
|
|
||||||
spawnBtn.addEventListener("click", () => requestOpenSpawn());
|
|
||||||
footer.appendChild(spawnBtn);
|
|
||||||
el.appendChild(footer);
|
|
||||||
|
|
||||||
// Drag a session row into the canvas (issue #32). Delegated so it survives
|
|
||||||
// tree re-renders; the controller handles drag-vs-tap (tap still navigates).
|
|
||||||
treeHost.addEventListener("pointerdown", (e) => {
|
|
||||||
if (e.button !== 0 || !opts.onLeafDrag) return;
|
|
||||||
const leaf = (e.target as HTMLElement).closest<HTMLElement>(".tree-leaf");
|
|
||||||
const id = leaf?.dataset.sessionId;
|
|
||||||
if (id) opts.onLeafDrag(id, e);
|
|
||||||
});
|
|
||||||
// The rows are <a> elements, so the browser's native link drag-and-drop would
|
|
||||||
// otherwise hijack pointermove and our live drop-zone preview wouldn't show
|
|
||||||
// until release. Suppress it so the pointer-based drag runs from the start.
|
|
||||||
treeHost.addEventListener("dragstart", (e) => {
|
|
||||||
if ((e.target as HTMLElement).closest(".tree-leaf")) e.preventDefault();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Drag-to-resize handle on the sidebar's right edge. Persists the chosen
|
|
||||||
// width as a :root CSS var the shell grid reads; clamped to a sane range.
|
|
||||||
const SIDEBAR_W_KEY = "maestro-sidebar-w";
|
|
||||||
const storedW = localStorage.getItem(SIDEBAR_W_KEY);
|
|
||||||
if (storedW) document.documentElement.style.setProperty("--sidebar-w", storedW);
|
|
||||||
const handle = document.createElement("div");
|
|
||||||
handle.className = "sidebar-resize";
|
|
||||||
handle.setAttribute("aria-hidden", "true");
|
|
||||||
el.appendChild(handle);
|
|
||||||
let dragStartX = 0;
|
|
||||||
let dragStartW = 0;
|
|
||||||
let dragging = false;
|
|
||||||
handle.addEventListener("pointerdown", (e) => {
|
|
||||||
dragging = true;
|
|
||||||
dragStartX = e.clientX;
|
|
||||||
dragStartW = el.getBoundingClientRect().width;
|
|
||||||
handle.setPointerCapture(e.pointerId);
|
|
||||||
document.body.style.userSelect = "none";
|
|
||||||
e.preventDefault();
|
|
||||||
});
|
|
||||||
handle.addEventListener("pointermove", (e) => {
|
|
||||||
if (!dragging) return;
|
|
||||||
const w = Math.max(200, Math.min(560, dragStartW + (e.clientX - dragStartX)));
|
|
||||||
document.documentElement.style.setProperty("--sidebar-w", `${w}px`);
|
|
||||||
});
|
|
||||||
const endDrag = (e: PointerEvent) => {
|
|
||||||
if (!dragging) return;
|
|
||||||
dragging = false;
|
|
||||||
try {
|
|
||||||
handle.releasePointerCapture(e.pointerId);
|
|
||||||
} catch {
|
|
||||||
// pointer may already be released
|
|
||||||
}
|
|
||||||
document.body.style.userSelect = "";
|
|
||||||
const cur = document.documentElement.style.getPropertyValue("--sidebar-w");
|
|
||||||
if (cur) localStorage.setItem(SIDEBAR_W_KEY, cur);
|
|
||||||
};
|
|
||||||
handle.addEventListener("pointerup", endDrag);
|
|
||||||
handle.addEventListener("pointercancel", endDrag);
|
|
||||||
|
|
||||||
// Touch entry (#157): the field is a button that looks like a field. It
|
|
||||||
// never holds a query — focusing or tapping it hands the keyboard straight
|
|
||||||
// to the bar's own input, which is also why it blurs first: leaving the
|
|
||||||
// focus here would make the bar impossible to close without reopening it.
|
|
||||||
const search = searchHeader.querySelector(".search") as HTMLInputElement;
|
|
||||||
const openBar = () => {
|
|
||||||
search.value = "";
|
|
||||||
search.blur();
|
|
||||||
requestOpenCommandBar();
|
|
||||||
};
|
|
||||||
search.addEventListener("focus", openBar);
|
|
||||||
// A tap can land without a focus event (iOS Safari on a blurred field), so
|
|
||||||
// the pointer path opens the bar too; the blur above keeps the two from
|
|
||||||
// stacking two overlays.
|
|
||||||
search.addEventListener("pointerdown", (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
openBar();
|
|
||||||
});
|
|
||||||
|
|
||||||
let highlights: SidebarHighlights = { open: new Set(), focused: null };
|
|
||||||
// The previewed row is painted alongside the open/focused tiers so a
|
|
||||||
// re-render can't drop it, but it is never part of `highlights`: a preview
|
|
||||||
// is a view, and nothing about it is durable (docs/PREVIEW.md).
|
|
||||||
let previewing: string | null = null;
|
|
||||||
const applyHighlights = () => {
|
|
||||||
for (const n of treeHost.querySelectorAll<HTMLElement>(".tree-leaf")) {
|
|
||||||
const id = n.dataset.sessionId;
|
|
||||||
if (!id) continue;
|
|
||||||
const focused = highlights.focused === id;
|
|
||||||
const open = highlights.open.has(id);
|
|
||||||
n.classList.toggle("is-active", focused);
|
|
||||||
n.classList.toggle("is-open", open && !focused);
|
|
||||||
n.classList.toggle("is-previewing", previewing === id);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---- keyboard navigation ----
|
|
||||||
// The walk reads the rendered DOM rather than a parallel model, so it can
|
|
||||||
// never disagree with what is on screen. Rows carry a `data-nav-key`
|
|
||||||
// (`session:<id>` / `folder:<path>`) that survives a re-render.
|
|
||||||
let navKey: string | null = null;
|
|
||||||
|
|
||||||
const navRows = (): HTMLElement[] => [
|
|
||||||
...treeHost.querySelectorAll<HTMLElement>(NAV_SELECTOR),
|
|
||||||
];
|
|
||||||
|
|
||||||
const rowFor = (key: string | null): HTMLElement | undefined =>
|
|
||||||
key === null ? undefined : navRows().find((r) => r.dataset.navKey === key);
|
|
||||||
|
|
||||||
const applyNav = () => {
|
|
||||||
const rows = navRows();
|
|
||||||
// A row that went away (session killed, folder collapsed) drops the
|
|
||||||
// highlight rather than leaving it pointing at nothing.
|
|
||||||
if (navKey !== null && !rows.some((r) => r.dataset.navKey === navKey)) navKey = null;
|
|
||||||
for (const r of rows) r.classList.toggle("is-highlighted", r.dataset.navKey === navKey);
|
|
||||||
};
|
|
||||||
|
|
||||||
const sessionOf = (key: string | null): string | null =>
|
|
||||||
key?.startsWith("session:") ? key.slice("session:".length) : null;
|
|
||||||
|
|
||||||
const moveNav = (delta: number) => {
|
|
||||||
const rows = navRows();
|
|
||||||
if (rows.length === 0) return;
|
|
||||||
const cur = rows.findIndex((r) => r.dataset.navKey === navKey);
|
|
||||||
// Nothing highlighted: start one off the end we are walking from, so the
|
|
||||||
// first step lands on the first (or last) row.
|
|
||||||
const from = cur === -1 ? (delta > 0 ? -1 : 0) : cur;
|
|
||||||
const next = rows[(((from + delta) % rows.length) + rows.length) % rows.length];
|
|
||||||
navKey = next.dataset.navKey ?? null;
|
|
||||||
applyNav();
|
|
||||||
next.scrollIntoView({ block: "nearest" });
|
|
||||||
opts.onPreview?.(sessionOf(navKey));
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Enter: open the highlighted session, or expand the highlighted folder. */
|
|
||||||
const activateNav = () => {
|
|
||||||
const row = rowFor(navKey);
|
|
||||||
if (!row) return;
|
|
||||||
if (row.classList.contains("tree-leaf")) {
|
|
||||||
row.click();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Expanding replaces the folder's header in the walk with the rows it was
|
|
||||||
// hiding, so land on the first of them instead of losing the highlight.
|
|
||||||
const key = navKey;
|
|
||||||
row.click();
|
|
||||||
const header = [...treeHost.querySelectorAll<HTMLElement>(".tree-folder-header")].find(
|
|
||||||
(h) => h.dataset.navKey === key,
|
|
||||||
);
|
|
||||||
const revealed = header?.parentElement
|
|
||||||
? [...header.parentElement.querySelectorAll<HTMLElement>(NAV_SELECTOR)][0]
|
|
||||||
: undefined;
|
|
||||||
navKey = revealed?.dataset.navKey ?? null;
|
|
||||||
applyNav();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Pointer hover previews only while Ctrl is held — plain hover was too
|
|
||||||
// disruptive when sweeping the sidebar. Keyboard walk (j/k) still previews
|
|
||||||
// unconditionally via applyNav.
|
|
||||||
let hovered: string | null = null;
|
|
||||||
const setHovered = (id: string | null) => {
|
|
||||||
if (hovered === id) return;
|
|
||||||
hovered = id;
|
|
||||||
opts.onPreview?.(id);
|
|
||||||
};
|
|
||||||
treeHost.addEventListener("pointerover", (e) => {
|
|
||||||
if (e.pointerType === "touch" || !e.ctrlKey) return;
|
|
||||||
const row = (e.target as HTMLElement | null)?.closest<HTMLElement>(".tree-leaf");
|
|
||||||
setHovered(row?.dataset.sessionId ?? null);
|
|
||||||
});
|
|
||||||
treeHost.addEventListener("pointerleave", (e) => {
|
|
||||||
if (e.pointerType === "touch") return;
|
|
||||||
setHovered(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Focusable as a unit: the walk moves a highlight, not DOM focus, so the
|
|
||||||
// keyboard stays here while j/k runs.
|
|
||||||
treeHost.tabIndex = -1;
|
|
||||||
// The keyboard walk's preview belongs to the list having the keyboard: once
|
|
||||||
// it doesn't, the content pane goes back to what the operator actually opened.
|
|
||||||
treeHost.addEventListener("blur", () => opts.onPreview?.(null));
|
|
||||||
|
|
||||||
/** The session behind the highlighted row, if the highlight is on a card. */
|
|
||||||
const highlightedSession = (): { row: HTMLElement; session: Session } | undefined => {
|
|
||||||
const row = rowFor(navKey);
|
|
||||||
const id = row?.dataset.sessionId;
|
|
||||||
if (!row || !id) return undefined;
|
|
||||||
const session = store.snapshot().sessions.get(id);
|
|
||||||
return session ? { row, session } : undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** `c`: collapse or expand the highlighted folder, the TUI's
|
|
||||||
* `ToggleSidebarGroup`. Clicking the header is what toggles it; on a card
|
|
||||||
* the key does nothing rather than toggling some other folder. */
|
|
||||||
const toggleHighlightedFolder = () => {
|
|
||||||
const row = rowFor(navKey);
|
|
||||||
if (!row || !row.classList.contains("tree-folder-header")) return;
|
|
||||||
row.click();
|
|
||||||
applyNav();
|
|
||||||
};
|
|
||||||
|
|
||||||
// The session list's half of the command registry (#148). One set per
|
|
||||||
// sidebar, so a second sidebar (tests, hot reload) drives its own rows; the
|
|
||||||
// labels and chords still come from the one registry.
|
|
||||||
const listKeys = new CommandSet(
|
|
||||||
defineCommands({
|
|
||||||
"list.next": () => moveNav(1),
|
|
||||||
"list.prev": () => moveNav(-1),
|
|
||||||
"list.activate": () => activateNav(),
|
|
||||||
"list.toggleFolder": () => toggleHighlightedFolder(),
|
|
||||||
"list.rename": () => {
|
|
||||||
const hit = highlightedSession();
|
|
||||||
if (hit) beginRename(hit.row, hit.session);
|
|
||||||
},
|
|
||||||
"list.kill": () => {
|
|
||||||
const hit = highlightedSession();
|
|
||||||
if (hit) requestKill(hit.session, sessionName(hit.session));
|
|
||||||
},
|
|
||||||
"list.focusPane": () => document.dispatchEvent(new CustomEvent(FOCUS_PANE_EVENT)),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// The inline rename field lives inside a row and owns its own keys; the set's
|
|
||||||
// plain-key rule keeps this walk out of it.
|
|
||||||
treeHost.addEventListener("keydown", (e) => listKeys.handle(e));
|
|
||||||
|
|
||||||
/** Signature of what is currently on screen — see `treeSignature`. */
|
|
||||||
let renderedSignature: string | null = null;
|
|
||||||
|
|
||||||
const refresh = () => {
|
|
||||||
// Rebuilding the tree now would blow away the field being typed into.
|
|
||||||
if (renamingId !== null) return;
|
|
||||||
const state = store.snapshot();
|
|
||||||
const sessions = [...state.sessions.values()];
|
|
||||||
const tree = buildTree(sessions, homeDir);
|
|
||||||
const signature = sessions.length === 0 ? "EMPTY" : treeSignature(tree);
|
|
||||||
if (signature === renderedSignature) {
|
|
||||||
for (const row of treeHost.querySelectorAll<HTMLElement>(".tree-leaf")) {
|
|
||||||
const s = row.dataset.sessionId ? state.sessions.get(row.dataset.sessionId) : undefined;
|
|
||||||
if (s) leafPatches.get(row)?.(s);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
renderedSignature = signature;
|
|
||||||
treeHost.replaceChildren();
|
|
||||||
if (sessions.length === 0) {
|
|
||||||
treeHost.appendChild(renderEmpty(() => requestOpenSpawn()));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (tree.kind === "folder") {
|
|
||||||
for (const child of tree.children) treeHost.appendChild(renderNode(child, 0));
|
|
||||||
}
|
|
||||||
applyHighlights();
|
|
||||||
applyNav();
|
|
||||||
};
|
|
||||||
|
|
||||||
void getDaemonInfo()
|
|
||||||
.then((info) => {
|
|
||||||
homeDir = info.home_dir;
|
|
||||||
refresh();
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
/* absolute paths are a fine fallback */
|
|
||||||
});
|
|
||||||
|
|
||||||
// Dispatched when something outside the store changed what the tree should
|
|
||||||
// look like — a folder collapsed, a rename ended — none of which the store
|
|
||||||
// snapshot can show, so the signature check has to be bypassed.
|
|
||||||
const rerenderHandler = () => {
|
|
||||||
renderedSignature = null;
|
|
||||||
refresh();
|
|
||||||
};
|
|
||||||
document.addEventListener("catalog-rerender", rerenderHandler);
|
|
||||||
|
|
||||||
// The command bar's action page (#151) picks an action and hands it here:
|
|
||||||
// both flows are the ones the card already has, reached by session rather
|
|
||||||
// than by row.
|
|
||||||
const killHandler = (ev: Event) => {
|
|
||||||
const s = (ev as CustomEvent<Session>).detail;
|
|
||||||
requestKill(s, sessionName(s));
|
|
||||||
};
|
|
||||||
const renameHandler = (ev: Event) => {
|
|
||||||
const s = (ev as CustomEvent<Session>).detail;
|
|
||||||
const row = rowForSession(s);
|
|
||||||
if (row) beginRename(row, s);
|
|
||||||
};
|
|
||||||
document.addEventListener(KILL_SESSION_EVENT, killHandler);
|
|
||||||
document.addEventListener(RENAME_SESSION_EVENT, renameHandler);
|
|
||||||
|
|
||||||
/** The card a session is rendered as, expanding whatever folder hides it
|
|
||||||
* first: the rename prompt lives inside the card, so a collapsed folder
|
|
||||||
* would otherwise swallow the request. */
|
|
||||||
function rowForSession(s: Session): HTMLElement | null {
|
|
||||||
const find = () => treeHost.querySelector<HTMLElement>(`.tree-leaf[data-session-id="${s.id}"]`);
|
|
||||||
const found = find();
|
|
||||||
if (found) return found;
|
|
||||||
// Folder paths are the abbreviated directory's segments joined back up
|
|
||||||
// (see buildTree), so the session's own path is normalised the same way
|
|
||||||
// before it is compared against them.
|
|
||||||
const cwd = sessionTreePath(s);
|
|
||||||
const path = (cwd ? abbreviateCwd(cwd, homeDir) : "").split("/").filter(Boolean).join("/");
|
|
||||||
for (const folder of [...collapsed]) {
|
|
||||||
if (path === folder || path.startsWith(`${folder}/`)) collapsed.delete(folder);
|
|
||||||
}
|
|
||||||
rerenderHandler();
|
|
||||||
return find();
|
|
||||||
}
|
|
||||||
|
|
||||||
// "Spawn similar" (#28) is dispatched here and handled in main.ts, where the
|
|
||||||
// multi-view controller can seed the spawn modal with the session's dir/command.
|
|
||||||
|
|
||||||
const unsubscribe = store.subscribe(refresh);
|
|
||||||
|
|
||||||
// Contract granularity: re-render at least every 30s while visible so the
|
|
||||||
// duration cutover points land within one tick.
|
|
||||||
const clock = setInterval(() => {
|
|
||||||
if (document.visibilityState !== "visible") return;
|
|
||||||
refresh();
|
|
||||||
}, 30_000);
|
|
||||||
|
|
||||||
return {
|
|
||||||
el,
|
|
||||||
setActive(id: string | null) {
|
|
||||||
highlights = {
|
|
||||||
open: id ? new Set([id]) : new Set(),
|
|
||||||
focused: id,
|
|
||||||
};
|
|
||||||
applyHighlights();
|
|
||||||
},
|
|
||||||
setHighlights(h: SidebarHighlights) {
|
|
||||||
highlights = h;
|
|
||||||
applyHighlights();
|
|
||||||
},
|
|
||||||
setPreview(sessionId: string | null) {
|
|
||||||
previewing = sessionId;
|
|
||||||
applyHighlights();
|
|
||||||
},
|
|
||||||
focusList() {
|
|
||||||
// Start from the session on screen — that is where the user's attention
|
|
||||||
// already is — falling back to the standing highlight, then the top row.
|
|
||||||
const preferred = highlights.focused ? `session:${highlights.focused}` : null;
|
|
||||||
const start = rowFor(preferred) ?? rowFor(navKey) ?? navRows()[0];
|
|
||||||
navKey = start?.dataset.navKey ?? null;
|
|
||||||
applyNav();
|
|
||||||
treeHost.focus();
|
|
||||||
start?.scrollIntoView({ block: "nearest" });
|
|
||||||
},
|
|
||||||
hasListFocus() {
|
|
||||||
return treeHost.contains(document.activeElement);
|
|
||||||
},
|
|
||||||
dispose() {
|
|
||||||
clearInterval(clock);
|
|
||||||
status?.dispose();
|
|
||||||
// An unfinished rename would otherwise leave every later sidebar
|
|
||||||
// refusing to refresh.
|
|
||||||
renamingId = null;
|
|
||||||
document.removeEventListener("catalog-rerender", rerenderHandler);
|
|
||||||
document.removeEventListener(KILL_SESSION_EVENT, killHandler);
|
|
||||||
document.removeEventListener(RENAME_SESSION_EVENT, renameHandler);
|
|
||||||
unsubscribe();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user