Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 130 additions & 9 deletions app/src/main/kotlin/com/gemini/app/ui/chat/ChatScreen.kt
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
package com.gemini.app.ui.chat

import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.DocumentsContract
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.Image
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.LinearEasing
Expand Down Expand Up @@ -130,7 +136,9 @@ fun ChatScreen(
val error by viewModel.error.collectAsState()
val pendingCall by viewModel.pendingCall.collectAsState()
val model by viewModel.model.collectAsState()
val availableModels by viewModel.availableModels.collectAsState()
val workspaceLabel by viewModel.workspaceLabel.collectAsState()
val workspaceUri by viewModel.workspaceUri.collectAsState()
val thinking by viewModel.thinking.collectAsState()
val tokenUsage by viewModel.tokenUsage.collectAsState()
val compressing by viewModel.compressing.collectAsState()
Expand All @@ -141,6 +149,14 @@ fun ChatScreen(
) { uri ->
if (uri != null) viewModel.attachImageFromUri(context, uri)
}
val folderPicker = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocumentTree()
) { uri ->
if (uri != null) viewModel.setProjectFolder(uri.toString())
}

var modelMenuOpen by remember { mutableStateOf(false) }
var folderMenuOpen by remember { mutableStateOf(false) }

val listState = rememberLazyListState()
val isNearBottom by remember(listState) {
Expand Down Expand Up @@ -189,16 +205,78 @@ fun ChatScreen(
)
Spacer(Modifier.width(8.dp))
Column {
Text(
model,
style = MaterialTheme.typography.labelMedium
)
Row(verticalAlignment = Alignment.CenterVertically) {
Box {
Text(
workspaceLabel.substringAfterLast('/'),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
model,
style = MaterialTheme.typography.labelMedium,
modifier = Modifier
.clickable { modelMenuOpen = true }
.padding(vertical = 2.dp, horizontal = 4.dp)
)
DropdownMenu(
expanded = modelMenuOpen,
onDismissRequest = { modelMenuOpen = false }
) {
availableModels.forEach { name ->
DropdownMenuItem(
text = {
Text(
name,
style = if (name == model)
MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.primary
)
else MaterialTheme.typography.bodyMedium
)
},
onClick = {
viewModel.setModel(name)
modelMenuOpen = false
}
)
}
if (availableModels.isNotEmpty()) {
androidx.compose.material3.HorizontalDivider()
}
DropdownMenuItem(
text = { Text("More models…") },
onClick = {
modelMenuOpen = false
showSettings = true
}
)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Box {
Text(
workspaceLabel.substringAfterLast('/'),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.clickable { folderMenuOpen = true }
.padding(vertical = 2.dp, horizontal = 4.dp)
)
DropdownMenu(
expanded = folderMenuOpen,
onDismissRequest = { folderMenuOpen = false }
) {
DropdownMenuItem(
text = { Text("Open folder") },
onClick = {
folderMenuOpen = false
openWorkspaceFolder(context, workspaceUri)
}
)
DropdownMenuItem(
text = { Text("Change folder") },
onClick = {
folderMenuOpen = false
folderPicker.launch(null)
}
)
}
}
val tokenLabel = formatTokens(tokenUsage.total, tokenUsage.limit)
if (tokenLabel != null) {
Spacer(Modifier.width(6.dp))
Expand Down Expand Up @@ -594,7 +672,15 @@ fun MessageBubble(
}
}
} else {
MarkdownText(text = message.text, color = content)
Column {
if (message.attachmentPaths.isNotEmpty()) {
AttachmentThumbnails(message.attachmentPaths)
if (message.text.isNotBlank()) Spacer(Modifier.height(6.dp))
}
if (message.text.isNotBlank()) {
MarkdownText(text = message.text, color = content)
}
}
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
DropdownMenuItem(
Expand Down Expand Up @@ -1107,6 +1193,41 @@ private fun AttachmentThumbnails(paths: List<String>) {
}
}

// Best-effort "reveal workspace in a file manager". For SAF tree URIs we build
// the corresponding document URI so DocumentsUI can open it; for plain file://
// URIs we just ACTION_VIEW. There is no universal folder-view intent on
// Android, so we gracefully fall back to a toast when no app handles it.
private fun openWorkspaceFolder(context: Context, workspaceUri: String?) {
val raw = workspaceUri?.takeIf { it.isNotBlank() }
if (raw == null) {
Toast.makeText(context, "No workspace folder set", Toast.LENGTH_SHORT).show()
return
}
val uri = runCatching { Uri.parse(raw) }.getOrNull() ?: return
val intent = Intent(Intent.ACTION_VIEW).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
val target = if (uri.scheme == "content") {
runCatching {
val id = DocumentsContract.getTreeDocumentId(uri)
DocumentsContract.buildDocumentUriUsingTree(uri, id)
}.getOrNull() ?: uri
} else uri
intent.setDataAndType(target, "vnd.android.document/directory")
runCatching { context.startActivity(intent) }.onFailure {
// Retry without mime type — some Files apps only match on the URI.
val fallback = Intent(Intent.ACTION_VIEW, target)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
Comment on lines +1218 to +1220

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Fallback folder intent drops the read-permission flag, which can break content:// URIs.

In openWorkspaceFolder, the fallback Intent created on retry omits FLAG_GRANT_READ_URI_PERMISSION, unlike the primary intent. For SAF content:// URIs this means the target app may not be able to read the folder on the fallback path. Please mirror the permission flags from the primary intent on the fallback (at least FLAG_GRANT_READ_URI_PERMISSION) so both code paths behave consistently.

runCatching { context.startActivity(fallback) }.onFailure {
Toast.makeText(
context,
"No app available to open this folder",
Toast.LENGTH_SHORT
).show()
}
}
}

private fun decodeThumbnail(path: String): androidx.compose.ui.graphics.ImageBitmap? {
val file = java.io.File(path)
if (!file.exists()) return null
Expand Down
12 changes: 12 additions & 0 deletions app/src/main/kotlin/com/gemini/app/ui/chat/ChatViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,15 @@ class ChatViewModel(private val core: RestGeminiCore) : ViewModel() {
private val _workspaceReason = MutableStateFlow(core.workspace.unreachableReason())
val workspaceReason: StateFlow<String?> = _workspaceReason.asStateFlow()

private val _workspaceUri = MutableStateFlow(core.workspace.rootUri()?.toString())
val workspaceUri: StateFlow<String?> = _workspaceUri.asStateFlow()

private val _availableModels = MutableStateFlow(core.listModels())
val availableModels: StateFlow<List<String>> = _availableModels.asStateFlow()

private val _imagenModel = MutableStateFlow(core.imagenModel())
val imagenModel: StateFlow<String> = _imagenModel.asStateFlow()

private val _thinking = MutableStateFlow<String?>(null)
val thinking: StateFlow<String?> = _thinking.asStateFlow()

Expand Down Expand Up @@ -347,6 +353,11 @@ class ChatViewModel(private val core: RestGeminiCore) : ViewModel() {
_model.value = core.currentModel()
}

fun setImagenModel(name: String) {
core.setImagenModel(name)
_imagenModel.value = core.imagenModel()
}

fun setAutoApprove(enabled: Boolean) {
core.setAutoApprove(enabled)
_autoApprove.value = enabled
Expand All @@ -360,6 +371,7 @@ class ChatViewModel(private val core: RestGeminiCore) : ViewModel() {
_workspaceLabel.value = core.workspace.rootLabel()
_workspacePath.value = core.workspace.absolutePath()
_workspaceReason.value = core.workspace.unreachableReason()
_workspaceUri.value = core.workspace.rootUri()?.toString()
}
}
}
Expand Down
77 changes: 77 additions & 0 deletions app/src/main/kotlin/com/gemini/app/ui/settings/SettingsSheet.kt
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,10 @@ fun SettingsSheet(
val compressThreshold by viewModel.autoCompressThreshold.collectAsState()
val tokenUsage by viewModel.tokenUsage.collectAsState()
val autoSave by viewModel.autoSaveEnabled.collectAsState()
val imagenModel by viewModel.imagenModel.collectAsState()

var customModel by remember { mutableStateOf("") }
var customImagenModel by remember { mutableStateOf("") }
var expanded by remember { mutableStateOf(emptySet<String>()) }

val folderLauncher = rememberLauncherForActivityResult(
Expand Down Expand Up @@ -258,6 +260,81 @@ fun SettingsSheet(
}
) { Text("Use") }
}

Spacer(Modifier.height(16.dp))
Text(
"Image generation model (Imagen)",
style = MaterialTheme.typography.labelMedium
)
Spacer(Modifier.height(4.dp))
var imagenDropdownOpen by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = imagenDropdownOpen,
onExpandedChange = { imagenDropdownOpen = it }
) {
OutlinedTextField(
value = imagenModel,
onValueChange = {},
readOnly = true,
label = { Text("Imagen model") },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = imagenDropdownOpen)
},
colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(),
modifier = Modifier
.menuAnchor()
.fillMaxWidth()
)
DropdownMenu(
expanded = imagenDropdownOpen,
onDismissRequest = { imagenDropdownOpen = false }
) {
com.gemini.bridge.RestGeminiCore.AVAILABLE_IMAGEN_MODELS.forEach { name ->
DropdownMenuItem(
text = {
Text(
name,
style = if (name == imagenModel)
MaterialTheme.typography.bodyMedium.copy(
fontWeight = androidx.compose.ui.text.font.FontWeight.Medium,
color = MaterialTheme.colorScheme.primary
)
else MaterialTheme.typography.bodyMedium
)
},
onClick = {
viewModel.setImagenModel(name)
imagenDropdownOpen = false
}
)
}
}
}
Spacer(Modifier.height(4.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = customImagenModel,
onValueChange = { customImagenModel = it },
placeholder = { Text("e.g. imagen-4.0-generate-001") },
singleLine = true,
modifier = Modifier.weight(1f)
)
Spacer(Modifier.width(8.dp))
TextButton(
onClick = {
if (customImagenModel.isNotBlank()) {
viewModel.setImagenModel(customImagenModel.trim())
customImagenModel = ""
}
}
) { Text("Use") }
}
Text(
"The model uses `generate_image` automatically when you ask for " +
"a drawing/illustration. Imagen is billed separately from Gemini.",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}

SettingsAccordion(
Expand Down
Loading
Loading