diff --git a/.github/workflows/pr-review-gate.yml b/.github/workflows/pr-review-gate.yml new file mode 100644 index 00000000..58f7f017 --- /dev/null +++ b/.github/workflows/pr-review-gate.yml @@ -0,0 +1,34 @@ +name: PR Review Gate + +on: + pull_request: + # "edited" re-runs the gate when the PR description changes, so the review report can be + # added after the PR exists and the check turns green without a new push. + types: [opened, edited, synchronize, reopened] + +permissions: + contents: read + +jobs: + review-gate: + runs-on: ubuntu-latest + # Weblate opens automated translation PRs; they cannot run the agent-side review skill. + if: github.event.pull_request.user.login != 'weblate' + steps: + - name: Require an approved pre-PR review + env: + PR_BODY: ${{ github.event.pull_request.body }} + run: | + if printf '%s' "$PR_BODY" | grep -qi 'pre-pr-review: approved'; then + echo "Approved pre-PR review is recorded in the PR description." + else + echo "::error::This PR has no approved pre-PR review." + echo "" + echo "Every PR must be reviewed by the repo-reviewer subagent before it is opened." + echo "Run the 'pre-pr-review' skill (see .opencode/skills/pre-pr-review/SKILL.md):" + echo " 1. Let the repo-reviewer subagent review the branch diff against origin/main." + echo " 2. Fix any blocking findings and re-review until the verdict is APPROVE." + echo " 3. Paste the reviewer's report into this PR's description inside the marker:" + echo " " + exit 1 + fi diff --git a/.opencode/agents/repo-reviewer.md b/.opencode/agents/repo-reviewer.md new file mode 100644 index 00000000..5bee67d4 --- /dev/null +++ b/.opencode/agents/repo-reviewer.md @@ -0,0 +1,96 @@ +--- +description: Read-only code reviewer with deep knowledge of the AndCode Android repository. Use via the pre-pr-review skill before opening a pull request. +mode: subagent +temperature: 0.1 +color: accent +permission: + edit: deny + webfetch: deny + task: deny + bash: + "*": deny + "git status*": allow + "git log*": allow + "git diff*": allow + "git show*": allow + "git branch*": allow + "git fetch*": allow + "ls*": allow + "rg *": allow + "grep *": allow + "cat *": allow + "wc *": allow +--- + +You are the dedicated code reviewer for this repository (AndCode). You review changes like a +maintainer who knows the codebase inside out: you verify claims against the actual code instead of +trusting the diff alone. You never modify files; you only read and report. + +## Repository map (verify details against the code when reviewing) + +- Android app, single Gradle module `app/`, Kotlin + Jetpack Compose, package root + `com.yugahashimoto.andcode`. JDK 17, AGP 8.x, compileSdk 35. +- `feature/` packages own screens and ViewModels: `chat`, `settings`, `workspace`, `onboarding`, + `schedule`, `assistant` (voice), `wakeword` (Vosk), `widget`, `activity`. +- `runtime/` owns agent backends: `OpenCodeBackend` interface; `runtime/local/` runs a PRoot-based + Linux environment on-device (`LocalRuntimeManager`, `LocalRuntimeInstaller`, + `ClaudeCodeController`, `AntigravityController`). Remote targets exist too. +- `core/` holds cross-cutting code: `core/api` (OpenCode HTTP/SSE client), `core/locale` + (app-language switching), `core/notification`, `core/diagnostics`, `core/security`. +- DI is Koin (`di/` modules) plus hand-rolled `ViewModelFactory` inside composables; both + construction paths must stay in sync when a ViewModel gains a dependency. +- `runtime_tools/` and `scripts/` generate the Android runtime assets; `pages/` is the website. + +## Hard rules of this repo (flag violations as blocking) + +1. **i18n**: English source is `app/src/main/res/values/strings.xml`; every key must also exist in + every `values-*/strings.xml` (ar, es, fr, ja, pt-rBR, ru, zh-rCN) unless marked + `translatable="false"` — `.github/workflows/i18n-check.yml` fails otherwise. No hardcoded + user-visible text in Kotlin or XML: use `stringResource`/`getString`. LLM prompts and log + messages stay English. ViewModels receive user-visible messages through constructor injection + with an English default (see `WorkspaceViewModel.incompleteConnectionMessage`, + `McpViewModel.authNotRemovedMessage`) or a `*Messages` interface with an + `Android*Messages(context)` implementation (see `LocalRuntimeMessages`). +2. **Formatting/static analysis**: spotless (ktlint 1.2.1) and detekt (`config/detekt/`) run in CI; + code must pass `./gradlew detekt spotlessCheck`. +3. **Tests**: JUnit4 unit tests under `app/src/test`; behavior changes need test updates. +4. **No secrets** in code or config; GitHub OAuth client id comes from build config/env. +5. **Comments**: the repo favors explanatory comments for non-obvious decisions; do not demand + their removal, and do not demand adding boilerplate comments. +6. **Worktree rule**: changes must live on a branch created by `scripts/new-worktree.sh` + (based on `origin/main`), never directly on the main working tree. + +## Review procedure + +1. Establish scope: `git fetch origin main` then `git diff --stat origin/main...HEAD` and the full + `git diff origin/main...HEAD`. Read every hunk. +2. For each hunk, open the surrounding code (`rg`, reads) to check: callers, tests, DI wiring, + resource keys in all 8 locale files, and any interface/implementation pairs. +3. Evaluate against, in order: correctness, regressions in adjacent behavior, i18n/localization, + Compose recomposition and state pitfalls, concurrency (Flow/coroutine scope leaks), resource + leaks (recognizers, receivers, streams), security (secrets, path traversal, intent handling), + test coverage, repo conventions above. +4. Do not nitpick style that spotless/detekt already enforces. Do not relitigate established + patterns listed above. Prefer fewer, high-signal findings. + +## Output format (mandatory) + +Respond in Japanese (the maintainer's language) with exactly this structure: + +``` +判定: APPROVE | REQUEST_CHANGES + +## ブロッカー +- : +(なければ「なし」) + +## 提案(非ブロッキング) +- ... +(なければ「なし」) + +## チェック済み項目 +- +``` + +Use `REQUEST_CHANGES` if and only if there is at least one ブロッカー. A finding is a ブロッカー +when it would fail CI, break behavior, regress localization, or violate the hard rules above. diff --git a/.opencode/skills/pre-pr-review/SKILL.md b/.opencode/skills/pre-pr-review/SKILL.md new file mode 100644 index 00000000..38504525 --- /dev/null +++ b/.opencode/skills/pre-pr-review/SKILL.md @@ -0,0 +1,70 @@ +--- +name: pre-pr-review +description: Mandatory review gate before opening a pull request in this repository. Runs local CI gates and the repo-reviewer subagent on the full branch diff, iterates until the reviewer approves, and records the verdict in the PR description. Use whenever you are about to create a PR. +license: MIT +--- + +# Pre-PR review (mandatory) + +This repository requires an approved review BEFORE any pull request is created. The reviewer is +the `repo-reviewer` subagent, which carries the repository's architecture map and hard rules. The +gate is also enforced in CI (`.github/workflows/pr-review-gate.yml`): a PR whose description does +not contain an approved review report fails the check. + +## Workflow + +1. **Sync and scope the diff** + + ```bash + git fetch origin main + git diff --stat origin/main...HEAD + ``` + + If the branch has no commits ahead of `origin/main`, stop: there is nothing to review. + +2. **Run the local gates first** (cheap failures should never reach the reviewer): + + ```bash + ./gradlew detekt spotlessCheck + ``` + + If `app/src/main/res/values*/strings.xml` changed, also run the key-parity check from + `.github/workflows/i18n-check.yml` (every source key must exist in every locale file). + A full Gradle build may be impossible on-device (x86_64 aapt2 on an arm64 device); that is + expected — CI compiles. Fix everything that can run locally. + +3. **Invoke the reviewer** with the task tool, subagent type `repo-reviewer`. Prompt it with: + + ``` + このブランチ(、base: origin/main)のPR前レビューをお願いします。 + 変更概要: + ``` + + The subagent reviews `git diff origin/main...HEAD` itself; do not paste the whole diff into the + prompt. + +4. **Handle the verdict** + + - `REQUEST_CHANGES`: fix every ブロッカー, then re-run from step 3. Repeat until `APPROVE`. + Never open the PR while a ブロッカー is outstanding. + - `APPROVE`: proceed. Consider 提案(非ブロッキング) items; apply the cheap, safe ones. + +5. **Record the verdict in the PR description.** The PR body must contain the reviewer's report + verbatim inside the marker block below — the CI gate looks for the first line: + + ```markdown + + ## Pre-PR review + + + ``` + + If a later push changes the branch materially, re-run this skill and update the block + (`pre-pr-review: approved` must stay truthful for the HEAD commit). + +## Rules + +- Do not skip, summarize away, or forge the reviewer report; the block must be the subagent's + actual output for the current HEAD. +- If the `repo-reviewer` subagent is unavailable, say so and stop — do not open the PR silently. +- Trivial bot PRs (e.g. Weblate translation sync) are exempt and are skipped by the CI gate. diff --git a/AGENTS.md b/AGENTS.md index 5dd350e2..70e7de53 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,11 @@ When working on a multi-step task, use the todo feature to track progress. When work is complete: -1. Create a pull request against `main`. -2. Wait for CI to pass. -3. Merge the PR once CI passes. +1. **Run the mandatory pre-PR review.** Load the `pre-pr-review` skill and follow it exactly: + the `repo-reviewer` subagent must review the full branch diff against `origin/main`, and every + blocking finding must be fixed and re-reviewed until the verdict is `APPROVE`. NEVER open a PR + without this. The CI `PR Review Gate` check fails any PR whose description does not contain the + approved review report (``). +2. Create a pull request against `main`, including the reviewer's report as the skill describes. +3. Wait for CI to pass. +4. Merge the PR once CI passes. diff --git a/app/src/main/java/com/yugahashimoto/andcode/di/ViewModelModule.kt b/app/src/main/java/com/yugahashimoto/andcode/di/ViewModelModule.kt index d1fa443c..3f1d11b0 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/di/ViewModelModule.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/di/ViewModelModule.kt @@ -39,6 +39,8 @@ val viewModelModule = settings = get(), registry = get(), voskModels = get(), + providerDisconnectRejectedMessage = + androidContext().getString(com.yugahashimoto.andcode.R.string.provider_disconnect_rejected), ) } diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/assistant/SpeechRecognizerManager.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/assistant/SpeechRecognizerManager.kt index 25bf3cce..0b904f42 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/assistant/SpeechRecognizerManager.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/assistant/SpeechRecognizerManager.kt @@ -19,7 +19,7 @@ private const val TAG = "SpeechRecognizerManager" private const val CLEANUP_DELAY_MS = 300L private const val MAX_RESULTS = 3 -// A short pause is common inside a Japanese sentence. Giving the recognizer more time here +// A short pause is common in the middle of a sentence. Giving the recognizer more time here // avoids finalizing a segment before the user has finished the thought; chat then starts the next // segment after a genuine recognition result so long dictation remains in one composer value. // @@ -29,22 +29,24 @@ private const val MAX_RESULTS = 3 private const val SILENCE_LENGTH_MS = 3000 /** - * 音声認識マネージャー + * Manages the platform speech recognizer. */ class SpeechRecognizerManager(private val context: Context) { private var recognizer: SpeechRecognizer? = null /** - * 音声認識を利用可能かチェック + * Checks whether speech recognition is available on this device. */ fun isAvailable(): Boolean { return SpeechRecognizer.isRecognitionAvailable(context) } /** - * 音声認識を開始し、結果をFlowで返す + * Starts listening and returns the results as a Flow. The caller must pass the user's + * language tag; there is no default so a missing locale can never silently fall back to + * the wrong language. */ - fun startListening(language: String = "ja-JP"): Flow = + fun startListening(language: String): Flow = callbackFlow { Log.d(TAG, "startListening called, isAvailable=${isAvailable()}") @@ -195,7 +197,7 @@ class SpeechRecognizerManager(private val context: Context) { } /** - * 音声認識の結果 + * A speech recognition result. */ sealed interface SpeechResult { data object Ready : SpeechResult diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt index 98435aee..c870479e 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt @@ -1021,6 +1021,9 @@ private fun ChatComposer( ) { Column(modifier = Modifier.padding(vertical = 4.dp)) { filtered.forEach { suggestion -> + val description = + suggestion.descriptionRes?.let { stringResource(it) } + ?: suggestion.description DropdownMenuItem( text = { Row( @@ -1033,7 +1036,7 @@ private fun ChatComposer( fontWeight = FontWeight.SemiBold, ) Text( - text = suggestion.description, + text = description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/chat/SessionHandoff.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/SessionHandoff.kt index c47a603f..917d01d6 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/chat/SessionHandoff.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/SessionHandoff.kt @@ -1,7 +1,7 @@ package com.yugahashimoto.andcode.feature.chat private const val HANDOFF_HEADER = - "以下は別の実行先から引き継いだ会話の要約です。続きから対応してください。" + "Below is a summary of a conversation handed over from another session. Continue from where it left off." fun buildHandoffPrompt( messages: List, @@ -11,7 +11,7 @@ fun buildHandoffPrompt( messages.mapNotNull { message -> val text = message.text.trim() if (text.isEmpty()) return@mapNotNull null - val role = if (message.isUser) "ユーザー" else "アシスタント" + val role = if (message.isUser) "User" else "Assistant" "$role: $text" } if (lines.isEmpty()) return HANDOFF_HEADER diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/chat/SlashCommandRegistry.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/SlashCommandRegistry.kt index 783ce8ac..530e0ddf 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/chat/SlashCommandRegistry.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/SlashCommandRegistry.kt @@ -1,5 +1,7 @@ package com.yugahashimoto.andcode.feature.chat +import androidx.annotation.StringRes +import com.yugahashimoto.andcode.R import com.yugahashimoto.andcode.core.api.OpenCodeCommand import com.yugahashimoto.andcode.core.api.OpenCodeSkill @@ -7,7 +9,7 @@ enum class SlashAction { NEW_CHAT, CLEAR, MODEL, AGENT, ATTACH, HELP } data class SlashCommand( val name: String, - val description: String, + @StringRes val descriptionRes: Int, val action: SlashAction, ) @@ -15,14 +17,26 @@ data class SlashCommand( * One entry in the composer's slash-command popup: either an app-level command or a command/skill * the connected backend advertises. All of them end up inserting `/ ` into the input; the * send path then routes backend commands and skills through the runtime's command handling. + * + * App commands carry a string resource so their description follows the app language; backend + * entries carry the text the backend advertised. */ sealed interface SlashSuggestion { val name: String val description: String + @get:StringRes + val descriptionRes: Int? + get() = null + + /** + * [description] is empty by design: UI must resolve [descriptionRes] so the text follows the + * app language. Only backend entries carry literal text. + */ data class App(val command: SlashCommand) : SlashSuggestion { override val name: String = command.name - override val description: String = command.description + override val description: String = "" + override val descriptionRes: Int = command.descriptionRes } data class Backend( @@ -35,12 +49,12 @@ sealed interface SlashSuggestion { object SlashCommandRegistry { val commands: List = listOf( - SlashCommand("/new", "Start a new session", SlashAction.NEW_CHAT), - SlashCommand("/clear", "Clear current conversation", SlashAction.CLEAR), - SlashCommand("/model", "Switch model", SlashAction.MODEL), - SlashCommand("/agent", "Switch agent", SlashAction.AGENT), - SlashCommand("/attach", "Attach a file", SlashAction.ATTACH), - SlashCommand("/help", "Show help", SlashAction.HELP), + SlashCommand("/new", R.string.slash_desc_new, SlashAction.NEW_CHAT), + SlashCommand("/clear", R.string.slash_desc_clear, SlashAction.CLEAR), + SlashCommand("/model", R.string.slash_desc_model, SlashAction.MODEL), + SlashCommand("/agent", R.string.slash_desc_agent, SlashAction.AGENT), + SlashCommand("/attach", R.string.slash_desc_attach, SlashAction.ATTACH), + SlashCommand("/help", R.string.slash_desc_help, SlashAction.HELP), ) /** diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/settings/AppearanceSettings.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/settings/AppearanceSettings.kt deleted file mode 100644 index 56c4973b..00000000 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/settings/AppearanceSettings.kt +++ /dev/null @@ -1,103 +0,0 @@ -package com.yugahashimoto.andcode.feature.settings - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -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.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.RadioButton -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.unit.dp -import com.yugahashimoto.andcode.ui.theme.ClaudeTheme -import com.yugahashimoto.andcode.ui.theme.DarkTheme -import com.yugahashimoto.andcode.ui.theme.GhosttyTheme -import com.yugahashimoto.andcode.ui.theme.LightTheme -import com.yugahashimoto.andcode.ui.theme.MidnightTheme -import com.yugahashimoto.andcode.ui.theme.ThemeColors -import com.yugahashimoto.andcode.ui.theme.ZincTheme - -@Composable -fun ThemePickerDialog( - currentTheme: String, - onThemeChange: (String) -> Unit, - onDismiss: () -> Unit, -) { - val themes = - listOf( - "dark" to DarkTheme, - "light" to LightTheme, - "zinc" to ZincTheme, - "midnight" to MidnightTheme, - "claude" to ClaudeTheme, - "ghostty" to GhosttyTheme, - "auto" to DarkTheme, - ) - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Theme") }, - text = { - Column { - themes.forEach { (key, colors) -> - Row( - modifier = - Modifier - .fillMaxWidth() - .clickable { onThemeChange(key) } - .padding(vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - RadioButton( - selected = currentTheme == key, - onClick = { onThemeChange(key) }, - ) - Text( - text = key.replaceFirstChar { it.uppercase() }, - modifier = - Modifier - .weight(1f) - .padding(start = 8.dp), - ) - ThemeColorPreview(colors) - } - } - } - }, - confirmButton = { - TextButton(onClick = onDismiss) { - Text("Confirm") - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text("Cancel") - } - }, - ) -} - -@Composable -private fun ThemeColorPreview(colors: ThemeColors) { - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - listOf(colors.surface0, colors.accent, colors.foreground).forEach { color -> - Box( - modifier = - Modifier - .size(12.dp) - .clip(CircleShape) - .background(color), - ) - } - } -} diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/settings/McpScreen.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/settings/McpScreen.kt index 234aa8bb..8df64dee 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/settings/McpScreen.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/settings/McpScreen.kt @@ -37,6 +37,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -57,10 +58,19 @@ fun McpScreen( onOpenBrowser: (String) -> Unit, onBack: () -> Unit, ) { + val context = LocalContext.current val viewModel: McpViewModel = viewModel( key = "mcp-${agent.id}", - factory = ViewModelFactory { McpViewModel(registry, agent) }, + factory = + ViewModelFactory { + McpViewModel( + registry, + agent, + authNotRemovedMessage = context.getString(R.string.mcp_auth_not_removed), + authFailedTemplate = context.getString(R.string.mcp_auth_failed_status), + ) + }, ) val state by viewModel.state.collectAsState() diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/settings/McpViewModel.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/settings/McpViewModel.kt index 3c38c761..20d4c48f 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/settings/McpViewModel.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/settings/McpViewModel.kt @@ -48,11 +48,15 @@ data class McpUiState( class McpViewModel( private val backendProvider: (LocalAgent) -> OpenCodeBackend?, private val agent: LocalAgent = LocalAgent.OPEN_CODE, + private val authNotRemovedMessage: String = "Authentication was not removed", + private val authFailedTemplate: String = "Authentication failed: %1\$s", ) : ViewModel() { constructor( registry: RuntimeRegistry, agent: LocalAgent = LocalAgent.OPEN_CODE, - ) : this(registry::targetFor, agent) + authNotRemovedMessage: String = "Authentication was not removed", + authFailedTemplate: String = "Authentication failed: %1\$s", + ) : this(registry::targetFor, agent, authNotRemovedMessage, authFailedTemplate) private val _state = MutableStateFlow( @@ -110,7 +114,7 @@ class McpViewModel( runCatching { backend.removeMcpAuth(name) } .onSuccess { result -> _state.update { it.copy(isAuthenticating = false) } - if (result.success) refresh() else _state.update { it.copy(error = "Authentication was not removed") } + if (result.success) refresh() else _state.update { it.copy(error = authNotRemovedMessage) } } .onFailure { e -> _state.update { it.copy(error = e.message, isAuthenticating = false) } } } @@ -170,7 +174,7 @@ class McpViewModel( } else { _state.update { it.copy( - error = status.error ?: "Authentication failed: ${status.status}", + error = status.error ?: authFailedTemplate.format(status.status), isAuthenticating = false, ) } diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/settings/ServerInfoScreen.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/settings/ServerInfoScreen.kt index d8a97b8b..44908ebd 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/settings/ServerInfoScreen.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/settings/ServerInfoScreen.kt @@ -71,10 +71,11 @@ fun ServerInfoScreen( val state by viewModel.state.collectAsState() var selectedTab by remember { mutableIntStateOf(0) } val snackbarHostState = remember { SnackbarHostState() } + val configSavedMessage = stringResource(R.string.server_info_config_saved) LaunchedEffect(state.saveSuccess) { if (state.saveSuccess) { - snackbarHostState.showSnackbar("Config saved") + snackbarHostState.showSnackbar(configSavedMessage) viewModel.consumeSaveSuccess() } } diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/settings/SettingsViewModel.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/settings/SettingsViewModel.kt index 9046f88d..3309df62 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/settings/SettingsViewModel.kt @@ -97,6 +97,7 @@ class SettingsViewModel( private val settings: SecureSettingsRepository, private val registry: RuntimeRegistry, private val voskModels: VoskModelStore, + private val providerDisconnectRejectedMessage: String = "Provider disconnect was not accepted", ) : ViewModel() { private val settingsTick = MutableStateFlow(0) private val oauthState = MutableStateFlow(OAuthState()) @@ -555,7 +556,7 @@ class SettingsViewModel( } finishProviderAuth(ProviderAuthNotice.DISCONNECTED) } else { - oauthState.update { it.copy(message = "Provider disconnect was not accepted") } + oauthState.update { it.copy(message = providerDisconnectRejectedMessage) } } } .onFailure { error -> diff --git a/app/src/main/java/com/yugahashimoto/andcode/ui/AndCodeApp.kt b/app/src/main/java/com/yugahashimoto/andcode/ui/AndCodeApp.kt index dc3973e6..9e34d608 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/ui/AndCodeApp.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/ui/AndCodeApp.kt @@ -64,6 +64,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import com.yugahashimoto.andcode.AndCodeApplication +import com.yugahashimoto.andcode.BuildConfig import com.yugahashimoto.andcode.R import com.yugahashimoto.andcode.core.diagnostics.CrashLog import com.yugahashimoto.andcode.feature.activity.ActivityViewModel @@ -119,6 +120,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.util.UUID @@ -229,6 +231,7 @@ fun AndCodeApp( settings = app.settings, registry = app.runtimeRegistry, voskModels = app.voskModels, + providerDisconnectRejectedMessage = context.getString(R.string.provider_disconnect_rejected), ) }, ) @@ -1200,11 +1203,28 @@ fun AndCodeApp( } if (showDiagnostics) { + val runtimeStateFlow = + remember(selectedRuntime) { + selectedRuntime?.state ?: MutableStateFlow(RuntimeState.Disconnected) + } + val runtimeState = runtimeStateFlow.collectAsState().value DiagnosticsSheet( onDismiss = { showDiagnostics = false }, - appVersion = "0.3.0", - connectionStatus = "connected", - runtimeStatus = "ready", + appVersion = BuildConfig.VERSION_NAME, + connectionStatus = + when (runtimeState) { + is RuntimeState.Connected -> stringResource(R.string.connected_label) + RuntimeState.Connecting -> stringResource(R.string.runtime_status_starting) + else -> stringResource(R.string.disconnected_label) + }, + runtimeStatus = + when (runtimeState) { + RuntimeState.Disconnected -> stringResource(R.string.disconnected_label) + RuntimeState.Connecting -> stringResource(R.string.runtime_status_starting) + is RuntimeState.Connected -> stringResource(R.string.connected_version, runtimeState.version) + is RuntimeState.Unavailable -> runtimeState.reason + is RuntimeState.Failed -> runtimeState.message + }, ) } } @@ -1228,6 +1248,7 @@ private fun GithubCloneDialog( var isCloning by remember { mutableStateOf(false) } var error by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() + val cloneFailedMessage = stringResource(R.string.workspace_clone_failed) LaunchedEffect(githubConfigured) { if (githubConfigured) { @@ -1247,7 +1268,7 @@ private fun GithubCloneDialog( onDismiss() } else { error = result.output.lineSequence().lastOrNull { it.isNotBlank() } - ?: "Clone failed (${result.exitCode})" + ?: cloneFailedMessage.format(result.exitCode) isCloning = false } } diff --git a/app/src/main/java/com/yugahashimoto/andcode/ui/AppDrawerContent.kt b/app/src/main/java/com/yugahashimoto/andcode/ui/AppDrawerContent.kt index 7afda395..a05caeb2 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/ui/AppDrawerContent.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/ui/AppDrawerContent.kt @@ -695,11 +695,11 @@ private fun AppDrawerContentPreview() { AppDrawerContent( recentSessions = listOf( - DrawerRecentSession("1", "認証バグの調査", "3時間前", "/workspace/android-code"), - DrawerRecentSession("2", "READMEの更新", "昨日", "/workspace/android-code"), - DrawerRecentSession("3", "テスト失敗を修正", "2日前", "/workspace/api-server"), - DrawerRecentSession("4", "APIレスポンスを整理", "4日前", "/workspace/api-server"), - DrawerRecentSession("5", "依存関係を更新", "1週間前", null), + DrawerRecentSession("1", "Investigate auth bug", "3 hours ago", "/workspace/android-code"), + DrawerRecentSession("2", "Update README", "Yesterday", "/workspace/android-code"), + DrawerRecentSession("3", "Fix failing tests", "2 days ago", "/workspace/api-server"), + DrawerRecentSession("4", "Clean up API responses", "4 days ago", "/workspace/api-server"), + DrawerRecentSession("5", "Update dependencies", "1 week ago", null), ), workspaces = listOf( diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 0d203e92..8c5aeb70 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -75,6 +75,7 @@ URL البحث في المستودعات جارٍ تحميل المستودعات… + فشل الاستنساخ (رمز الخروج %1$d) خيارات إضافية إزالة من القائمة حذف الملفات @@ -569,6 +570,8 @@ أكمل المصادقة في المتصفح، ثم ألصق رمز التفويض. رمز التفويض إكمال + لم تتم إزالة المصادقة + فشلت المصادقة: %1$s إزالة الخادم %1$d أدوات اسم الخادم @@ -578,6 +581,7 @@ خوادم MCP معلومات الخادم + تم حفظ الإعدادات التكوين الموفرون الأوامر @@ -1034,6 +1038,7 @@ سؤال حدث غير مدعوم معلومات الاتصال غير مكتملة + لم يتم قبول فصل المزود المتصفح الضيف المتصفح الضيف http://127.0.0.1:port diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 5b337197..e7ebd5f4 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -75,6 +75,7 @@ URL Buscar repositorios Cargando repositorios… + Error al clonar (código de salida %1$d) Más opciones Quitar de la lista Eliminar archivos @@ -569,6 +570,8 @@ Completa la autenticación en el navegador y pega el código de autorización. Código de autorización Completar + No se eliminó la autenticación + Error de autenticación: %1$s Eliminar servidor %1$d herramientas Nombre del servidor @@ -578,6 +581,7 @@ Servidores MCP Información del servidor + Configuración guardada Configuración Proveedores Comandos @@ -1034,6 +1038,7 @@ Pregunta Evento no compatible La información de conexión está incompleta + No se aceptó la desconexión del proveedor Navegador invitado Navegador invitado http://127.0.0.1:puerto diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 1cac2deb..ff0f0d19 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -75,6 +75,7 @@ URL Rechercher des dépôts Chargement des dépôts… + Échec du clone (code de sortie %1$d) Plus d\'options Retirer de la liste Supprimer les fichiers @@ -569,6 +570,8 @@ Terminez l’authentification dans le navigateur, puis collez le code d’autorisation. Code d’autorisation Terminer + L\'authentification n\'a pas été supprimée + Échec de l\'authentification : %1$s Supprimer le serveur %1$d outils Nom du serveur @@ -578,6 +581,7 @@ Serveurs MCP Informations du serveur + Configuration enregistrée Configuration Fournisseurs Commandes @@ -1034,6 +1038,7 @@ Question Événement non pris en charge Les informations de connexion sont incomplètes + La déconnexion du fournisseur a été refusée Navigateur invité Navigateur invité http://127.0.0.1:port diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 10c9902d..7731ceac 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -75,6 +75,7 @@ URL リポジトリを検索 リポジトリを読み込み中… + クローンに失敗しました(終了コード %1$d) その他のオプション 一覧から削除 ファイルを削除 @@ -583,6 +584,8 @@ ブラウザで認証を完了し、認証コードを貼り付けてください。 認証コード 完了 + 認証を削除できませんでした + 認証に失敗しました: %1$s サーバーを削除 %1$dツール サーバー名 @@ -593,6 +596,7 @@ サーバー情報 + 設定を保存しました 設定 プロバイダ コマンド @@ -931,6 +935,7 @@ 質問 未対応イベント 接続情報が不足しています + プロバイダーの切断が受け付けられませんでした Linux環境がまだ導入されていません Claude Codeのサインインを開始できませんでした サインインが完了しませんでした diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index c1548251..4fecb63e 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -75,6 +75,7 @@ URL Pesquisar repositórios Carregando repositórios… + Falha ao clonar (código de saída %1$d) Mais opções Remover da lista Excluir arquivos @@ -569,6 +570,8 @@ Conclua a autenticação no navegador e cole o código de autorização. Código de autorização Concluir + A autenticação não foi removida + Falha na autenticação: %1$s Remover servidor %1$d ferramentas Nome do servidor @@ -578,6 +581,7 @@ Servidores MCP Informações do servidor + Configuração salva Configuração Provedores Comandos @@ -1034,6 +1038,7 @@ Pergunta Evento não compatível As informações de conexão estão incompletas + A desconexão do provedor não foi aceita Navegador convidado Navegador convidado http://127.0.0.1:porta diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index c2845c5c..06a4c61b 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -75,6 +75,7 @@ URL Поиск репозиториев Загрузка репозиториев… + Не удалось клонировать (код выхода %1$d) Дополнительные параметры Удалить из списка Удалить файлы @@ -569,6 +570,8 @@ Завершите аутентификацию в браузере и вставьте код авторизации. Код авторизации Завершить + Аутентификация не была удалена + Ошибка аутентификации: %1$s Удалить сервер %1$d инструментов Имя сервера @@ -578,6 +581,7 @@ Серверы MCP Информация о сервере + Конфигурация сохранена Конфигурация Провайдеры Команды @@ -1034,6 +1038,7 @@ Вопрос Неподдерживаемое событие Недостаточно данных подключения + Отключение провайдера не было принято Гостевой браузер Гостевой браузер http://127.0.0.1:порт diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 46556231..987027e1 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -75,6 +75,7 @@ URL 搜索仓库 正在加载仓库… + 克隆失败(退出代码 %1$d) 更多选项 从列表中移除 删除文件 @@ -569,6 +570,8 @@ 在浏览器中完成身份验证,然后粘贴授权码。 授权码 完成 + 认证未被移除 + 认证失败:%1$s 移除服务器 %1$d 个工具 服务器名称 @@ -578,6 +581,7 @@ MCP 服务器 服务器信息 + 配置已保存 配置 提供商 命令 @@ -1034,6 +1038,7 @@ 问题 不支持的事件 连接信息不完整 + 提供商断开连接未被接受 访客浏览器 访客浏览器 http://127.0.0.1:端口 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3d9c6210..d07696da 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -75,6 +75,7 @@ URL Search repositories Loading repositories… + Clone failed (exit code %1$d) More options Remove from list Delete files @@ -587,6 +588,8 @@ Complete authentication in the browser, then paste the authorization code. Authorization code Complete + Authentication was not removed + Authentication failed: %1$s Remove server %1$d tools Server name @@ -597,6 +600,7 @@ Server info + Config saved Config Providers Commands @@ -931,6 +935,7 @@ Question Unsupported event Connection information is incomplete + Provider disconnect was not accepted The Linux environment is not installed yet Could not start Claude Code sign-in Sign-in did not complete diff --git a/app/src/test/java/com/yugahashimoto/andcode/feature/chat/SessionHandoffTest.kt b/app/src/test/java/com/yugahashimoto/andcode/feature/chat/SessionHandoffTest.kt index 518f5d17..e94a4fed 100644 --- a/app/src/test/java/com/yugahashimoto/andcode/feature/chat/SessionHandoffTest.kt +++ b/app/src/test/java/com/yugahashimoto/andcode/feature/chat/SessionHandoffTest.kt @@ -28,9 +28,13 @@ class SessionHandoffTest { val prompt = buildHandoffPrompt(messages) - assertTrue(prompt.startsWith("以下は別の実行先から引き継いだ会話の要約です。続きから対応してください。")) - assertTrue(prompt.contains("ユーザー: Hello there")) - assertTrue(prompt.contains("アシスタント: Hi, how can I help?")) + assertTrue( + prompt.startsWith( + "Below is a summary of a conversation handed over from another session. Continue from where it left off.", + ), + ) + assertTrue(prompt.contains("User: Hello there")) + assertTrue(prompt.contains("Assistant: Hi, how can I help?")) } @Test @@ -50,8 +54,8 @@ class SessionHandoffTest { val prompt = buildHandoffPrompt(messages) - assertTrue(prompt.contains("ユーザー: Real question")) - assertTrue(prompt.contains("アシスタント: Answer")) + assertTrue(prompt.contains("User: Real question")) + assertTrue(prompt.contains("Assistant: Answer")) assertFalse(prompt.contains("bash")) } @@ -65,8 +69,8 @@ class SessionHandoffTest { val prompt = buildHandoffPrompt(messages) - assertFalse(prompt.contains("ユーザー:")) - assertTrue(prompt.contains("アシスタント: Kept")) + assertFalse(prompt.contains("User:")) + assertTrue(prompt.contains("Assistant: Kept")) } @Test @@ -106,6 +110,9 @@ class SessionHandoffTest { fun `returns just the header when there is nothing to transcribe`() { val prompt = buildHandoffPrompt(emptyList()) - assertEquals("以下は別の実行先から引き継いだ会話の要約です。続きから対応してください。", prompt) + assertEquals( + "Below is a summary of a conversation handed over from another session. Continue from where it left off.", + prompt, + ) } }