diff --git a/android/app/src/main/java/com/hyzq/boss/BossApiClient.java b/android/app/src/main/java/com/hyzq/boss/BossApiClient.java index 6ad2e5d..140411b 100644 --- a/android/app/src/main/java/com/hyzq/boss/BossApiClient.java +++ b/android/app/src/main/java/com/hyzq/boss/BossApiClient.java @@ -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); diff --git a/android/app/src/main/java/com/hyzq/boss/ConversationInfoActivity.java b/android/app/src/main/java/com/hyzq/boss/ConversationInfoActivity.java index 580af67..fc72a66 100644 --- a/android/app/src/main/java/com/hyzq/boss/ConversationInfoActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/ConversationInfoActivity.java @@ -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); diff --git a/android/app/src/main/java/com/hyzq/boss/ThreadStatusActivity.java b/android/app/src/main/java/com/hyzq/boss/ThreadStatusActivity.java new file mode 100644 index 0000000..58af1d7 --- /dev/null +++ b/android/app/src/main/java/com/hyzq/boss/ThreadStatusActivity.java @@ -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(); + } +} diff --git a/android/app/src/test/java/com/hyzq/boss/ConversationInfoActivityTest.java b/android/app/src/test/java/com/hyzq/boss/ConversationInfoActivityTest.java index 4882ff5..df30585 100644 --- a/android/app/src/test/java/com/hyzq/boss/ConversationInfoActivityTest.java +++ b/android/app/src/test/java/com/hyzq/boss/ConversationInfoActivityTest.java @@ -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() diff --git a/android/app/src/test/java/com/hyzq/boss/ThreadStatusActivityTest.java b/android/app/src/test/java/com/hyzq/boss/ThreadStatusActivityTest.java new file mode 100644 index 0000000..5613959 --- /dev/null +++ b/android/app/src/test/java/com/hyzq/boss/ThreadStatusActivityTest.java @@ -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 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; + } +} diff --git a/src/app/api/v1/projects/[projectId]/thread-status/route.ts b/src/app/api/v1/projects/[projectId]/thread-status/route.ts new file mode 100644 index 0000000..dfd8450 --- /dev/null +++ b/src/app/api/v1/projects/[projectId]/thread-status/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireRequestSession } from "@/lib/boss-auth"; +import { readState } from "@/lib/boss-data"; + +export async function GET( + request: NextRequest, + context: { params: Promise<{ projectId: string }> }, +) { + const session = await requireRequestSession(request); + if (!session) { + return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }); + } + + const { projectId } = await context.params; + const state = await readState(); + const project = state.projects.find((item) => item.id === projectId); + if (!project) { + return NextResponse.json({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 }); + } + + const threadStatusDocument = + state.threadStatusDocuments.find((item) => item.projectId === projectId) ?? null; + const recentProgressEvents = state.threadProgressEvents + .filter((item) => item.projectId === projectId) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) + .slice(0, 5); + + return NextResponse.json({ + ok: true, + projectId, + threadStatusDocument, + recentProgressEvents, + }); +} diff --git a/src/components/app-ui.tsx b/src/components/app-ui.tsx index ac24854..9a889a4 100644 --- a/src/components/app-ui.tsx +++ b/src/components/app-ui.tsx @@ -762,7 +762,7 @@ export function ChatBubble({ message }: { message: Message }) { export function ProjectHeaderActions({ projectId }: { projectId: string }) { return ( -
+
转发 + + 线程状态 +
); } diff --git a/tests/thread-status-route.test.ts b/tests/thread-status-route.test.ts new file mode 100644 index 0000000..82f81fd --- /dev/null +++ b/tests/thread-status-route.test.ts @@ -0,0 +1,121 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import { mkdtemp, rm } from "node:fs/promises"; +import { NextRequest } from "next/server"; + +let runtimeRoot = ""; +let getThreadStatusRoute: (typeof import("../src/app/api/v1/projects/[projectId]/thread-status/route"))["GET"]; +let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"]; +let readState: (typeof import("../src/lib/boss-data"))["readState"]; +let writeState: (typeof import("../src/lib/boss-data"))["writeState"]; +let AUTH_SESSION_COOKIE = ""; + +async function setup() { + if (runtimeRoot) return; + + runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-thread-status-route-")); + process.env.BOSS_RUNTIME_ROOT = runtimeRoot; + process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json"); + + const [routeModule, dataModule, authModule] = await Promise.all([ + import("../src/app/api/v1/projects/[projectId]/thread-status/route.ts"), + import("../src/lib/boss-data.ts"), + import("../src/lib/boss-auth.ts"), + ]); + + getThreadStatusRoute = routeModule.GET; + createAuthSession = dataModule.createAuthSession; + readState = dataModule.readState; + writeState = dataModule.writeState; + AUTH_SESSION_COOKIE = authModule.AUTH_SESSION_COOKIE; +} + +test.after(async () => { + if (runtimeRoot) { + await rm(runtimeRoot, { recursive: true, force: true }); + } +}); + +async function createAuthedRequest(url: string) { + const session = await createAuthSession({ + account: "17600003315", + role: "highest_admin", + displayName: "Boss 超级管理员", + loginMethod: "password", + }); + + return new NextRequest(url, { + method: "GET", + headers: { + cookie: `${AUTH_SESSION_COOKIE}=${session.sessionToken}`, + }, + }); +} + +test("GET thread-status returns current document and recent progress events", async () => { + await setup(); + + const state = await readState(); + const project = state.projects.find((item) => item.id === "master-agent"); + assert.ok(project, "expected seeded master-agent project"); + + state.threadStatusDocuments = [ + { + documentId: "doc-1", + projectId: "master-agent", + threadId: "thread-master-agent", + threadDisplayName: "主 Agent", + folderName: "Master", + deviceId: "mac-studio", + projectGoal: "完成线程状态同步", + currentPhase: "增量同步", + currentProgress: "已经记录最近进展事件", + technicalArchitecture: "Next.js API + Android 原生客户端", + currentBlockers: "", + recommendedNextStep: "继续补 Android 只读页", + keyFiles: ["src/app/api/v1/projects/[projectId]/thread-status/route.ts"], + keyCommands: ["npx --yes tsx --test tests/thread-status-route.test.ts"], + updatedAt: "2026-04-04T18:00:00+08:00", + sourceTaskId: "task-1", + sourceKind: "incremental_sync", + }, + ]; + state.threadProgressEvents = Array.from({ length: 7 }, (_, index) => ({ + eventId: `event-${index}`, + projectId: "master-agent", + threadId: "thread-master-agent", + threadDisplayName: "主 Agent", + deviceId: "mac-studio", + eventType: "progress_updated", + summary: `进展 ${index}`, + phase: "增量同步", + blockerDelta: "", + nextStepDelta: "", + createdAt: `2026-04-04T18:0${index}:00+08:00`, + sourceTaskId: `task-${index}`, + })); + await writeState(state); + + const response = await getThreadStatusRoute( + await createAuthedRequest("http://127.0.0.1:3000/api/v1/projects/master-agent/thread-status"), + { params: Promise.resolve({ projectId: "master-agent" }) }, + ); + + assert.equal(response.status, 200); + const body = (await response.json()) as { + ok: boolean; + projectId: string; + threadStatusDocument: { documentId: string; threadDisplayName: string; projectGoal: string } | null; + recentProgressEvents: Array<{ eventId: string; summary: string }>; + }; + assert.equal(body.ok, true); + assert.equal(body.projectId, "master-agent"); + assert.equal(body.threadStatusDocument?.documentId, "doc-1"); + assert.equal(body.threadStatusDocument?.threadDisplayName, "主 Agent"); + assert.equal(body.threadStatusDocument?.projectGoal, "完成线程状态同步"); + assert.equal(body.recentProgressEvents.length, 5); + assert.equal(body.recentProgressEvents[0]?.eventId, "event-6"); + assert.equal(body.recentProgressEvents[4]?.eventId, "event-2"); +});