fix: keep chat scroll usable while sending

This commit is contained in:
kris
2026-04-18 05:23:14 +08:00
parent 449f84fcbc
commit bb237fdd4f
4 changed files with 133 additions and 31 deletions

View File

@@ -19,6 +19,7 @@ import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.widget.Button; import android.widget.Button;
import android.widget.EditText; import android.widget.EditText;
import android.widget.ImageButton;
import android.widget.LinearLayout; import android.widget.LinearLayout;
import android.widget.ScrollView; import android.widget.ScrollView;
import android.widget.TextView; import android.widget.TextView;
@@ -61,7 +62,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
private LinearLayout quickActionsLayout; private LinearLayout quickActionsLayout;
private LinearLayout composerRow; private LinearLayout composerRow;
private LinearLayout multiSelectActionsLayout; private LinearLayout multiSelectActionsLayout;
private Button composerAttachmentButton; private ImageButton composerAttachmentButton;
private EditText composerInput; private EditText composerInput;
private Button composerSendButton; private Button composerSendButton;
private Button multiSelectForwardButton; private Button multiSelectForwardButton;
@@ -105,6 +106,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
private boolean realtimeReloadRequiresFullSnapshot; private boolean realtimeReloadRequiresFullSnapshot;
private boolean reloadInFlight; private boolean reloadInFlight;
private boolean pendingReload; private boolean pendingReload;
private boolean pendingReloadMessagesOnly;
private boolean pendingReloadForcedScrollToBottom; private boolean pendingReloadForcedScrollToBottom;
private volatile boolean activityDestroyed; private volatile boolean activityDestroyed;
private final Runnable conversationAutoRefreshRunnable = new Runnable() { private final Runnable conversationAutoRefreshRunnable = new Runnable() {
@@ -114,7 +116,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
if (!shouldMaintainConversationAutoRefresh()) { if (!shouldMaintainConversationAutoRefresh()) {
return; return;
} }
if (!reloadInFlight && !refreshLayout.isRefreshing() && shouldAutoRefreshConversation()) { if (!reloadInFlight && !isComposerBusy() && shouldAutoRefreshConversation()) {
reload(false); reload(false);
} }
armConversationAutoRefresh(); armConversationAutoRefresh();
@@ -280,6 +282,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
BossWindowInsets.applyKeyboardAvoidingInset(composerRow); BossWindowInsets.applyKeyboardAvoidingInset(composerRow);
BossWindowInsets.applyKeyboardAvoidingInset(multiSelectActionsLayout); BossWindowInsets.applyKeyboardAvoidingInset(multiSelectActionsLayout);
bindChatRefreshScrollBridge();
updateProjectHeader(initialProjectName == null ? "项目详情" : initialProjectName, "正在同步项目详情..."); updateProjectHeader(initialProjectName == null ? "项目详情" : initialProjectName, "正在同步项目详情...");
if (composerAttachmentButton != null) { if (composerAttachmentButton != null) {
@@ -352,6 +355,18 @@ public class ProjectDetailActivity extends BossScreenActivity {
reload(false); reload(false);
} }
private void bindChatRefreshScrollBridge() {
if (refreshLayout == null) {
return;
}
refreshLayout.setOnChildScrollUpCallback((parent, child) -> {
if (chatScrollView == null || chatScrollView.getVisibility() != View.VISIBLE) {
return false;
}
return chatScrollView.canScrollVertically(-1) || chatScrollView.getScrollY() > 0;
});
}
void handleRealtimeEvent(BossRealtimeEvent event) { void handleRealtimeEvent(BossRealtimeEvent event) {
if (event == null || event.eventName.isEmpty() || projectId == null || projectId.isEmpty()) { if (event == null || event.eventName.isEmpty() || projectId == null || projectId.isEmpty()) {
return; return;
@@ -371,7 +386,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
if (tryApplyRealtimeMessagesPatch(event)) { if (tryApplyRealtimeMessagesPatch(event)) {
return; return;
} }
runOnUiThread(() -> scheduleRealtimeReload(true)); runOnUiThread(() -> scheduleRealtimeReload(!"project.messages.updated".equals(event.eventName)));
} }
private boolean tryApplyRealtimeMessagesPatch(BossRealtimeEvent event) { private boolean tryApplyRealtimeMessagesPatch(BossRealtimeEvent event) {
@@ -562,6 +577,10 @@ public class ProjectDetailActivity extends BossScreenActivity {
return; return;
} }
if (reloadInFlight) { if (reloadInFlight) {
pendingReloadMessagesOnly = pendingReload && pendingReloadMessagesOnly && messagesOnly;
if (!pendingReload) {
pendingReloadMessagesOnly = messagesOnly;
}
pendingReload = true; pendingReload = true;
pendingReloadForcedScrollToBottom = pendingReloadForcedScrollToBottom || forcedScrollToBottom; pendingReloadForcedScrollToBottom = pendingReloadForcedScrollToBottom || forcedScrollToBottom;
return; return;
@@ -641,20 +660,16 @@ public class ProjectDetailActivity extends BossScreenActivity {
return; return;
} }
boolean forcedScrollToBottom = pendingReloadForcedScrollToBottom; boolean forcedScrollToBottom = pendingReloadForcedScrollToBottom;
boolean messagesOnly = pendingReloadMessagesOnly;
pendingReload = false; pendingReload = false;
pendingReloadMessagesOnly = false;
pendingReloadForcedScrollToBottom = false; pendingReloadForcedScrollToBottom = false;
reload(forcedScrollToBottom); reloadSnapshot(forcedScrollToBottom, messagesOnly);
} }
@Override @Override
protected void setRefreshing(boolean refreshing) { protected void setRefreshing(boolean refreshing) {
super.setRefreshing(refreshing); super.setRefreshing(refreshing);
if (composerAttachmentButton != null) {
composerAttachmentButton.setEnabled(!refreshing && !composerSending);
}
if (composerInput != null) {
composerInput.setEnabled(!refreshing);
}
updateComposerSendButtonState(); updateComposerSendButtonState();
updateSelectionUi(); updateSelectionUi();
if (!refreshing) { if (!refreshing) {
@@ -788,7 +803,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
&& shouldMaintainConversationAutoRefresh() && shouldMaintainConversationAutoRefresh()
&& !reloadInFlight && !reloadInFlight
&& refreshLayout != null && refreshLayout != null
&& !refreshLayout.isRefreshing()) { && !isComposerBusy()) {
reload(); reload();
} }
updateConversationAutoRefresh(); updateConversationAutoRefresh();
@@ -1017,7 +1032,6 @@ public class ProjectDetailActivity extends BossScreenActivity {
private void sendProjectMessage(String kind, String body) { private void sendProjectMessage(String kind, String body) {
composerSending = true; composerSending = true;
updateComposerSendButtonState(); updateComposerSendButtonState();
setRefreshing(true);
appendPendingOutgoingBubble(body); appendPendingOutgoingBubble(body);
scrollChatToBottom(); scrollChatToBottom();
executor.execute(() -> { executor.execute(() -> {
@@ -1220,7 +1234,6 @@ public class ProjectDetailActivity extends BossScreenActivity {
private void uploadAttachment(AttachmentComposerState.PendingAttachment attachment) { private void uploadAttachment(AttachmentComposerState.PendingAttachment attachment) {
composerSending = true; composerSending = true;
updateComposerSendButtonState(); updateComposerSendButtonState();
setRefreshing(true);
appendPendingOutgoingAttachment(attachment); appendPendingOutgoingAttachment(attachment);
scrollChatToBottom(); scrollChatToBottom();
executor.execute(() -> { executor.execute(() -> {
@@ -2361,8 +2374,13 @@ public class ProjectDetailActivity extends BossScreenActivity {
if (composerSendButton == null || composerInput == null) { if (composerSendButton == null || composerInput == null) {
return; return;
} }
boolean composerBusy = isComposerBusy();
if (composerAttachmentButton != null) {
composerAttachmentButton.setEnabled(!composerBusy);
}
composerInput.setEnabled(!composerBusy);
String body = composerInput.getText() == null ? "" : composerInput.getText().toString(); String body = composerInput.getText() == null ? "" : composerInput.getText().toString();
composerSendButton.setEnabled(ProjectChatUiState.canSend(body, isComposerBusy())); composerSendButton.setEnabled(ProjectChatUiState.canSend(body, composerBusy));
} }
private boolean isComposerBusy() { private boolean isComposerBusy() {
@@ -2698,7 +2716,6 @@ public class ProjectDetailActivity extends BossScreenActivity {
} }
composerSending = true; composerSending = true;
updateComposerSendButtonState(); updateComposerSendButtonState();
setRefreshing(true);
showMessage(waitingMessage); showMessage(waitingMessage);
enqueueReplyWaitPoll(waitSpec.baselineMessageId, includeDispatchPlans); enqueueReplyWaitPoll(waitSpec.baselineMessageId, includeDispatchPlans);
} }
@@ -2781,7 +2798,6 @@ public class ProjectDetailActivity extends BossScreenActivity {
if (!hasReply && !isMasterAgentConversation()) { if (!hasReply && !isMasterAgentConversation()) {
composerSending = true; composerSending = true;
updateComposerSendButtonState(); updateComposerSendButtonState();
setRefreshing(true);
} }
}); });
renderedInitialSnapshot = true; renderedInitialSnapshot = true;

View File

@@ -141,18 +141,17 @@
android:paddingRight="12dp" android:paddingRight="12dp"
android:paddingBottom="12dp"> android:paddingBottom="12dp">
<Button <androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/project_chat_attach" android:id="@+id/project_chat_attach"
android:layout_width="44dp" android:layout_width="48dp"
android:layout_height="44dp" android:layout_height="48dp"
android:layout_marginRight="8dp" android:layout_marginRight="8dp"
android:background="@drawable/bg_secondary_button" android:background="@drawable/bg_secondary_button"
android:minWidth="0dp" android:contentDescription="发送附件"
android:text="+" android:padding="12dp"
android:textAllCaps="false" android:scaleType="center"
android:textColor="@color/boss_text_primary" android:src="@drawable/ic_boss_add"
android:textSize="20sp" android:tint="@color/boss_text_primary" />
android:textStyle="bold" />
<EditText <EditText
android:id="@+id/project_chat_input" android:id="@+id/project_chat_input"

View File

@@ -45,7 +45,8 @@ public class ProjectDetailActivityRealtimeTest {
); );
Shadows.shadowOf(activity.getMainLooper()).idle(); Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(1, activity.reloadCount); assertEquals(0, activity.reloadCount);
assertEquals(1, activity.messageReloadCount);
} }
@Test @Test
@@ -131,7 +132,8 @@ public class ProjectDetailActivityRealtimeTest {
); );
Shadows.shadowOf(activity.getMainLooper()).idle(); Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(2, activity.reloadCount); assertEquals(1, activity.reloadCount);
assertEquals(1, activity.messageReloadCount);
} }
@Test @Test
@@ -159,6 +161,7 @@ public class ProjectDetailActivityRealtimeTest {
Shadows.shadowOf(activity.getMainLooper()).idle(); Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(1, activity.reloadCount); assertEquals(1, activity.reloadCount);
assertEquals(0, activity.messageReloadCount);
} }
@Test @Test
@@ -196,7 +199,8 @@ public class ProjectDetailActivityRealtimeTest {
); );
Shadows.shadowOf(activity.getMainLooper()).idle(); Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(1, activity.reloadCount); assertEquals(0, activity.reloadCount);
assertEquals(1, activity.messageReloadCount);
} }
@Test @Test
@@ -241,13 +245,15 @@ public class ProjectDetailActivityRealtimeTest {
); );
Shadows.shadowOf(activity.getMainLooper()).idle(); Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(1, activity.loadCallCount); assertEquals(0, activity.loadCallCount);
assertEquals(1, activity.messageLoadCallCount);
assertEquals(0, activity.renderCount); assertEquals(0, activity.renderCount);
activity.releaseFirstLoad(); activity.releaseFirstLoad();
waitFor(() -> activity.renderCount == 2 && activity.loadCallCount == 2); waitFor(() -> activity.renderCount == 2 && activity.messageLoadCallCount == 1 && activity.loadCallCount == 1);
assertEquals(2, activity.loadCallCount); assertEquals(1, activity.loadCallCount);
assertEquals(1, activity.messageLoadCallCount);
assertEquals(2, activity.renderCount); assertEquals(2, activity.renderCount);
} }
@@ -313,7 +319,9 @@ public class ProjectDetailActivityRealtimeTest {
public static class TestRealtimeProjectDetailActivity extends ProjectDetailActivity { public static class TestRealtimeProjectDetailActivity extends ProjectDetailActivity {
int reloadCount; int reloadCount;
int messageReloadCount;
volatile int loadCallCount; volatile int loadCallCount;
volatile int messageLoadCallCount;
volatile int renderCount; volatile int renderCount;
private CountDownLatch firstLoadStarted; private CountDownLatch firstLoadStarted;
private CountDownLatch releaseFirstLoad; private CountDownLatch releaseFirstLoad;
@@ -344,6 +352,26 @@ public class ProjectDetailActivityRealtimeTest {
super.reload(); super.reload();
} }
@Override
ProjectSnapshot loadProjectMessagesSnapshotForRefresh() throws Exception {
messageReloadCount += 1;
messageLoadCallCount += 1;
if (messageLoadCallCount == 1 && firstLoadStarted != null && releaseFirstLoad != null) {
firstLoadStarted.countDown();
releaseFirstLoad.await(2, TimeUnit.SECONDS);
}
return new ProjectSnapshot(
new JSONObject().put(
"project",
new JSONObject()
.put("name", "北区试产线")
.put("messages", new JSONArray())
),
null,
null
);
}
@Override @Override
ProjectSnapshot loadProjectSnapshotForRefresh() throws Exception { ProjectSnapshot loadProjectSnapshotForRefresh() throws Exception {
loadCallCount += 1; loadCallCount += 1;

View File

@@ -17,6 +17,7 @@ import android.widget.LinearLayout;
import android.widget.ListView; import android.widget.ListView;
import android.widget.ScrollView; import android.widget.ScrollView;
import android.widget.TextView; import android.widget.TextView;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AlertDialog;
import org.json.JSONArray; import org.json.JSONArray;
import org.json.JSONObject; import org.json.JSONObject;
@@ -131,6 +132,22 @@ public class ProjectDetailActivityUiTest {
assertEquals(R.id.project_chat_scroll, ((View) contentLayout.getParent()).getId()); assertEquals(R.id.project_chat_scroll, ((View) contentLayout.getParent()).getId());
} }
@Test
public void chatRefreshUsesScrollViewAsChildScrollReference() {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
SwipeRefreshLayout refreshLayout = activity.findViewById(R.id.screen_refresh_layout);
Object childScrollCallback = ReflectionHelpers.getField(refreshLayout, "mChildScrollUpCallback");
assertNotNull(childScrollCallback);
}
@Test @Test
public void composerRowLayoutChangeWithFocusedInput_scrollsChatToBottomAgain() { public void composerRowLayoutChangeWithFocusedInput_scrollsChatToBottomAgain() {
Intent intent = new Intent() Intent intent = new Intent()
@@ -176,6 +193,48 @@ public class ProjectDetailActivityUiTest {
assertEquals(baselineScrollCount, activity.scrollChatToBottomCount); assertEquals(baselineScrollCount, activity.scrollChatToBottomCount);
} }
@Test
public void composerBusyWithoutRefreshSpinnerStillDisablesComposerInputs() {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
ReflectionHelpers.setField(activity, "composerSending", true);
ReflectionHelpers.callInstanceMethod(activity, "updateComposerSendButtonState");
Button sendButton = activity.findViewById(R.id.project_chat_send);
View composerInput = activity.findViewById(R.id.project_chat_input);
View attachView = activity.findViewById(R.id.project_chat_attach);
SwipeRefreshLayout refreshLayout = activity.findViewById(R.id.screen_refresh_layout);
assertFalse(refreshLayout.isRefreshing());
assertFalse(sendButton.isEnabled());
assertFalse(composerInput.isEnabled());
assertFalse(attachView.isEnabled());
}
@Test
public void composerAttachmentUsesIconButtonFrameInsteadOfTextButton() {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
View attachView = activity.findViewById(R.id.project_chat_attach);
assertTrue(attachView instanceof ImageButton);
ViewGroup.LayoutParams params = attachView.getLayoutParams();
assertTrue(params.width >= BossUi.dp(activity, 46));
assertTrue(params.height >= BossUi.dp(activity, 46));
}
@Test @Test
public void manualAnalysisAttachmentShowsActionChip() throws Exception { public void manualAnalysisAttachmentShowsActionChip() throws Exception {
Intent intent = new Intent() Intent intent = new Intent()