diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml index fc61fbc..ac511f3 100644 --- a/.github/workflows/android-build.yml +++ b/.github/workflows/android-build.yml @@ -1,8 +1,6 @@ name: Android Build on: - push: - pull_request: workflow_dispatch: jobs: diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 7902791..7ce0ebc 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -1,8 +1,6 @@ name: Desktop Build on: - push: - pull_request: workflow_dispatch: jobs: diff --git a/.github/workflows/ios-build.yml b/.github/workflows/ios-build.yml index 6d8c3ac..543783e 100644 --- a/.github/workflows/ios-build.yml +++ b/.github/workflows/ios-build.yml @@ -1,8 +1,6 @@ name: iOS Build on: - push: - pull_request: workflow_dispatch: jobs: diff --git a/AGENT.md b/AGENT.md new file mode 100644 index 0000000..f01e876 --- /dev/null +++ b/AGENT.md @@ -0,0 +1,88 @@ +# AGENT.md + +## ๐Ÿš€ Setup Commands + +### Web Application +The web application is a React PWA located in the `web/` directory. + +```bash +cd web +pnpm install +# Start development server +pnpm dev +``` + +### Mobile & Desktop Application (Kotlin Multiplatform) +The native application is built with Kotlin Multiplatform and Compose Multiplatform. + +**Android:** +```bash +./gradlew :composeApp:installDebug +``` + +**Desktop (JVM):** +```bash +./gradlew :composeApp:run +``` + +**iOS:** +Open `iosApp/iosApp.xcodeproj` in Xcode or run via Android Studio configuration. + +--- + +## ๐ŸŽจ Code Style + +### Kotlin Multiplatform (KMP) +- **Package Name**: `dev.therealashik.client.jules` +- **UI Framework**: Compose Multiplatform (Material 3) +- **Shared Code**: Located in `composeApp/src/commonMain/kotlin` +- **Conventions**: + - Use `expect`/`actual` for platform-specific implementations. + - Follow standard Kotlin coding conventions. + - Use "Filled" or "Solid" style icons for visual weight. + +### Web Application (React) +- **Framework**: React 19 + TypeScript + Vite +- **Styling**: Tailwind CSS +- **Guidelines**: + - Refer to `web/AGENT.md` for detailed "Premium UI" and accessibility guidelines. + - Use `pnpm` for package management. + +--- + +## ๐Ÿงช Testing Instructions + +### Web Application +```bash +cd web +# Run unit tests (Vitest) +pnpm test + +# Run E2E/Visual tests (Playwright) +npx playwright test +``` + +### Kotlin Multiplatform +```bash +# Run all tests +./gradlew allTests + +# Run specific module tests +./gradlew :composeApp:testDebugUnitTest +``` + +--- + +## ๐Ÿ—๏ธ Architecture + +The repository is a monorepo containing: + +- **`composeApp/`**: The core Kotlin Multiplatform shared module. + - `src/commonMain`: Shared UI and business logic for Android, iOS, and Desktop. + - `src/androidMain`, `src/iosMain`, `src/jvmMain`: Platform-specific implementations. +- **`web/`**: A standalone React Progressive Web App (PWA) designed to interact with the Jules Google AI coding agent. +- **`iosApp/`**: The iOS entry point project (Swift/SwiftUI) that consumes the shared KMP framework. + +### Integration +- The projects share design principles (e.g., color palettes in `web/.Jules/palette.md` and `composeApp` theme) but currently operate as separate build artifacts. +- Both clients interact with the Jules Google AI API. diff --git a/build_log.txt b/build_log.txt deleted file mode 100644 index b20c185..0000000 --- a/build_log.txt +++ /dev/null @@ -1,66 +0,0 @@ -Calculating task graph as no cached configuration is available for tasks: composeApp:compileCommonMainKotlinMetadata -Type-safe project accessors is an incubating feature. - -> Configure project :composeApp -w: The 'org.jetbrains.kotlin.multiplatform' plugin deprecated compatibility with Android Gradle plugin: 'com.android.application' -The 'org.jetbrains.kotlin.multiplatform' plugin will not be compatible with 'com.android.application' starting with Android Gradle Plugin 9.0.0. - -Please change the structure of the your project and move the usage of 'com.android.application' into a separate subproject. The new subproject should add a dependency on this KMP subproject. - -Read more: https://kotl.in/kmp-project-structure-migration -Solution: Please change the structure of your project and move the usage of 'com.android.application' into a separate subproject. -See https://kotl.in/gradle/agp-new-kmp for more details. - - -> Task :composeApp:kmpPartiallyResolvedDependenciesChecker -> Task :composeApp:checkKotlinGradlePluginConfigurationErrors SKIPPED -> Task :composeApp:generateComposeResClass UP-TO-DATE -> Task :composeApp:copyNonXmlValueResourcesForCommonMain UP-TO-DATE -> Task :composeApp:convertXmlValueResourcesForCommonMain NO-SOURCE -> Task :composeApp:prepareComposeResourcesTaskForCommonMain UP-TO-DATE -> Task :composeApp:generateResourceAccessorsForCommonMain UP-TO-DATE -> Task :composeApp:generateExpectResourceCollectorsForCommonMain UP-TO-DATE -> Task :composeApp:transformCommonMainDependenciesMetadata - -> Task :composeApp:compileCommonMainKotlinMetadata FAILED -e: file:///C:/Users/Ashik/Desktop/JulesClient/composeApp/src/commonMain/kotlin/dev/therealashik/client/jules/App.kt:46:43 None of the following candidates is applicable: - -fun Modifier.background(color: Color, shape: Shape = ...): Modifier: - Argument type mismatch: actual type is 'Modifier', but 'Color' was expected. - -fun Modifier.background(brush: Brush, shape: Shape = ..., alpha: Float = ...): Modifier: - Argument type mismatch: actual type is 'Modifier', but 'Brush' was expected. -e: file:///C:/Users/Ashik/Desktop/JulesClient/composeApp/src/commonMain/kotlin/dev/therealashik/client/jules/App.kt:46:54 Unresolved reference 'MaterialTheme'. -e: file:///C:/Users/Ashik/Desktop/JulesClient/composeApp/src/commonMain/kotlin/dev/therealashik/client/jules/ui/JulesTheme.kt:32:42 Unresolved reference 'DarkColorScheme'. -e: file:///C:/Users/Ashik/Desktop/JulesClient/composeApp/src/commonMain/kotlin/dev/therealashik/client/jules/ui/screens/RepositoryView.kt:58:21 Unresolved reference 'Brush'. -e: file:///C:/Users/Ashik/Desktop/JulesClient/composeApp/src/commonMain/kotlin/dev/therealashik/client/jules/ui/screens/RepositoryView.kt:77:34 Unresolved reference 'FontFamily'. -e: file:///C:/Users/Ashik/Desktop/JulesClient/composeApp/src/commonMain/kotlin/dev/therealashik/client/jules/ui/screens/SessionView.kt:226:17 No value passed for parameter 'onSendMessage'. -e: file:///C:/Users/Ashik/Desktop/JulesClient/composeApp/src/commonMain/kotlin/dev/therealashik/client/jules/ui/screens/SettingsView.kt:145:42 None of the following candidates is applicable: - -fun Modifier.background(color: Color, shape: Shape = ...): Modifier: - Argument type mismatch: actual type is 'Modifier', but 'Color' was expected. - -fun Modifier.background(brush: Brush, shape: Shape = ..., alpha: Float = ...): Modifier: - Argument type mismatch: actual type is 'Modifier', but 'Brush' was expected. -e: file:///C:/Users/Ashik/Desktop/JulesClient/composeApp/src/commonMain/kotlin/dev/therealashik/client/jules/ui/screens/SettingsView.kt:145:60 Function invocation 'background(...)' expected. -e: file:///C:/Users/Ashik/Desktop/JulesClient/composeApp/src/commonMain/kotlin/dev/therealashik/client/jules/ui/screens/SettingsView.kt:148:76 Unresolved reference 'primary'. -e: file:///C:/Users/Ashik/Desktop/JulesClient/composeApp/src/commonMain/kotlin/dev/therealashik/client/jules/ui/screens/SettingsView.kt:157:64 Unresolved reference 'primary'. - -[Incubating] Problems report is available at: file:///C:/Users/Ashik/Desktop/JulesClient/build/reports/problems/problems-report.html - -FAILURE: Build failed with an exception. - -* What went wrong: -Execution failed for task ':composeApp:compileCommonMainKotlinMetadata'. -> A failure occurred while executing org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction - > Compilation error. See log for more details - -* Try: -> Run with --stacktrace option to get the stack trace. -> Run with --info or --debug option to get more log output. -> Run with --scan to get full insights. -> Get more help at https://help.gradle.org. - -BUILD FAILED in 25s -8 actionable tasks: 3 executed, 5 up-to-date -Configuration cache entry stored. diff --git a/build_log_2.txt b/build_log_2.txt deleted file mode 100644 index b250de6..0000000 --- a/build_log_2.txt +++ /dev/null @@ -1,37 +0,0 @@ -Reusing configuration cache. -> Task :composeApp:kmpPartiallyResolvedDependenciesChecker -> Task :composeApp:checkKotlinGradlePluginConfigurationErrors SKIPPED -> Task :composeApp:generateExpectResourceCollectorsForCommonMain UP-TO-DATE -> Task :composeApp:convertXmlValueResourcesForCommonMain NO-SOURCE -> Task :composeApp:copyNonXmlValueResourcesForCommonMain UP-TO-DATE -> Task :composeApp:prepareComposeResourcesTaskForCommonMain UP-TO-DATE -> Task :composeApp:generateResourceAccessorsForCommonMain UP-TO-DATE -> Task :composeApp:transformCommonMainDependenciesMetadata UP-TO-DATE -> Task :composeApp:generateComposeResClass UP-TO-DATE - -> Task :composeApp:compileCommonMainKotlinMetadata -w: file:///C:/Users/Ashik/Desktop/JulesClient/composeApp/src/commonMain/kotlin/dev/therealashik/client/jules/Settings.kt:3:1 'expect'/'actual' classes (including interfaces, objects, annotations, enums, and 'actual' typealiases) are in Beta. Consider using the '-Xexpect-actual-classes' flag to suppress this warning. Also see: https://youtrack.jetbrains.com/issue/KT-61573 -w: file:///C:/Users/Ashik/Desktop/JulesClient/composeApp/src/commonMain/kotlin/dev/therealashik/client/jules/ui/components/Drawer.kt:128:17 'fun Divider(modifier: Modifier = ..., thickness: Dp = ..., color: Color = ...): Unit' is deprecated. Renamed to HorizontalDivider. -w: file:///C:/Users/Ashik/Desktop/JulesClient/composeApp/src/commonMain/kotlin/dev/therealashik/client/jules/ui/components/Drawer.kt:357:73 'val Icons.Filled.KeyboardArrowRight: ImageVector' is deprecated. Use the AutoMirrored version at Icons.AutoMirrored.Filled.KeyboardArrowRight. -w: file:///C:/Users/Ashik/Desktop/JulesClient/composeApp/src/commonMain/kotlin/dev/therealashik/client/jules/ui/components/Header.kt:180:37 'fun Divider(modifier: Modifier = ..., thickness: Dp = ..., color: Color = ...): Unit' is deprecated. Renamed to HorizontalDivider. -w: file:///C:/Users/Ashik/Desktop/JulesClient/composeApp/src/commonMain/kotlin/dev/therealashik/client/jules/ui/components/Header.kt:234:13 'fun Divider(modifier: Modifier = ..., thickness: Dp = ..., color: Color = ...): Unit' is deprecated. Renamed to HorizontalDivider. -w: file:///C:/Users/Ashik/Desktop/JulesClient/composeApp/src/commonMain/kotlin/dev/therealashik/client/jules/ui/components/InputArea.kt:173:39 'val Icons.Filled.ArrowForward: ImageVector' is deprecated. Use the AutoMirrored version at Icons.AutoMirrored.Filled.ArrowForward. -w: file:///C:/Users/Ashik/Desktop/JulesClient/composeApp/src/commonMain/kotlin/dev/therealashik/client/jules/ui/components/InputArea.kt:351:96 'val Icons.Filled.Send: ImageVector' is deprecated. Use the AutoMirrored version at Icons.AutoMirrored.Filled.Send. -w: file:///C:/Users/Ashik/Desktop/JulesClient/composeApp/src/commonMain/kotlin/dev/therealashik/client/jules/ui/components/InputArea.kt:436:52 'val Icons.Filled.ArrowForward: ImageVector' is deprecated. Use the AutoMirrored version at Icons.AutoMirrored.Filled.ArrowForward. -w: file:///C:/Users/Ashik/Desktop/JulesClient/composeApp/src/commonMain/kotlin/dev/therealashik/client/jules/ui/components/ProactiveSection.kt:122:40 'val Icons.Filled.List: ImageVector' is deprecated. Use the AutoMirrored version at Icons.AutoMirrored.Filled.List. -w: file:///C:/Users/Ashik/Desktop/JulesClient/composeApp/src/commonMain/kotlin/dev/therealashik/client/jules/ui/screens/RepositoryView.kt:86:17 'fun TabRow(selectedTabIndex: Int, modifier: Modifier = ..., containerColor: Color = ..., contentColor: Color = ..., indicator: ComposableFunction1, Unit> = ..., divider: ComposableFunction0 = ..., tabs: ComposableFunction0): Unit' is deprecated. Replaced with PrimaryTabRow and SecondaryTabRow. -w: file:///C:/Users/Ashik/Desktop/JulesClient/composeApp/src/commonMain/kotlin/dev/therealashik/client/jules/ui/screens/RepositoryView.kt:94:34 'fun Modifier.tabIndicatorOffset(currentTabPosition: TabPosition): Modifier' is deprecated. Solely for use alongside deprecated TabRowDefaults.Indicator method. For recommended PrimaryIndicator and SecondaryIndicator methods, please use TabIndicatorScope.tabIndicatorOffset method. -w: file:///C:/Users/Ashik/Desktop/JulesClient/composeApp/src/commonMain/kotlin/dev/therealashik/client/jules/ui/screens/SessionView.kt:482:31 Condition is always 'true'. -w: file:///C:/Users/Ashik/Desktop/JulesClient/composeApp/src/commonMain/kotlin/dev/therealashik/client/jules/ui/screens/SessionView.kt:488:27 Condition is always 'true'. -w: file:///C:/Users/Ashik/Desktop/JulesClient/composeApp/src/commonMain/kotlin/dev/therealashik/client/jules/ui/screens/SessionView.kt:506:78 Unnecessary safe call on a non-null receiver of type 'SessionFailed'. -w: file:///C:/Users/Ashik/Desktop/JulesClient/composeApp/src/commonMain/kotlin/dev/therealashik/client/jules/ui/screens/SessionView.kt:581:36 'val Icons.Filled.List: ImageVector' is deprecated. Use the AutoMirrored version at Icons.AutoMirrored.Filled.List. -w: file:///C:/Users/Ashik/Desktop/JulesClient/composeApp/src/commonMain/kotlin/dev/therealashik/client/jules/ui/screens/SessionView.kt:898:28 'val LocalClipboardManager: ProvidableCompositionLocal' is deprecated. Use LocalClipboard instead which supports suspend functions. -w: file:///C:/Users/Ashik/Desktop/JulesClient/composeApp/src/commonMain/kotlin/dev/therealashik/client/jules/ui/screens/SessionView.kt:973:44 'val Icons.Filled.OpenInNew: ImageVector' is deprecated. Use the AutoMirrored version at Icons.AutoMirrored.Filled.OpenInNew. -w: file:///C:/Users/Ashik/Desktop/JulesClient/composeApp/src/commonMain/kotlin/dev/therealashik/client/jules/ui/screens/SettingsView.kt:69:43 'val Icons.Filled.ArrowBack: ImageVector' is deprecated. Use the AutoMirrored version at Icons.AutoMirrored.Filled.ArrowBack. -w: file:///C:/Users/Ashik/Desktop/JulesClient/composeApp/src/commonMain/kotlin/dev/therealashik/client/jules/ui/screens/SettingsView.kt:277:21 'fun Divider(modifier: Modifier = ..., thickness: Dp = ..., color: Color = ...): Unit' is deprecated. Renamed to HorizontalDivider. -w: file:///C:/Users/Ashik/Desktop/JulesClient/composeApp/src/commonMain/kotlin/dev/therealashik/client/jules/viewmodel/SharedViewModel.kt:19:8 'typealias Clock = Clock' is deprecated. This type is deprecated in favor of `kotlin.time.Clock`. -w: file:///C:/Users/Ashik/Desktop/JulesClient/composeApp/src/commonMain/kotlin/dev/therealashik/client/jules/viewmodel/SharedViewModel.kt:21:8 'typealias Instant = Instant' is deprecated. This type is deprecated in favor of `kotlin.time.Instant`. - -BUILD SUCCESSFUL in 10s -8 actionable tasks: 2 executed, 6 up-to-date -Configuration cache entry reused. diff --git a/composeApp/src/androidMain/kotlin/dev/therealashik/client/jules/utils/FilePicker.android.kt b/composeApp/src/androidMain/kotlin/dev/therealashik/client/jules/utils/FilePicker.android.kt new file mode 100644 index 0000000..fa91be9 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/dev/therealashik/client/jules/utils/FilePicker.android.kt @@ -0,0 +1,67 @@ +package dev.therealashik.client.jules.utils + +import android.content.ContentResolver +import android.net.Uri +import android.provider.OpenableColumns +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class AndroidPlatformFile( + private val uri: Uri, + private val contentResolver: ContentResolver +) : PlatformFile { + override val name: String + get() { + var result: String? = null + if (uri.scheme == "content") { + val cursor = contentResolver.query(uri, null, null, null, null) + try { + if (cursor != null && cursor.moveToFirst()) { + val index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (index >= 0) { + result = cursor.getString(index) + } + } + } catch (e: Exception) { + // Ignore + } finally { + cursor?.close() + } + } + if (result == null) { + result = uri.path + val cut = result?.lastIndexOf('/') + if (cut != null && cut != -1) { + result = result?.substring(cut + 1) + } + } + return result ?: "unknown" + } + + override suspend fun readText(): String = withContext(Dispatchers.IO) { + contentResolver.openInputStream(uri)?.use { inputStream -> + inputStream.bufferedReader().use { it.readText() } + } ?: throw Exception("Cannot read file") + } +} + +@Composable +actual fun rememberFilePickerLauncher(onFilePicked: (PlatformFile) -> Unit): FilePickerLauncher { + val context = LocalContext.current + val contentResolver = context.contentResolver + val launcher = rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent()) { uri: Uri? -> + if (uri != null) { + onFilePicked(AndroidPlatformFile(uri, contentResolver)) + } + } + return remember { + FilePickerLauncher { + launcher.launch("*/*") + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/client/jules/api/GeminiService.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/client/jules/api/GeminiService.kt index 9a5c4e9..2bd124e 100644 --- a/composeApp/src/commonMain/kotlin/dev/therealashik/client/jules/api/GeminiService.kt +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/client/jules/api/GeminiService.kt @@ -9,6 +9,30 @@ import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* import kotlinx.serialization.json.Json +interface JulesApi { + fun setApiKey(key: String) + fun getApiKey(): String + suspend fun listSources(pageSize: Int = 50, pageToken: String? = null): ListSourcesResponse + suspend fun listAllSources(): List + suspend fun getSource(sourceName: String): JulesSource + suspend fun listSessions(pageSize: Int = 20, pageToken: String? = null): ListSessionsResponse + suspend fun listAllSessions(): List + suspend fun getSession(sessionName: String): JulesSession + suspend fun createSession( + prompt: String, + sourceName: String, + title: String? = null, + requirePlanApproval: Boolean = true, + automationMode: AutomationMode = AutomationMode.AUTO_CREATE_PR, + startingBranch: String = "main" + ): JulesSession + suspend fun updateSession(sessionName: String, updates: Map, updateMask: List): JulesSession + suspend fun deleteSession(sessionName: String) + suspend fun listActivities(sessionName: String, pageSize: Int = 50, pageToken: String? = null): ListActivitiesResponse + suspend fun sendMessage(sessionName: String, prompt: String) + suspend fun approvePlan(sessionName: String, planId: String? = null) +} + object GeminiService { private const val BASE_URL = "https://jules.googleapis.com/v1alpha" @@ -26,7 +50,6 @@ object GeminiService { install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true - prettyPrint = true isLenient = true encodeDefaults = true }) @@ -112,7 +135,7 @@ object GeminiService { suspend fun createSession( prompt: String, - sourceName: String, + sourceName: String, title: String? = null, requirePlanApproval: Boolean = true, automationMode: AutomationMode = AutomationMode.AUTO_CREATE_PR, @@ -216,3 +239,28 @@ object GeminiService { } } } + +object RealJulesApi : JulesApi { + override fun setApiKey(key: String) = GeminiService.setApiKey(key) + override fun getApiKey(): String = GeminiService.getApiKey() + override suspend fun listSources(pageSize: Int, pageToken: String?): ListSourcesResponse = GeminiService.listSources(pageSize, pageToken) + override suspend fun listAllSources(): List = GeminiService.listAllSources() + override suspend fun getSource(sourceName: String): JulesSource = GeminiService.getSource(sourceName) + override suspend fun listSessions(pageSize: Int, pageToken: String?): ListSessionsResponse = GeminiService.listSessions(pageSize, pageToken) + override suspend fun listAllSessions(): List = GeminiService.listAllSessions() + override suspend fun getSession(sessionName: String): JulesSession = GeminiService.getSession(sessionName) + override suspend fun createSession( + prompt: String, + sourceName: String, + title: String?, + requirePlanApproval: Boolean, + automationMode: AutomationMode, + startingBranch: String + ): JulesSession = GeminiService.createSession(prompt, sourceName, title, requirePlanApproval, automationMode, startingBranch) + + override suspend fun updateSession(sessionName: String, updates: Map, updateMask: List): JulesSession = GeminiService.updateSession(sessionName, updates, updateMask) + override suspend fun deleteSession(sessionName: String) = GeminiService.deleteSession(sessionName) + override suspend fun listActivities(sessionName: String, pageSize: Int, pageToken: String?): ListActivitiesResponse = GeminiService.listActivities(sessionName, pageSize, pageToken) + override suspend fun sendMessage(sessionName: String, prompt: String) = GeminiService.sendMessage(sessionName, prompt) + override suspend fun approvePlan(sessionName: String, planId: String?) = GeminiService.approvePlan(sessionName, planId) +} diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/client/jules/ui/components/InputArea.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/client/jules/ui/components/InputArea.kt index a6e087e..3700832 100644 --- a/composeApp/src/commonMain/kotlin/dev/therealashik/client/jules/ui/components/InputArea.kt +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/client/jules/ui/components/InputArea.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -30,8 +31,11 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import dev.therealashik.client.jules.model.AutomationMode import dev.therealashik.client.jules.model.JulesSource +import dev.therealashik.client.jules.utils.PlatformFile +import dev.therealashik.client.jules.utils.rememberFilePickerLauncher import dev.therealashik.client.jules.viewmodel.CreateSessionConfig import kotlinx.coroutines.delay +import kotlinx.coroutines.launch private val DEFAULT_PLACEHOLDERS = listOf( "Refactor this function...", @@ -74,8 +78,15 @@ fun InputArea( } val placeholderText = DEFAULT_PLACEHOLDERS[placeholderIndex] + // Attachments + val attachments = remember { mutableStateListOf() } + val filePicker = rememberFilePickerLauncher { file -> + attachments.add(file) + } + val scope = rememberCoroutineScope() + // Expansion Logic - val isExpanded = isFocused || input.isNotEmpty() + val isExpanded = isFocused || input.isNotEmpty() || attachments.isNotEmpty() // Branch Selection var selectedBranch by remember(currentSource) { @@ -109,7 +120,7 @@ fun InputArea( ) { // Plus Button IconButton( - onClick = { /* TODO: Attachments */ }, + onClick = { filePicker.launch() }, modifier = Modifier .size(36.dp) .clip(CircleShape) @@ -142,16 +153,34 @@ fun InputArea( ) // Send Button - val isEnabled = input.isNotBlank() && !isLoading + val isEnabled = (input.isNotBlank() || attachments.isNotEmpty()) && !isLoading IconButton( onClick = { if (isEnabled) { - if (onSendMessageMinimal != null) { - onSendMessageMinimal(input) - } else { - onSendMessage(input, CreateSessionConfig(startingBranch = selectedBranch)) + scope.launch { + var prompt = input + if (attachments.isNotEmpty()) { + val sb = StringBuilder() + attachments.forEach { file -> + val content = try { + file.readText() + } catch (e: Exception) { + "Error reading file: ${e.message}" + } + sb.append("File: ${file.name}\n```\n$content\n```\n\n") + } + val filesContent = sb.toString().trim() + prompt = if (prompt.isNotBlank()) "$prompt\n\n--- Attached Files ---\n$filesContent" else "--- Attached Files ---\n$filesContent" + } + + if (onSendMessageMinimal != null) { + onSendMessageMinimal(prompt) + } else { + onSendMessage(prompt, CreateSessionConfig(startingBranch = selectedBranch)) + } + input = "" + attachments.clear() } - input = "" } }, enabled = isEnabled || isLoading, @@ -232,6 +261,36 @@ fun InputArea( minLines = if (isExpanded) 3 else 1, maxLines = if (isExpanded) 10 else 1 ) + + // Attachments Display + if (attachments.isNotEmpty()) { + LazyRow( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(attachments) { file -> + InputChip( + selected = true, + onClick = { attachments.remove(file) }, + label = { Text(file.name, maxLines = 1, fontSize = 12.sp) }, + trailingIcon = { Icon(Icons.Default.Close, null, Modifier.size(14.dp)) }, + colors = InputChipDefaults.inputChipColors( + containerColor = Color(0xFF27272A), + labelColor = Color(0xFFE4E4E7), + selectedContainerColor = Color(0xFF27272A), + selectedLabelColor = Color(0xFFE4E4E7) + ), + border = InputChipDefaults.inputChipBorder( + enabled = true, + selected = true, + borderColor = Color.White.copy(alpha = 0.1f) + ) + ) + } + } + } } } @@ -251,7 +310,7 @@ fun InputArea( ) { // Plus Button IconButton( - onClick = { /* TODO: Attachments */ }, + onClick = { filePicker.launch() }, modifier = Modifier .size(32.dp) .padding(4.dp) @@ -400,39 +459,57 @@ fun InputArea( // Send Button IconButton( onClick = { - if (input.isNotBlank() && !isLoading) { - onSendMessage( - input, - CreateSessionConfig( - title = sessionTitle.takeIf { it.isNotBlank() }, - startingBranch = selectedBranch, - automationMode = when (selectedMode) { - "INTERACTIVE", "REVIEW" -> AutomationMode.NONE - else -> AutomationMode.AUTO_CREATE_PR + if ((input.isNotBlank() || attachments.isNotEmpty()) && !isLoading) { + scope.launch { + var prompt = input + if (attachments.isNotEmpty()) { + val sb = StringBuilder() + attachments.forEach { file -> + val content = try { + file.readText() + } catch (e: Exception) { + "Error reading file: ${e.message}" + } + sb.append("File: ${file.name}\n```\n$content\n```\n\n") } + val filesContent = sb.toString().trim() + prompt = if (prompt.isNotBlank()) "$prompt\n\n--- Attached Files ---\n$filesContent" else "--- Attached Files ---\n$filesContent" + } + + onSendMessage( + prompt, + CreateSessionConfig( + title = sessionTitle.takeIf { it.isNotBlank() }, + startingBranch = selectedBranch, + automationMode = when (selectedMode) { + "INTERACTIVE", "REVIEW" -> AutomationMode.NONE + else -> AutomationMode.AUTO_CREATE_PR + } + ) ) - ) - input = "" - sessionTitle = "" + input = "" + sessionTitle = "" + attachments.clear() + } } }, - enabled = input.isNotBlank() && !isLoading, + enabled = (input.isNotBlank() || attachments.isNotEmpty()) && !isLoading, modifier = Modifier .size(24.dp) .shadow( - elevation = if (input.isNotBlank()) 4.dp else 0.dp, + elevation = if (input.isNotBlank() || attachments.isNotEmpty()) 4.dp else 0.dp, spotColor = Color(0xFF6366F1).copy(alpha = 0.25f), shape = RoundedCornerShape(6.dp) ) .background( - if (input.isNotBlank()) Color(0xFF4F46E5) else Color(0xFF27272A), + if (input.isNotBlank() || attachments.isNotEmpty()) Color(0xFF4F46E5) else Color(0xFF27272A), RoundedCornerShape(6.dp) ) ) { if (isLoading) { CircularProgressIndicator(modifier = Modifier.size(12.dp), color = Color.White, strokeWidth = 2.dp) } else { - Icon(Icons.Default.ArrowForward, contentDescription = "Send", tint = if (input.isNotBlank()) Color.White else Color.Gray, modifier = Modifier.size(14.dp)) + Icon(Icons.Default.ArrowForward, contentDescription = "Send", tint = if (input.isNotBlank() || attachments.isNotEmpty()) Color.White else Color.Gray, modifier = Modifier.size(14.dp)) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/client/jules/utils/FilePicker.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/client/jules/utils/FilePicker.kt new file mode 100644 index 0000000..a6ee91e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/client/jules/utils/FilePicker.kt @@ -0,0 +1,15 @@ +package dev.therealashik.client.jules.utils + +import androidx.compose.runtime.Composable + +interface PlatformFile { + val name: String + suspend fun readText(): String +} + +fun interface FilePickerLauncher { + fun launch() +} + +@Composable +expect fun rememberFilePickerLauncher(onFilePicked: (PlatformFile) -> Unit): FilePickerLauncher diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/client/jules/viewmodel/SharedViewModel.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/client/jules/viewmodel/SharedViewModel.kt index 0c31a6a..49a3791 100644 --- a/composeApp/src/commonMain/kotlin/dev/therealashik/client/jules/viewmodel/SharedViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/client/jules/viewmodel/SharedViewModel.kt @@ -3,7 +3,8 @@ package dev.therealashik.client.jules.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dev.therealashik.client.jules.Settings -import dev.therealashik.client.jules.api.GeminiService +import dev.therealashik.client.jules.api.JulesApi +import dev.therealashik.client.jules.api.RealJulesApi import dev.therealashik.client.jules.model.* import dev.therealashik.client.jules.ui.ThemePreset import kotlinx.coroutines.Dispatchers @@ -49,9 +50,9 @@ data class JulesUiState( // ==================== VIEW MODEL ==================== -class SharedViewModel : ViewModel() { - - private val api = GeminiService +class SharedViewModel( + private val api: JulesApi = RealJulesApi +) : ViewModel() { private val _uiState = MutableStateFlow(JulesUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -322,6 +323,10 @@ class SharedViewModel : ViewModel() { session.state == SessionState.PLANNING || session.state == SessionState.IN_PROGRESS + if (session.state == SessionState.COMPLETED || session.state == SessionState.FAILED) { + stopPolling() + } + _uiState.update { state -> // Update session in the main list too if changed val updatedList = state.sessions.map { if (it.name == session.name) session else it } diff --git a/composeApp/src/iosMain/kotlin/dev/therealashik/client/jules/utils/FilePicker.ios.kt b/composeApp/src/iosMain/kotlin/dev/therealashik/client/jules/utils/FilePicker.ios.kt new file mode 100644 index 0000000..8e82abd --- /dev/null +++ b/composeApp/src/iosMain/kotlin/dev/therealashik/client/jules/utils/FilePicker.ios.kt @@ -0,0 +1,13 @@ +package dev.therealashik.client.jules.utils + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember + +@Composable +actual fun rememberFilePickerLauncher(onFilePicked: (PlatformFile) -> Unit): FilePickerLauncher { + return remember { + FilePickerLauncher { + println("File picking not implemented on iOS") + } + } +} diff --git a/composeApp/src/jvmMain/kotlin/dev/therealashik/client/jules/utils/FilePicker.jvm.kt b/composeApp/src/jvmMain/kotlin/dev/therealashik/client/jules/utils/FilePicker.jvm.kt new file mode 100644 index 0000000..07e01b9 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/dev/therealashik/client/jules/utils/FilePicker.jvm.kt @@ -0,0 +1,33 @@ +package dev.therealashik.client.jules.utils + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.awt.FileDialog +import java.io.File +import javax.swing.SwingUtilities + +class JvmPlatformFile(private val file: File) : PlatformFile { + override val name: String = file.name + override suspend fun readText(): String = withContext(Dispatchers.IO) { + file.readText() + } +} + +@Composable +actual fun rememberFilePickerLauncher(onFilePicked: (PlatformFile) -> Unit): FilePickerLauncher { + return remember { + FilePickerLauncher { + SwingUtilities.invokeLater { + val dialog = FileDialog(null as java.awt.Frame?, "Select File", FileDialog.LOAD) + dialog.isVisible = true + + if (dialog.directory != null && dialog.file != null) { + val file = File(dialog.directory, dialog.file) + onFilePicked(JvmPlatformFile(file)) + } + } + } + } +} diff --git a/web/App.tsx b/web/App.tsx index 4f35677..5efc59b 100644 --- a/web/App.tsx +++ b/web/App.tsx @@ -183,7 +183,9 @@ export default function App() { const isActive = ['QUEUED', 'PLANNING', 'IN_PROGRESS'].includes(sess.state); setIsProcessing(isActive); - if (activePollingSession.current === sessionName) { + const isTerminal = ['COMPLETED', 'FAILED'].includes(sess.state); + + if (activePollingSession.current === sessionName && !isTerminal) { pollTimeout.current = window.setTimeout(poll, 2000); } } catch (e) { diff --git a/web/tests/unit/AppPolling.test.tsx b/web/tests/unit/AppPolling.test.tsx new file mode 100644 index 0000000..2df91a6 --- /dev/null +++ b/web/tests/unit/AppPolling.test.tsx @@ -0,0 +1,128 @@ +import React from 'react'; +import { render, waitFor, act, screen } from '@testing-library/react'; +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import App from '../../App'; +import * as JulesApi from '../../services/geminiService'; +import { MemoryRouter } from 'react-router-dom'; +import { ThemeProvider } from '../../contexts/ThemeContext'; + +// Mock the API +vi.mock('../../services/geminiService', () => ({ + setApiKey: vi.fn(), + listSources: vi.fn(), + listAllSessions: vi.fn(), + getSession: vi.fn(), + listActivities: vi.fn(), +})); + +// Mock scrollIntoView +window.HTMLElement.prototype.scrollIntoView = vi.fn(); + +// Mock localStorage +const localStorageMock = (function() { + let store: Record = {}; + return { + getItem: (key: string) => store[key] || null, + setItem: (key: string, value: string) => { store[key] = value.toString(); }, + removeItem: (key: string) => { delete store[key]; }, + clear: () => { store = {}; } + }; +})(); + +Object.defineProperty(window, 'localStorage', { + value: localStorageMock +}); + +describe('App Polling Logic', () => { + beforeEach(() => { + // Use real timers by default to avoid waitFor issues + vi.useRealTimers(); + localStorageMock.clear(); + vi.clearAllMocks(); + + // Setup default mocks + localStorageMock.setItem('jules_api_key', 'test-key'); + (JulesApi.listSources as any).mockResolvedValue({ sources: [] }); + (JulesApi.listAllSessions as any).mockResolvedValue([]); + }); + + it('should stop polling when session is COMPLETED', async () => { + const sessionName = 'sessions/123'; + const completedSession = { + name: sessionName, + state: 'COMPLETED', + createTime: new Date().toISOString(), + prompt: 'test', + }; + + (JulesApi.getSession as any).mockResolvedValue(completedSession); + (JulesApi.listActivities as any).mockResolvedValue({ activities: [] }); + + // Render App with a route that triggers session loading + render( + + + + + + ); + + // Wait for initial load + await waitFor(() => { + expect(JulesApi.getSession).toHaveBeenCalledWith(sessionName); + }, { timeout: 3000 }); + + const initialCallCount = (JulesApi.getSession as any).mock.calls.length; + + // Wait for polling interval (2s) + some buffer + // Using real timers means we just wait + await act(async () => { + await new Promise(r => setTimeout(r, 2500)); + }); + + // Fixed behavior: it should NOT call getSession anymore because state is COMPLETED. + // Initial load calls getSession (1). + // Then startPolling calls poll -> getSession (2). + // Then poll checks state -> COMPLETED -> Stops. + // So count should remain equal to initialCallCount (which captures up to step 2 usually, or close to it). + + // Wait, initialCallCount was captured AFTER initial load. + // Let's rely on absolute numbers if possible or just equality. + // If initialCallCount captured the first poll call, then it should stay there. + + expect((JulesApi.getSession as any).mock.calls.length).toBe(initialCallCount); + }, 10000); + + it('should stop polling when session is FAILED', async () => { + const sessionName = 'sessions/456'; + const failedSession = { + name: sessionName, + state: 'FAILED', + createTime: new Date().toISOString(), + prompt: 'test', + }; + + (JulesApi.getSession as any).mockResolvedValue(failedSession); + (JulesApi.listActivities as any).mockResolvedValue({ activities: [] }); + + render( + + + + + + ); + + await waitFor(() => { + expect(JulesApi.getSession).toHaveBeenCalledWith(sessionName); + }, { timeout: 3000 }); + + const initialCallCount = (JulesApi.getSession as any).mock.calls.length; + + await act(async () => { + await new Promise(r => setTimeout(r, 2500)); + }); + + expect((JulesApi.getSession as any).mock.calls.length).toBe(initialCallCount); + }, 10000); +}); diff --git a/web/vitest.config.ts b/web/vitest.config.ts index 2ec1093..e767d01 100644 --- a/web/vitest.config.ts +++ b/web/vitest.config.ts @@ -5,7 +5,7 @@ export default defineConfig({ plugins: [react()], test: { environment: 'jsdom', - include: ['tests/unit/**/*.test.ts'], + include: ['tests/unit/**/*.test.{ts,tsx}'], globals: true, setupFiles: [], // Add setup file if needed for jest-dom matchers, but simple tests might not need it yet },