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
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,17 @@ class ConversationViewTest {
)
}

@Test
fun composerRemainsEditableWhileNewSessionHydrates() {
val intents = mutableListOf<RemoteSessionIntent>()
val state = mutableStateOf(readyState(sessionId = "pending", draft = "").copy(busy = true, timeline = null))

setConversationContent(state = { state.value }, onIntent = { intents += it })

composeRule.onNodeWithTag(COMPOSER_INPUT_TEST_TAG).performTextReplacement("draft during load")
assertEquals(listOf(RemoteSessionIntent.UpdateDraft("draft during load")), intents)
}

@Test
fun composerFollowsStoreDraftUpdatesWithinTheSameSession() {
val state = mutableStateOf(readyState(sessionId = "s-code", draft = "first"))
Expand Down Expand Up @@ -403,6 +414,37 @@ class ConversationViewTest {
composeRule.onNodeWithTag(COMPOSER_INPUT_TEST_TAG).assertTextEquals("send me")
}

@Test
fun submittedDraftStaysClearedAcrossRecreationWhileAwaitingAck() {
val restoration = androidx.compose.ui.test.junit4.StateRestorationTester(composeRule)
val state = mutableStateOf(readyState(sessionId = "s-code", draft = "send me"))
restoration.setContent {
OpenBitFunTheme(dark = false) {
ConversationView(
state = state.value, phase = ConnectionPhase.CONNECTED,
settingsPlacement = SettingsPlacement(SettingsPlacementMode.BOTTOM, 0, 0, 0),
onBack = {}, onIntent = { intent ->
state.value = when (intent) {
is RemoteSessionIntent.UpdateDraft -> state.value.copy(draft = intent.text)
else -> state.value.copy(busy = true)
}
}, contextTitle = "Test desktop", onOpenFile = { _, _ -> },
previewingRemotePath = "", previewLoading = false,
download = RemoteFileDownloadUiState.None, onDownloadFile = { _, _ -> },
modifier = Modifier.fillMaxSize(),
)
}
}
composeRule.onNodeWithTag(COMPOSER_SEND_TEST_TAG).performClick()
restoration.emulateSavedInstanceStateRestore()
composeRule.onNodeWithTag(COMPOSER_INPUT_TEST_TAG).assert(
SemanticsMatcher.expectValue(SemanticsProperties.EditableText, AnnotatedString("")),
)
composeRule.onNodeWithTag(COMPOSER_INPUT_TEST_TAG).performTextReplacement("next draft")
composeRule.runOnIdle { state.value = state.value.copy(busy = false) }
composeRule.onNodeWithTag(COMPOSER_INPUT_TEST_TAG).assertTextEquals("next draft")
}

@Test
fun emptyStateShowsInvitationCopy() {
composeRule.setContent {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ internal fun ComposerBar(
draft: String,
images: List<ComposerImage>,
busy: Boolean,
/** The draft may be edited while a newly opened session is hydrating. */
inputEnabled: Boolean = !busy,
streaming: Boolean,
phase: ConnectionPhase,
model: ModelOption?,
Expand Down Expand Up @@ -273,7 +275,7 @@ internal fun ComposerBar(
}
ComposerField(
draft = draft,
enabled = !busy,
enabled = inputEnabled,
expanded = expanded,
placeholder = placeholder,
onDraftChange = onDraftChange,
Expand All @@ -290,6 +292,7 @@ internal fun ComposerBar(
}
PrimaryActionButton(
action = action,
stopEnabled = ChatComposerPolicy.canStop(streaming, capabilities.requiresRemoteConnection, phase),
onVoice = onVoice,
onSend = onSend,
onStop = onStop,
Expand Down Expand Up @@ -669,13 +672,14 @@ private const val DimmedAlpha: Float = 0.38f
@Composable
private fun PrimaryActionButton(
action: ComposerPrimaryAction,
stopEnabled: Boolean = true,
onVoice: () -> Unit,
onSend: () -> Unit,
onStop: () -> Unit,
testTag: String = COMPOSER_SEND_TEST_TAG,
) {
val colors = MaterialTheme.colorScheme
val enabled = action == ComposerPrimaryAction.STOP ||
val enabled = (action == ComposerPrimaryAction.STOP && stopEnabled) ||
action == ComposerPrimaryAction.SEND ||
action == ComposerPrimaryAction.VOICE
val description = when (action) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,23 @@ import com.openbitfun.mobile.core.feature.session.HistoryLoadState

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.interaction.DragInteraction
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
Expand Down Expand Up @@ -59,12 +65,13 @@ internal object ConversationScrollPolicy {
stickToBottom && hasRows

/**
* The LazyColumn puts the "load older messages" header at index zero when
* [hasMoreMessages] is true, so the real tail is one past [rowCount] instead
* of `rowCount - 1`.
* The LazyColumn puts one leading header in front of the messages when the
* transcript has an older page to load or has not been confirmed by the
* host yet, so the real tail is one past [rowCount] instead of
* `rowCount - 1`.
*/
fun lastItemIndex(rowCount: Int, hasMoreMessages: Boolean): Int =
if (hasMoreMessages) rowCount else (rowCount - 1).coerceAtLeast(0)
fun lastItemIndex(rowCount: Int, hasLeadingItem: Boolean): Int =
if (hasLeadingItem) rowCount else (rowCount - 1).coerceAtLeast(0)
}

/** One automatic page per deliberate drag; layout and bounce cannot re-arm it. */
Expand All @@ -84,6 +91,12 @@ internal class HistoryPageArrivalTracker {
internal fun ConversationTimelineView(
rows: List<ConversationRow>,
hasMoreMessages: Boolean,
/**
* The rows on screen are this device's stored copy rather than the host's
* transcript: a reopened session shows them at once, and the host has not
* answered for it yet. See `ChatTranscriptOrigin`.
*/
transcriptUnconfirmed: Boolean = false,
onLoadOlder: () -> Unit,
enabled: Boolean,
onApproveTool: (String, String?) -> Unit,
Expand Down Expand Up @@ -111,6 +124,9 @@ internal fun ConversationTimelineView(
val listState = rememberLazyListState()
var stickToBottom by rememberSaveable { mutableStateOf(true) }
val atBottom by remember(listState) { derivedStateOf { !listState.canScrollForward } }
// One header slot holds both leading rows, so the scroll policy counts an
// item, not a row.
val hasLeadingItem = hasMoreMessages || transcriptUnconfirmed

val historyArrival = remember { HistoryPageArrivalTracker() }
var userDragging by remember { mutableStateOf(false) }
Expand All @@ -132,12 +148,12 @@ internal fun ConversationTimelineView(
)
}
}
LaunchedEffect(rows, stickToBottom, hasMoreMessages) {
LaunchedEffect(rows, stickToBottom, hasLeadingItem) {
if (ConversationScrollPolicy.shouldScrollToBottom(stickToBottom, rows.isNotEmpty())) {
// A large offset positions the item's bottom at the viewport tail directly;
// unlike scrollToItem(index), it does not briefly expose the item's top.
listState.scrollToItem(
ConversationScrollPolicy.lastItemIndex(rows.size, hasMoreMessages),
ConversationScrollPolicy.lastItemIndex(rows.size, hasLeadingItem),
scrollOffset = Int.MAX_VALUE,
)
}
Expand Down Expand Up @@ -180,19 +196,46 @@ internal fun ConversationTimelineView(
),
verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.Bottom),
) {
if (hasMoreMessages) {
if (hasLeadingItem) {
item(key = "load-older-messages") {
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
TextButton(
onClick = { historyArrival.cancelArrival(); stickToBottom = false; onLoadOlder() },
enabled = enabled && historyLoadState != HistoryLoadState.LOADING,
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.onSurfaceVariant),
) {
Text(stringResource(when (historyLoadState) {
HistoryLoadState.LOADING -> R.string.chat_loading_older_messages
HistoryLoadState.FAILED -> R.string.chat_load_older_failed
else -> R.string.chat_load_older_messages
}))
Column(modifier = Modifier.fillMaxWidth()) {
if (transcriptUnconfirmed) {
// These rows stop where this device's last write stopped,
// inside the turn that was running when the app went away.
// Say the rest is on its way instead of letting a
// half-finished turn read as the session.
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(7.dp))
Text(
text = stringResource(R.string.chat_transcript_syncing),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (hasMoreMessages) {
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
TextButton(
onClick = { historyArrival.cancelArrival(); stickToBottom = false; onLoadOlder() },
enabled = enabled && historyLoadState != HistoryLoadState.LOADING,
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.onSurfaceVariant),
) {
Text(stringResource(when (historyLoadState) {
HistoryLoadState.LOADING -> R.string.chat_loading_older_messages
HistoryLoadState.FAILED -> R.string.chat_load_older_failed
else -> R.string.chat_load_older_messages
}))
}
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ import com.openbitfun.mobile.core.feature.session.RemoteSessionUiState
import com.openbitfun.mobile.core.feature.session.conversationRows
import com.openbitfun.mobile.core.feature.session.modelOptions
import com.openbitfun.mobile.core.feature.session.selectedModelOption
import com.openbitfun.mobile.core.feature.session.transcriptUnconfirmed
import com.openbitfun.mobile.core.feature.workspace.RemoteFileDownloadUiState

internal const val CONVERSATION_TEST_TAG: String = "conversation"
Expand Down Expand Up @@ -182,7 +183,7 @@ internal fun ConversationView(
// The remote composer's single source of truth is the store's draft. Typing,
// voice, and send all round-trip through `state.draft` so a half-written
// message survives session switches and process restarts via DraftStore.
var submittedDraft by remember(attachmentOwner, state.selectedSessionId) { mutableStateOf<String?>(null) }
var submittedDraft by rememberSaveable(attachmentOwner, state.selectedSessionId) { mutableStateOf<String?>(null) }
val draft = if (submittedDraft == state.draft) "" else state.draft
val focusManager = LocalFocusManager.current
val keyboard = LocalSoftwareKeyboardController.current
Expand Down Expand Up @@ -304,7 +305,7 @@ internal fun ConversationView(
ConversationHeader(
title = state.sessions.firstOrNull { it.id == sessionId }?.title.orEmpty(),
contextTitle = contextTitle,
canStop = activeTurn != null,
canStop = activeTurn != null && phase == ConnectionPhase.CONNECTED,
enabled = !state.busy && sessionId.isNotEmpty(),
onBack = onBack,
onOpenSidebar = onOpenSidebar,
Expand Down Expand Up @@ -347,6 +348,11 @@ internal fun ConversationView(
images = images,
// An empty session id would send nowhere, so it reads as busy.
busy = state.busy || preparingImage || attachmentsBlocked || sessionId.isEmpty(),
// Session hydration must not make the draft field require
// repeated taps. Sending and attachment actions remain
// guarded by `busy`; typing can start as soon as a session
// has been selected and the draft survives hydration.
inputEnabled = sessionId.isNotEmpty() && !preparingImage && !attachmentsBlocked,
streaming = activeTurn != null,
phase = phase,
model = timeline?.selectedModelOption(stringResource(R.string.models_unnamed)),
Expand Down Expand Up @@ -444,6 +450,9 @@ private fun ConversationTimelineViewHost(
ConversationTimelineView(
rows = visibleRows,
hasMoreMessages = state.hasMoreMessages,
transcriptUnconfirmed = state.timeline
?.takeIf { it.sessionId == state.selectedSessionId }
?.transcriptUnconfirmed() == true,
historyLoadState = state.historyLoadState,
onLoadOlder = { onIntent(RemoteSessionIntent.LoadOlderMessages) },
enabled = !state.busy,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ internal fun RemoteCompactHome(
) { Text(stringResource(R.string.home_recent_all), fontSize = 12.sp) }
}
recent.forEach { session ->
Column(Modifier.fillMaxWidth().clickable(enabled = !ready!!.busy) { onOpen(session.id) }
Column(Modifier.fillMaxWidth().clickable { onOpen(session.id) }
.padding(vertical = MobileDesignGeometry.RecentHomeRowPadding)) {
Text(session.title, fontSize = 15.sp, maxLines = 2)
val workspace = session.workspaceName?.takeIf { it.isNotBlank() }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,10 @@ internal fun RemoteSessionListContent(
settings = viewSettings,
projectChild = section is SessionListSection.Project,
selected = session.id == state.selectedSessionId,
enabled = !state.busy,
// Opening a session is cancellable/supersedable
// in the store; keep rows tappable while the
// previous transcript hydrates.
enabled = true,
onOpen = {
onIntent(RemoteSessionIntent.Open(session.id))
onOpen(session.id)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,11 @@ internal fun OpenBitFunCompactDrawer(
Box(
Modifier
.fillMaxSize()
.background(androidx.compose.material3.MaterialTheme.colorScheme.background)
// Use the semantic scrim token. Using the page
// background here makes a light-theme drawer paint a
// white sheet over the entire detail page, so opening
// the sidebar looks like the page disappeared.
.background(androidx.compose.material3.MaterialTheme.colorScheme.scrim)
.graphicsLayer { alpha = scrimProgress.value }
.clickable(
enabled = open,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,7 @@
<string name="remote_settings_openbitfun_user">OpenBitFun 用户</string>
<string name="remote_settings_account_signed_in">已认证</string>
<string name="chat_load_older_messages">加载更早消息</string>
<string name="chat_transcript_syncing">正在同步</string>
<string name="account_open_github">打开 OpenBitFun 授权</string>
<string name="account_device_link_invalid">请使用当前版本的 OpenBitFun 设备二维码。</string>
<string name="account_device_link_unavailable">该设备已离线,或不属于当前 OpenBitFun 账户。</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
<string name="general_chat_invalid_response">The model service returned an unrecognized response.</string>
<string name="general_chat_network">Could not reach the provider.</string>
<string name="chat_load_older_messages">Load earlier messages</string>
<string name="chat_transcript_syncing">Syncing</string>

<string name="model_service_title">Model</string>
<string name="model_service_local_title">Local custom model</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,10 @@ class ConversationScrollPolicyTest {
}

@Test
fun lastItemIndexAccountsForTheLoadOlderHeader() {
org.junit.Assert.assertEquals(2, ConversationScrollPolicy.lastItemIndex(rowCount = 3, hasMoreMessages = false))
org.junit.Assert.assertEquals(3, ConversationScrollPolicy.lastItemIndex(rowCount = 3, hasMoreMessages = true))
org.junit.Assert.assertEquals(0, ConversationScrollPolicy.lastItemIndex(rowCount = 0, hasMoreMessages = false))
org.junit.Assert.assertEquals(0, ConversationScrollPolicy.lastItemIndex(rowCount = 0, hasMoreMessages = true))
fun lastItemIndexAccountsForTheLeadingHeader() {
org.junit.Assert.assertEquals(2, ConversationScrollPolicy.lastItemIndex(rowCount = 3, hasLeadingItem = false))
org.junit.Assert.assertEquals(3, ConversationScrollPolicy.lastItemIndex(rowCount = 3, hasLeadingItem = true))
org.junit.Assert.assertEquals(0, ConversationScrollPolicy.lastItemIndex(rowCount = 0, hasLeadingItem = false))
org.junit.Assert.assertEquals(0, ConversationScrollPolicy.lastItemIndex(rowCount = 0, hasLeadingItem = true))
}
}
Loading
Loading