feat: queue device import review tasks

This commit is contained in:
kris
2026-03-31 22:38:57 +08:00
parent dcbff3cc7d
commit 87ffe19f78
7 changed files with 347 additions and 117 deletions

View File

@@ -23,7 +23,9 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
private String deviceName;
private @Nullable JSONObject currentDraft;
private @Nullable JSONObject currentResolution;
private @Nullable JSONObject currentReviewTask;
private final LinkedHashSet<String> selectedCandidateIds = new LinkedHashSet<>();
private final Runnable reviewPollRunnable = this::reload;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
@@ -48,7 +50,11 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
runOnUiThread(() -> applyPayload(response.json.optJSONObject("draft"), response.json.optJSONObject("resolution")));
runOnUiThread(() -> applyPayload(
response.json.optJSONObject("draft"),
response.json.optJSONObject("resolution"),
response.json.optJSONObject("reviewTask")
));
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
@@ -58,9 +64,10 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
});
}
private void applyPayload(@Nullable JSONObject draft, @Nullable JSONObject resolution) {
private void applyPayload(@Nullable JSONObject draft, @Nullable JSONObject resolution, @Nullable JSONObject reviewTask) {
currentDraft = draft;
currentResolution = resolution;
currentReviewTask = reviewTask;
selectedCandidateIds.clear();
JSONArray selected = draft == null ? null : draft.optJSONArray("selectedCandidateIds");
if (selected != null) {
@@ -74,9 +81,31 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
renderCurrentState();
}
@Override
protected void onDestroy() {
contentLayout.removeCallbacks(reviewPollRunnable);
super.onDestroy();
}
private boolean isReviewPending(@Nullable JSONObject draft, @Nullable JSONObject resolution, @Nullable JSONObject reviewTask) {
if (draft == null || resolution != null) {
return false;
}
if (!"pending_resolution".equals(draft.optString("status", ""))) {
return false;
}
if (reviewTask == null) {
return false;
}
String taskStatus = reviewTask.optString("status", "");
return "queued".equals(taskStatus) || "running".equals(taskStatus);
}
private void renderCurrentState() {
JSONObject draft = currentDraft;
JSONObject resolution = currentResolution;
JSONObject reviewTask = currentReviewTask;
contentLayout.removeCallbacks(reviewPollRunnable);
replaceContent();
appendContent(BossUi.buildSoftPanel(
this,
@@ -110,7 +139,7 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
appendContent(BossUi.buildCard(
this,
resolveStatusTitle(draft),
resolveStatusBody(draft, resolution),
resolveStatusBody(draft, resolution, reviewTask),
"候选 " + candidates.length()
+ " · 已选 " + selectedCandidateIds.size()
+ " · 推荐 " + recommendedCount
@@ -183,6 +212,17 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
}
}
if (reviewTask != null) {
appendContent(BossUi.buildCard(
this,
"审核任务",
"状态:" + reviewTask.optString("status", "unknown"),
isReviewPending(draft, resolution, reviewTask)
? "主 Agent 正在生成导入建议,页面会自动刷新。"
: "如果任务失败,可以直接重新生成导入建议。"
));
}
JSONArray appliedProjectNames = draft.optJSONArray("appliedProjectNames");
if (appliedProjectNames != null && appliedProjectNames.length() > 0) {
appendContent(BossUi.buildCard(
@@ -194,7 +234,14 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
}
Button reviewButton = BossUi.buildMiniActionButton(this, "生成导入建议", true);
reviewButton.setEnabled(!selectedCandidateIds.isEmpty());
reviewButton.setEnabled(!selectedCandidateIds.isEmpty() && !isReviewPending(draft, resolution, reviewTask));
if (isReviewPending(draft, resolution, reviewTask)) {
reviewButton.setText("主 Agent 审核中");
} else if (reviewTask != null && "failed".equals(reviewTask.optString("status", ""))) {
reviewButton.setText("重新生成导入建议");
} else if ("resolved".equals(draft.optString("status", "")) || "applied".equals(draft.optString("status", ""))) {
reviewButton.setText("重新生成导入建议");
}
reviewButton.setOnClickListener(v -> reviewSelection());
Button clearButton = BossUi.buildMiniActionButton(this, "清空勾选", false);
clearButton.setEnabled(!selectedCandidateIds.isEmpty());
@@ -208,6 +255,9 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
applyButton.setOnClickListener(v -> applyResolution());
appendContent(BossUi.buildInlineActionRow(this, reviewButton, clearButton, applyButton));
setRefreshing(false);
if (isReviewPending(draft, resolution, reviewTask)) {
contentLayout.postDelayed(reviewPollRunnable, 2000);
}
}
private String resolveStatusTitle(@Nullable JSONObject draft) {
@@ -233,7 +283,7 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
return "导入草稿";
}
private String resolveStatusBody(@Nullable JSONObject draft, @Nullable JSONObject resolution) {
private String resolveStatusBody(@Nullable JSONObject draft, @Nullable JSONObject resolution, @Nullable JSONObject reviewTask) {
if (draft == null) {
return "先让设备完成首次 heartbeat 并上报候选线程,导入草稿就会出现在这里。";
}
@@ -245,6 +295,12 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
return "先勾选想导入的线程,再生成导入建议。";
}
if ("pending_resolution".equals(status)) {
if (isReviewPending(draft, resolution, reviewTask)) {
return "勾选已保存,主 Agent 正在整理导入建议,页面会自动刷新。";
}
if (reviewTask != null && "failed".equals(reviewTask.optString("status", ""))) {
return "主 Agent 这次没能生成导入建议。可以稍后重新生成,当前勾选会保留。";
}
return "勾选已保存,接下来会生成导入建议。";
}
if ("resolved".equals(status)) {
@@ -306,14 +362,21 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
throw new IllegalStateException(reviewResponse.message());
}
runOnUiThread(() -> {
showMessage("已生成导入建议");
applyPayload(reviewResponse.json.optJSONObject("draft"), reviewResponse.json.optJSONObject("resolution"));
boolean hasResolution = reviewResponse.json.optJSONObject("resolution") != null;
showMessage(hasResolution ? "已生成导入建议" : "已提交给主 Agent 审核");
applyPayload(
reviewResponse.json.optJSONObject("draft"),
reviewResponse.json.optJSONObject("resolution"),
reviewResponse.json.optJSONObject("reviewTask") != null
? reviewResponse.json.optJSONObject("reviewTask")
: reviewResponse.json.optJSONObject("task")
);
});
} catch (Exception error) {
final JSONObject fallbackDraft = selectedDraft;
runOnUiThread(() -> {
if (fallbackDraft != null) {
applyPayload(fallbackDraft, null);
applyPayload(fallbackDraft, null, null);
} else {
setRefreshing(false);
}
@@ -337,7 +400,7 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
}
runOnUiThread(() -> {
showMessage("已清空当前勾选");
applyPayload(response.json.optJSONObject("draft"), null);
applyPayload(response.json.optJSONObject("draft"), null, null);
});
} catch (Exception error) {
runOnUiThread(() -> {
@@ -362,7 +425,7 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
}
runOnUiThread(() -> {
showMessage("已应用导入");
applyPayload(response.json.optJSONObject("draft"), response.json.optJSONObject("resolution"));
applyPayload(response.json.optJSONObject("draft"), response.json.optJSONObject("resolution"), null);
});
} catch (Exception error) {
runOnUiThread(() -> {

View File

@@ -36,6 +36,7 @@ public class DeviceImportDraftActivityTest {
activity,
"applyPayload",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildPendingDraft()),
ReflectionHelpers.ClassParameter.from(JSONObject.class, null),
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
);
@@ -62,7 +63,8 @@ public class DeviceImportDraftActivityTest {
activity,
"applyPayload",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildAppliedDraft()),
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildAppliedResolution())
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildAppliedResolution()),
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
);
View content = activity.findViewById(R.id.screen_content);
@@ -73,6 +75,32 @@ public class DeviceImportDraftActivityTest {
assertTrue(viewTreeContainsText(content, "已导入"));
}
@Test
public void renderCurrentStateShowsQueuedReviewTaskCopy() throws Exception {
TestDeviceImportDraftActivity activity = Robolectric
.buildActivity(
TestDeviceImportDraftActivity.class,
new Intent()
.putExtra(DeviceImportDraftActivity.EXTRA_DEVICE_ID, "device-1")
.putExtra(DeviceImportDraftActivity.EXTRA_DEVICE_NAME, "Mac Studio")
)
.setup()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"applyPayload",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildPendingResolutionDraft()),
ReflectionHelpers.ClassParameter.from(JSONObject.class, null),
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildQueuedReviewTask())
);
View content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content, "主 Agent 审核中"));
assertTrue(viewTreeContainsText(content, "审核任务"));
assertTrue(viewTreeContainsText(content, "状态queued"));
}
private static JSONObject buildPendingDraft() throws Exception {
return new JSONObject()
.put("draftId", "draft-1")
@@ -125,6 +153,24 @@ public class DeviceImportDraftActivityTest {
.put("suggestedImport", true)));
}
private static JSONObject buildPendingResolutionDraft() throws Exception {
return new JSONObject()
.put("draftId", "draft-1")
.put("deviceId", "device-1")
.put("status", "pending_resolution")
.put("selectedCandidateIds", new JSONArray().put("candidate-1"))
.put("appliedProjectNames", new JSONArray())
.put("candidates", new JSONArray()
.put(new JSONObject()
.put("candidateId", "candidate-1")
.put("deviceId", "device-1")
.put("folderName", "北区试产线")
.put("threadId", "thread-1")
.put("threadDisplayName", "北区试产线回归")
.put("lastActiveAt", "2026-03-30T10:18:00+08:00")
.put("suggestedImport", true)));
}
private static JSONObject buildAppliedResolution() throws Exception {
return new JSONObject()
.put("resolutionId", "resolution-1")
@@ -147,6 +193,13 @@ public class DeviceImportDraftActivityTest {
.put("reason", "作为独立聊天窗口导入。")));
}
private static JSONObject buildQueuedReviewTask() throws Exception {
return new JSONObject()
.put("taskId", "mastertask-1")
.put("taskType", "device_import_resolution")
.put("status", "queued");
}
private static boolean viewTreeContainsText(View root, String expectedText) {
if (root instanceof TextView) {
CharSequence text = ((TextView) root).getText();