feat: add thread status read views

This commit is contained in:
kris
2026-04-04 11:39:06 +08:00
parent 7d578aa12f
commit 010d8eda2d
8 changed files with 580 additions and 1 deletions

View File

@@ -231,6 +231,10 @@ public class BossApiClient {
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/participants", null);
}
public ApiResponse getThreadStatus(String projectId) throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/thread-status", null);
}
public ApiResponse replaceConversationParticipants(String projectId, JSONArray memberProjectIds) throws IOException, JSONException {
JSONObject payload = new JSONObject();
payload.put("memberProjectIds", memberProjectIds == null ? new JSONArray() : memberProjectIds);

View File

@@ -102,6 +102,15 @@ public class ConversationInfoActivity extends BossScreenActivity {
v -> openProject(projectId, projectName)
));
appendContent(BossUi.buildWechatMenuRow(
this,
"线程状态",
"只读状态文档和最近进展事件",
projectFolderName.isEmpty() ? "只读" : projectFolderName + " · 只读",
null,
v -> openThreadStatus()
));
appendContent(BossUi.buildWechatMenuRow(
this,
"参与线程",
@@ -175,6 +184,17 @@ public class ConversationInfoActivity extends BossScreenActivity {
startActivity(intent);
}
private void openThreadStatus() {
if (projectId == null || projectId.isEmpty()) {
showMessage("缺少 projectId");
return;
}
Intent intent = new Intent(this, ThreadStatusActivity.class);
intent.putExtra(ThreadStatusActivity.EXTRA_PROJECT_ID, projectId);
intent.putExtra(ThreadStatusActivity.EXTRA_PROJECT_NAME, projectName);
startActivity(intent);
}
private void openRenameDialog() {
final EditText input = BossUi.buildInput(this, "线程名", false);
input.setText(projectName == null ? "" : projectName);

View File

@@ -0,0 +1,167 @@
package com.hyzq.boss;
import android.os.Bundle;
import android.widget.LinearLayout;
import androidx.annotation.Nullable;
import org.json.JSONArray;
import org.json.JSONObject;
public class ThreadStatusActivity extends BossScreenActivity {
public static final String EXTRA_PROJECT_ID = "project_id";
public static final String EXTRA_PROJECT_NAME = "project_name";
private String projectId;
private String projectName;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
projectId = getIntent().getStringExtra(EXTRA_PROJECT_ID);
projectName = getIntent().getStringExtra(EXTRA_PROJECT_NAME);
configureScreen("线程状态", projectName == null ? "只读状态文档" : projectName);
setHeaderAction("只读", v -> showMessage("线程状态只读"));
reload();
}
@Override
protected void reload() {
if (projectId == null || projectId.isEmpty()) {
replaceContent(BossUi.buildEmptyCard(this, "缺少 projectId。"));
setRefreshing(false);
return;
}
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.getThreadStatus(projectId);
if (!response.ok()) throw new IllegalStateException(response.message());
runOnUiThread(() -> renderThreadStatus(response.json));
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
replaceContent(BossUi.buildEmptyCard(this, "线程状态加载失败:" + error.getMessage()));
});
}
});
}
private void renderThreadStatus(JSONObject payload) {
replaceContent();
JSONObject document = payload.optJSONObject("threadStatusDocument");
JSONArray recentProgressEvents = payload.optJSONArray("recentProgressEvents");
if (document == null) {
appendContent(BossUi.buildEmptyCard(this, "当前还没有线程状态文档。"));
setRefreshing(false);
return;
}
String threadDisplayName = document.optString(
"threadDisplayName",
projectName == null ? "线程状态" : projectName
);
configureScreen("线程状态", buildSubtitle(document, recentProgressEvents));
appendContent(BossUi.buildSimpleProfileHeader(
this,
threadDisplayName,
"只读状态文档",
buildHeaderDetail(document, recentProgressEvents)
));
appendStatusCard("当前目标", document.optString("projectGoal", "暂无目标"));
appendStatusCard("当前阶段", document.optString("currentPhase", "暂无阶段"));
appendStatusCard("当前进度", document.optString("currentProgress", "暂无进度"));
appendStatusCard("技术架构", document.optString("technicalArchitecture", "暂无架构"));
appendStatusCard("当前阻塞", document.optString("currentBlockers", "暂无阻塞"));
appendStatusCard("建议下一步", document.optString("recommendedNextStep", "暂无建议"));
appendRecentEvents(recentProgressEvents);
setRefreshing(false);
}
private void appendStatusCard(String title, String body) {
appendContent(BossUi.buildCard(this, title, body, ""));
}
private void appendRecentEvents(@Nullable JSONArray recentProgressEvents) {
int count = recentProgressEvents == null ? 0 : recentProgressEvents.length();
appendContent(BossUi.buildWechatMenuRow(
this,
"最近进展事件",
count <= 0 ? "当前还没有进展事件。" : "" + count + "",
"只读视图 · 最近 5 条",
null,
null
));
if (recentProgressEvents == null || recentProgressEvents.length() == 0) {
appendContent(BossUi.buildEmptyCard(this, "当前还没有进展事件。"));
return;
}
for (int i = 0; i < recentProgressEvents.length(); i++) {
JSONObject event = recentProgressEvents.optJSONObject(i);
if (event == null) continue;
LinearLayout row = BossUi.buildWechatMenuRow(
this,
event.optString("summary", "线程状态更新"),
event.optString("phase", event.optString("eventType", "progress_updated")),
buildEventMeta(event),
null,
null
);
appendContent(row);
}
}
private String buildSubtitle(JSONObject document, @Nullable JSONArray recentProgressEvents) {
int count = recentProgressEvents == null ? 0 : recentProgressEvents.length();
String folderName = document.optString("folderName", "");
String suffix = count <= 0 ? "暂无进展事件" : "最近 " + count + " 条进展事件";
if (folderName.isEmpty()) {
return suffix;
}
return folderName + " · " + suffix;
}
private String buildHeaderDetail(JSONObject document, @Nullable JSONArray recentProgressEvents) {
int count = recentProgressEvents == null ? 0 : recentProgressEvents.length();
StringBuilder builder = new StringBuilder();
String threadId = document.optString("threadId", "");
if (!threadId.isEmpty()) {
builder.append(threadId);
}
String deviceId = document.optString("deviceId", "");
if (!deviceId.isEmpty()) {
if (builder.length() > 0) {
builder.append(" · ");
}
builder.append(deviceId);
}
if (builder.length() > 0) {
builder.append(" · ");
}
builder.append(count <= 0 ? "暂无进展事件" : count + " 条进展事件");
return builder.toString();
}
private String buildEventMeta(JSONObject event) {
StringBuilder builder = new StringBuilder();
String deviceId = event.optString("deviceId", "");
if (!deviceId.isEmpty()) {
builder.append(deviceId);
}
String createdAt = event.optString("createdAt", "");
if (!createdAt.isEmpty()) {
if (builder.length() > 0) {
builder.append(" · ");
}
builder.append(createdAt);
}
if (builder.length() == 0) {
return event.optString("eventType", "progress_updated");
}
return builder.toString();
}
}

View File

@@ -100,6 +100,38 @@ public class ConversationInfoActivityTest {
);
}
@Test
public void conversationInfoShowsThreadStatusEntryForThreadConversation() throws Exception {
Intent intent = new Intent()
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
TestConversationInfoActivity activity = Robolectric
.buildActivity(TestConversationInfoActivity.class, intent)
.setup()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"renderConversation",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildDetailPayload()),
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildParticipantsPayload())
);
View threadStatusRow = findClickableViewContainingText(
activity.findViewById(R.id.screen_content),
"线程状态"
);
assertNotNull(threadStatusRow);
threadStatusRow.performClick();
Intent nextIntent = Shadows.shadowOf(activity).getNextStartedActivity();
assertNotNull(nextIntent);
assertEquals(ThreadStatusActivity.class.getName(), nextIntent.getComponent().getClassName());
assertEquals("project-1", nextIntent.getStringExtra(ThreadStatusActivity.EXTRA_PROJECT_ID));
assertEquals("北区试产线回归", nextIntent.getStringExtra(ThreadStatusActivity.EXTRA_PROJECT_NAME));
}
@Test
public void conversationInfoUsesOverflowMenuInTopBar() {
Intent intent = new Intent()

View File

@@ -0,0 +1,193 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import android.content.Context;
import android.content.Intent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.LinearLayout;
import android.widget.TextView;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import org.robolectric.util.ReflectionHelpers;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.AbstractExecutorService;
import java.util.concurrent.TimeUnit;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class ThreadStatusActivityTest {
@Test
public void reloadReadsThreadStatusAndRendersDocumentAndEvents() throws Exception {
Intent intent = new Intent()
.putExtra(ThreadStatusActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ThreadStatusActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
TestThreadStatusActivity activity = Robolectric
.buildActivity(TestThreadStatusActivity.class, intent)
.setup()
.get();
RecordingBossApiClient apiClient = new RecordingBossApiClient(
activity.getSharedPreferences("thread-status-test", Context.MODE_PRIVATE),
"https://boss.hyzq.net"
);
ReflectionHelpers.setField(activity, "apiClient", apiClient);
ReflectionHelpers.setField(activity, "executor", new DirectExecutorService());
ReflectionHelpers.setField(activity, "reloadEnabled", true);
activity.reload();
LinearLayout content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content, "当前目标"));
assertTrue(viewTreeContainsText(content, "完成线程状态回归"));
assertTrue(viewTreeContainsText(content, "当前阶段"));
assertTrue(viewTreeContainsText(content, "增量同步"));
assertTrue(viewTreeContainsText(content, "当前进度"));
assertTrue(viewTreeContainsText(content, "已经记录最近 2 条进展"));
assertTrue(viewTreeContainsText(content, "技术架构"));
assertTrue(viewTreeContainsText(content, "Android 原生客户端 + Next.js API"));
assertTrue(viewTreeContainsText(content, "当前阻塞"));
assertTrue(viewTreeContainsText(content, "建议下一步"));
assertTrue(viewTreeContainsText(content, "继续同步 Android 只读页"));
assertTrue(viewTreeContainsText(content, "最近进展事件"));
assertTrue(viewTreeContainsText(content, "事件 2"));
assertTrue(viewTreeContainsText(content, "事件 1"));
assertEquals("project-1", apiClient.lastRequestedProjectId);
assertEquals("只读", String.valueOf(activity.findViewById(R.id.screen_header_action).getContentDescription()));
}
private static final class TestThreadStatusActivity extends ThreadStatusActivity {
private boolean reloadEnabled;
@Override
protected void reload() {
if (!reloadEnabled) {
return;
}
super.reload();
}
}
private static final class RecordingBossApiClient extends BossApiClient {
private String lastRequestedProjectId = "";
RecordingBossApiClient(android.content.SharedPreferences prefs, String baseUrl) {
super(prefs, baseUrl);
}
@Override
public ApiResponse getThreadStatus(String projectId) throws java.io.IOException, org.json.JSONException {
lastRequestedProjectId = projectId;
JSONObject payload = new JSONObject()
.put("ok", true)
.put("projectId", projectId)
.put("threadStatusDocument", new JSONObject()
.put("documentId", "doc-1")
.put("projectId", projectId)
.put("threadId", "thread-1")
.put("threadDisplayName", "北区试产线回归")
.put("folderName", "Boss")
.put("deviceId", "mac-studio")
.put("projectGoal", "完成线程状态回归")
.put("currentPhase", "增量同步")
.put("currentProgress", "已经记录最近 2 条进展")
.put("technicalArchitecture", "Android 原生客户端 + Next.js API")
.put("currentBlockers", "暂无阻塞")
.put("recommendedNextStep", "继续同步 Android 只读页")
.put("keyFiles", new JSONArray().put("ThreadStatusActivity.java"))
.put("keyCommands", new JSONArray().put("./gradlew testDebugUnitTest"))
.put("updatedAt", "2026-04-04T18:00:00+08:00")
.put("sourceTaskId", "task-1")
.put("sourceKind", "incremental_sync"))
.put("recentProgressEvents", new JSONArray()
.put(new JSONObject()
.put("eventId", "event-2")
.put("projectId", projectId)
.put("threadId", "thread-1")
.put("threadDisplayName", "北区试产线回归")
.put("deviceId", "mac-studio")
.put("eventType", "progress_updated")
.put("summary", "事件 2")
.put("phase", "增量同步")
.put("createdAt", "2026-04-04T18:02:00+08:00")
.put("sourceTaskId", "task-2"))
.put(new JSONObject()
.put("eventId", "event-1")
.put("projectId", projectId)
.put("threadId", "thread-1")
.put("threadDisplayName", "北区试产线回归")
.put("deviceId", "mac-studio")
.put("eventType", "progress_updated")
.put("summary", "事件 1")
.put("phase", "增量同步")
.put("createdAt", "2026-04-04T18:01:00+08:00")
.put("sourceTaskId", "task-1")));
return new ApiResponse(200, payload);
}
}
private static final class DirectExecutorService extends AbstractExecutorService {
private boolean shutdown;
@Override
public void shutdown() {
shutdown = true;
}
@Override
public List<Runnable> shutdownNow() {
shutdown = true;
return Collections.emptyList();
}
@Override
public boolean isShutdown() {
return shutdown;
}
@Override
public boolean isTerminated() {
return shutdown;
}
@Override
public boolean awaitTermination(long timeout, TimeUnit unit) {
return true;
}
@Override
public void execute(Runnable command) {
command.run();
}
}
private static boolean viewTreeContainsText(View root, String expectedText) {
if (root instanceof TextView) {
CharSequence text = ((TextView) root).getText();
if (expectedText.contentEquals(text)) {
return true;
}
}
if (!(root instanceof ViewGroup)) {
return false;
}
ViewGroup group = (ViewGroup) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
if (viewTreeContainsText(group.getChildAt(index), expectedText)) {
return true;
}
}
return false;
}
}