Scope folder realtime refreshes by device
This commit is contained in:
@@ -24,6 +24,7 @@ public class ConversationFolderActivity extends BossScreenActivity {
|
|||||||
|
|
||||||
private String folderKey;
|
private String folderKey;
|
||||||
private String folderName;
|
private String folderName;
|
||||||
|
private String folderDeviceId;
|
||||||
private String targetProjectId;
|
private String targetProjectId;
|
||||||
private ArrayList<String> targetProjectIds;
|
private ArrayList<String> targetProjectIds;
|
||||||
private String targetProjectLabel;
|
private String targetProjectLabel;
|
||||||
@@ -36,6 +37,7 @@ public class ConversationFolderActivity extends BossScreenActivity {
|
|||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
folderKey = getIntent().getStringExtra(EXTRA_FOLDER_KEY);
|
folderKey = getIntent().getStringExtra(EXTRA_FOLDER_KEY);
|
||||||
folderName = getIntent().getStringExtra(EXTRA_FOLDER_NAME);
|
folderName = getIntent().getStringExtra(EXTRA_FOLDER_NAME);
|
||||||
|
folderDeviceId = parseFolderDeviceId(folderKey);
|
||||||
targetProjectId = getIntent().getStringExtra(EXTRA_TARGET_PROJECT_ID);
|
targetProjectId = getIntent().getStringExtra(EXTRA_TARGET_PROJECT_ID);
|
||||||
targetProjectIds = new ArrayList<>();
|
targetProjectIds = new ArrayList<>();
|
||||||
String[] extraTargetProjectIds = getIntent().getStringArrayExtra(EXTRA_TARGET_PROJECT_IDS);
|
String[] extraTargetProjectIds = getIntent().getStringArrayExtra(EXTRA_TARGET_PROJECT_IDS);
|
||||||
@@ -137,12 +139,16 @@ public class ConversationFolderActivity extends BossScreenActivity {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
String payloadProjectId = event.payload.optString("projectId", "").trim();
|
String payloadProjectId = event.payload.optString("projectId", "").trim();
|
||||||
if (payloadProjectId.isEmpty()) {
|
if (!payloadProjectId.isEmpty()) {
|
||||||
|
return trackedProjectIds.contains(payloadProjectId)
|
||||||
|
|| (!targetProjectIds.isEmpty() && targetProjectIds.contains(payloadProjectId))
|
||||||
|
|| (targetProjectId != null && targetProjectId.equals(payloadProjectId));
|
||||||
|
}
|
||||||
|
String payloadDeviceId = event.payload.optString("deviceId", "").trim();
|
||||||
|
if (payloadDeviceId.isEmpty() || folderDeviceId == null || folderDeviceId.isEmpty()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return trackedProjectIds.contains(payloadProjectId)
|
return payloadDeviceId.equals(folderDeviceId);
|
||||||
|| (!targetProjectIds.isEmpty() && targetProjectIds.contains(payloadProjectId))
|
|
||||||
|| (targetProjectId != null && targetProjectId.equals(payloadProjectId));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isDuplicateRealtimeEvent(String eventFingerprint, long now) {
|
private boolean isDuplicateRealtimeEvent(String eventFingerprint, long now) {
|
||||||
@@ -175,6 +181,7 @@ public class ConversationFolderActivity extends BossScreenActivity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String resolvedFolderName = folder.optString("folderLabel", folderName == null ? "项目线程" : folderName);
|
String resolvedFolderName = folder.optString("folderLabel", folderName == null ? "项目线程" : folderName);
|
||||||
|
folderDeviceId = folder.optString("deviceId", folderDeviceId == null ? "" : folderDeviceId).trim();
|
||||||
int threadCount = folder.optInt("threadCount", 0);
|
int threadCount = folder.optInt("threadCount", 0);
|
||||||
configureScreen(resolvedFolderName, threadCount + " 个线程");
|
configureScreen(resolvedFolderName, threadCount + " 个线程");
|
||||||
appendContent(BossUi.buildSoftPanel(
|
appendContent(BossUi.buildSoftPanel(
|
||||||
@@ -239,6 +246,17 @@ public class ConversationFolderActivity extends BossScreenActivity {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String parseFolderDeviceId(@Nullable String candidateFolderKey) {
|
||||||
|
if (candidateFolderKey == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
int separatorIndex = candidateFolderKey.indexOf(':');
|
||||||
|
if (separatorIndex <= 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return candidateFolderKey.substring(0, separatorIndex).trim();
|
||||||
|
}
|
||||||
|
|
||||||
private void renderThreadAtIndex(JSONArray threads, int index, boolean highlighted) {
|
private void renderThreadAtIndex(JSONArray threads, int index, boolean highlighted) {
|
||||||
JSONObject item = threads.optJSONObject(index);
|
JSONObject item = threads.optJSONObject(index);
|
||||||
if (item == null) return;
|
if (item == null) return;
|
||||||
|
|||||||
197
docs/superpowers/plans/2026-04-10-sse-refresh-noise-reduction.md
Normal file
197
docs/superpowers/plans/2026-04-10-sse-refresh-noise-reduction.md
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
# SSE Refresh Noise Reduction Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Reduce unnecessary realtime reloads on scoped conversation folder pages without breaking global conversation refresh behavior.
|
||||||
|
|
||||||
|
**Architecture:** Keep server-side event names stable and narrow consumption at scoped clients. The shared Web realtime refresh utility accepts optional device scope, while the Android folder activity applies the same project-first, device-only fallback filter.
|
||||||
|
|
||||||
|
**Tech Stack:** Next.js App Router, TypeScript `node:test`, Android Java native activities, Server-Sent Events.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add Device Scope To Shared Web Refresh Filtering
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/lib/realtime-refresh.ts`
|
||||||
|
- Test: `tests/realtime-refresh-utils.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
assert.equal(
|
||||||
|
shouldRefreshRealtimeEvent({
|
||||||
|
eventType: "conversation.updated",
|
||||||
|
eventData: JSON.stringify({ deviceId: "mac-studio" }),
|
||||||
|
projectIds: ["project-a", "project-b"],
|
||||||
|
deviceId: "mac-studio",
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
shouldRefreshRealtimeEvent({
|
||||||
|
eventType: "conversation.updated",
|
||||||
|
eventData: JSON.stringify({ deviceId: "windows-box" }),
|
||||||
|
projectIds: ["project-a", "project-b"],
|
||||||
|
deviceId: "mac-studio",
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the focused test and verify the new device-scope test fails**
|
||||||
|
|
||||||
|
Run: `npx tsx --test tests/realtime-refresh-utils.test.ts`
|
||||||
|
|
||||||
|
Expected: the new device-scope case fails before `deviceId` support exists in `RealtimeRefreshScope`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add minimal device scope support**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface RealtimeRefreshScope {
|
||||||
|
projectId?: string;
|
||||||
|
projectIds?: string[];
|
||||||
|
deviceId?: string;
|
||||||
|
deviceIds?: string[];
|
||||||
|
conversationUpdatedNotes?: string[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then parse `deviceId` from the JSON payload and require scoped listeners to match at least one scoped project or device identifier before refreshing.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the focused test and verify it passes**
|
||||||
|
|
||||||
|
Run: `npx tsx --test tests/realtime-refresh-utils.test.ts`
|
||||||
|
|
||||||
|
Expected: all realtime refresh utility tests pass.
|
||||||
|
|
||||||
|
### Task 2: Wire Web Folder Device Scope
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/components/app-runtime.tsx`
|
||||||
|
- Modify: `src/app/conversations/folders/[folderKey]/page.tsx`
|
||||||
|
- Test: `tests/project-scoped-realtime-refresh.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing route wiring assertion**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
assert.match(
|
||||||
|
folderPage,
|
||||||
|
/deviceId=\{folder\.deviceId\}/,
|
||||||
|
"expected folder page to scope device-only refreshes to the folder device",
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the route test and verify the assertion fails**
|
||||||
|
|
||||||
|
Run: `npx tsx --test tests/project-scoped-realtime-refresh.test.ts`
|
||||||
|
|
||||||
|
Expected: the assertion fails until the folder page passes `deviceId`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Pass device scope through the runtime component**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<RealtimeRefresh
|
||||||
|
projectIds={folder.threads.map((thread) => thread.projectId)}
|
||||||
|
deviceId={folder.deviceId}
|
||||||
|
events={["conversation.updated", "project.messages.updated"]}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
Also pass `deviceId` and `deviceIds` from `RealtimeRefresh` into `shouldRefreshRealtimeEvent`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the route test and verify it passes**
|
||||||
|
|
||||||
|
Run: `npx tsx --test tests/project-scoped-realtime-refresh.test.ts`
|
||||||
|
|
||||||
|
Expected: the folder route wiring test passes.
|
||||||
|
|
||||||
|
### Task 3: Mirror Filtering In Android Folder Activity
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `android/app/src/main/java/com/hyzq/boss/ConversationFolderActivity.java`
|
||||||
|
- Test: `tests/android-folder-realtime-refresh.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing Android source assertion**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
assert.match(
|
||||||
|
source,
|
||||||
|
/String payloadDeviceId = event\.payload\.optString\("deviceId", ""\)\.trim\(\);/,
|
||||||
|
"expected folder activity to inspect device-scoped realtime payloads",
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the Android static test and verify it fails**
|
||||||
|
|
||||||
|
Run: `npx tsx --test tests/android-folder-realtime-refresh.test.ts`
|
||||||
|
|
||||||
|
Expected: the test fails until the folder activity reads `deviceId`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add project-first and device-only filtering**
|
||||||
|
|
||||||
|
```java
|
||||||
|
String payloadProjectId = event.payload.optString("projectId", "").trim();
|
||||||
|
if (!payloadProjectId.isEmpty()) {
|
||||||
|
return trackedProjectIds.contains(payloadProjectId)
|
||||||
|
|| (!targetProjectIds.isEmpty() && targetProjectIds.contains(payloadProjectId))
|
||||||
|
|| (targetProjectId != null && targetProjectId.equals(payloadProjectId));
|
||||||
|
}
|
||||||
|
String payloadDeviceId = event.payload.optString("deviceId", "").trim();
|
||||||
|
if (payloadDeviceId.isEmpty() || folderDeviceId == null || folderDeviceId.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return payloadDeviceId.equals(folderDeviceId);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the Android static test and verify it passes**
|
||||||
|
|
||||||
|
Run: `npx tsx --test tests/android-folder-realtime-refresh.test.ts`
|
||||||
|
|
||||||
|
Expected: the Android folder realtime filtering test passes.
|
||||||
|
|
||||||
|
### Task 4: Verify Integration
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Verify: `src/lib/realtime-refresh.ts`
|
||||||
|
- Verify: `src/components/app-runtime.tsx`
|
||||||
|
- Verify: `src/app/conversations/folders/[folderKey]/page.tsx`
|
||||||
|
- Verify: `android/app/src/main/java/com/hyzq/boss/ConversationFolderActivity.java`
|
||||||
|
- Verify: `scripts/deploy-server.sh`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run focused realtime tests**
|
||||||
|
|
||||||
|
Run: `npx tsx --test tests/realtime-refresh-utils.test.ts tests/project-scoped-realtime-refresh.test.ts tests/android-folder-realtime-refresh.test.ts`
|
||||||
|
|
||||||
|
Expected: all focused realtime tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run deployment script guard test**
|
||||||
|
|
||||||
|
Run: `npx tsx --test tests/deploy-server-script.test.ts`
|
||||||
|
|
||||||
|
Expected: the deploy script tests pass and assert `.project`, `.classpath`, and `.settings` are excluded from rsync.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run broader realtime page tests**
|
||||||
|
|
||||||
|
Run: `npx tsx --test tests/realtime-refresh-utils.test.ts tests/project-scoped-realtime-refresh.test.ts tests/android-folder-realtime-refresh.test.ts tests/config-pages-realtime-refresh.test.ts tests/config-state-events.test.ts tests/settings-page-realtime-refresh.test.ts tests/settings-state-events.test.ts tests/status-pages-realtime-refresh.test.ts`
|
||||||
|
|
||||||
|
Expected: all selected realtime and event contract tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run project gates**
|
||||||
|
|
||||||
|
Run: `npm run lint`
|
||||||
|
|
||||||
|
Expected: lint exits 0.
|
||||||
|
|
||||||
|
Run: `npm run build`
|
||||||
|
|
||||||
|
Expected: production build exits 0.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Stage only intended files**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/lib/realtime-refresh.ts src/components/app-runtime.tsx 'src/app/conversations/folders/[folderKey]/page.tsx' tests/realtime-refresh-utils.test.ts tests/project-scoped-realtime-refresh.test.ts tests/android-folder-realtime-refresh.test.ts android/app/src/main/java/com/hyzq/boss/ConversationFolderActivity.java scripts/deploy-server.sh tests/deploy-server-script.test.ts docs/superpowers/specs/2026-04-10-sse-refresh-noise-reduction-design.md docs/superpowers/plans/2026-04-10-sse-refresh-noise-reduction.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: generated IDE metadata under `android/.project`, `android/.settings`, `android/app/.project`, `android/app/.settings`, and `android/app/.classpath` remains untracked.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# SSE Refresh Noise Reduction Design
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Reduce unnecessary realtime refreshes on scoped conversation folder pages while preserving the global conversation list's ability to refresh when device-level imports or thread discovery change the folder structure.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Boss currently publishes `conversation.updated` events for both project-scoped message changes and device-scoped import or discovery changes. The conversations root page should continue listening broadly because it needs to reflect new folders and latest replies across all devices. Folder detail pages are narrower: they should reload only when an event references a project in that folder, or when a device-scoped event targets that folder's owning device.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
Keep the existing backend event names stable. Add device scope support to the shared `RealtimeRefresh` filter so a scoped page can opt into `deviceId` or `deviceIds` alongside `projectId` or `projectIds`. If a scoped listener receives an event without any matching scoped identifier, it must ignore that event. If an event carries a project id, the listener should match it against the scoped project list. If an event carries only a device id, the listener should match it against the scoped device list.
|
||||||
|
|
||||||
|
The Web folder route passes the folder's `deviceId` to `RealtimeRefresh` in addition to its thread project ids. The Android folder activity mirrors the same rule locally: project events match known project ids; device-only conversation events match the parsed or loaded folder device id; unrelated device events do not reload the folder.
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
Malformed or empty SSE payloads are treated as unscoped. Scoped pages ignore them to avoid broad reload storms. Unscoped pages, including the conversations root list, keep the prior broad refresh behavior because they intentionally represent aggregate state.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
The shared realtime refresh utility tests cover project matching, missing identifiers, matching device scope, and mismatched device scope. Static route tests assert that the Web folder page wires `folder.deviceId` into `RealtimeRefresh`. Android static tests assert that `ConversationFolderActivity` tracks a folder device id and filters device-scoped payloads against it.
|
||||||
|
|
||||||
|
The deployment script test also asserts that local IDE metadata paths are excluded from rsync, because untracked Android IDE files should not be uploaded during this release.
|
||||||
|
|
||||||
|
## Out Of Scope
|
||||||
|
|
||||||
|
This change does not rename backend event types, remove device-level `conversation.updated` publications, or alter the root conversation list. It also does not include IDE metadata generated under `android/.project`, `android/.settings`, `android/app/.project`, `android/app/.settings`, or `android/app/.classpath`; those paths are explicitly excluded from deployment rsync.
|
||||||
@@ -78,6 +78,9 @@ RSYNC_EXCLUDES=(
|
|||||||
--exclude "node_modules"
|
--exclude "node_modules"
|
||||||
--exclude "data/"
|
--exclude "data/"
|
||||||
--exclude "android/app/build"
|
--exclude "android/app/build"
|
||||||
|
--exclude ".project"
|
||||||
|
--exclude ".classpath"
|
||||||
|
--exclude ".settings"
|
||||||
--exclude ".DS_Store"
|
--exclude ".DS_Store"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export default async function ConversationFolderPage({
|
|||||||
{folder ? (
|
{folder ? (
|
||||||
<RealtimeRefresh
|
<RealtimeRefresh
|
||||||
projectIds={folder.threads.map((thread) => thread.projectId)}
|
projectIds={folder.threads.map((thread) => thread.projectId)}
|
||||||
|
deviceId={folder.deviceId}
|
||||||
events={["conversation.updated", "project.messages.updated"]}
|
events={["conversation.updated", "project.messages.updated"]}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -262,11 +262,15 @@ export function RealtimeRefresh({
|
|||||||
events,
|
events,
|
||||||
projectId,
|
projectId,
|
||||||
projectIds,
|
projectIds,
|
||||||
|
deviceId,
|
||||||
|
deviceIds,
|
||||||
conversationUpdatedNotes,
|
conversationUpdatedNotes,
|
||||||
}: {
|
}: {
|
||||||
events: BossEventName[];
|
events: BossEventName[];
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
projectIds?: string[];
|
projectIds?: string[];
|
||||||
|
deviceId?: string;
|
||||||
|
deviceIds?: string[];
|
||||||
conversationUpdatedNotes?: string[];
|
conversationUpdatedNotes?: string[];
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -291,6 +295,8 @@ export function RealtimeRefresh({
|
|||||||
eventData,
|
eventData,
|
||||||
projectId,
|
projectId,
|
||||||
projectIds,
|
projectIds,
|
||||||
|
deviceId,
|
||||||
|
deviceIds,
|
||||||
conversationUpdatedNotes,
|
conversationUpdatedNotes,
|
||||||
})) {
|
})) {
|
||||||
return;
|
return;
|
||||||
@@ -326,7 +332,7 @@ export function RealtimeRefresh({
|
|||||||
cancelScheduledRefresh(throttleState);
|
cancelScheduledRefresh(throttleState);
|
||||||
source.close();
|
source.close();
|
||||||
};
|
};
|
||||||
}, [conversationUpdatedNotes, events, projectId, projectIds, router]);
|
}, [conversationUpdatedNotes, deviceId, deviceIds, events, projectId, projectIds, router]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
export interface RealtimeRefreshScope {
|
export interface RealtimeRefreshScope {
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
projectIds?: string[];
|
projectIds?: string[];
|
||||||
|
deviceId?: string;
|
||||||
|
deviceIds?: string[];
|
||||||
conversationUpdatedNotes?: string[];
|
conversationUpdatedNotes?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,6 +18,7 @@ export type RefreshThrottleDecision =
|
|||||||
|
|
||||||
interface RealtimeEventPayload {
|
interface RealtimeEventPayload {
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
|
deviceId?: string;
|
||||||
note?: string;
|
note?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,16 +47,35 @@ export function shouldRefreshRealtimeEvent(input: {
|
|||||||
.filter((value): value is string => Boolean(value?.trim()))
|
.filter((value): value is string => Boolean(value?.trim()))
|
||||||
.map((value) => value.trim()),
|
.map((value) => value.trim()),
|
||||||
);
|
);
|
||||||
|
const deviceScopeIds = new Set(
|
||||||
|
[input.deviceId, ...(input.deviceIds ?? [])]
|
||||||
|
.filter((value): value is string => Boolean(value?.trim()))
|
||||||
|
.map((value) => value.trim()),
|
||||||
|
);
|
||||||
|
let matchedScopedIdentifier = false;
|
||||||
|
|
||||||
if (projectScopeIds.size > 0) {
|
if (projectScopeIds.size > 0) {
|
||||||
if (!payload || typeof payload.projectId !== "string" || !payload.projectId.trim()) {
|
if (payload && typeof payload.projectId === "string" && payload.projectId.trim()) {
|
||||||
return true;
|
if (!projectScopeIds.has(payload.projectId.trim())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
matchedScopedIdentifier = true;
|
||||||
}
|
}
|
||||||
if (!projectScopeIds.has(payload.projectId)) {
|
}
|
||||||
return false;
|
|
||||||
|
if (deviceScopeIds.size > 0) {
|
||||||
|
if (payload && typeof payload.deviceId === "string" && payload.deviceId.trim()) {
|
||||||
|
if (!deviceScopeIds.has(payload.deviceId.trim())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
matchedScopedIdentifier = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((projectScopeIds.size > 0 || deviceScopeIds.size > 0) && !matchedScopedIdentifier) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (input.eventType === "conversation.updated" && input.conversationUpdatedNotes?.length) {
|
if (input.eventType === "conversation.updated" && input.conversationUpdatedNotes?.length) {
|
||||||
if (!payload || typeof payload.note !== "string" || !payload.note.trim()) {
|
if (!payload || typeof payload.note !== "string" || !payload.note.trim()) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
26
tests/android-folder-realtime-refresh.test.ts
Normal file
26
tests/android-folder-realtime-refresh.test.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
|
||||||
|
test("android folder activity refreshes on matching device-scoped conversation updates", async () => {
|
||||||
|
const source = await readFile(
|
||||||
|
new URL("../android/app/src/main/java/com/hyzq/boss/ConversationFolderActivity.java", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(
|
||||||
|
source,
|
||||||
|
/private String folderDeviceId;/,
|
||||||
|
"expected folder activity to keep track of the folder device id for realtime filtering",
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
source,
|
||||||
|
/String payloadDeviceId = event\.payload\.optString\("deviceId", ""\)\.trim\(\);/,
|
||||||
|
"expected folder activity to inspect device-scoped realtime payloads",
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
source,
|
||||||
|
/payloadDeviceId\.equals\(folderDeviceId\)/,
|
||||||
|
"expected folder activity to reload when the event targets its device",
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -83,6 +83,9 @@ exec "$@"
|
|||||||
assert.equal(sshCalls.length, 2);
|
assert.equal(sshCalls.length, 2);
|
||||||
assert.match(sshCalls[0] ?? "", /sudo mkdir -p \/opt\/boss/);
|
assert.match(sshCalls[0] ?? "", /sudo mkdir -p \/opt\/boss/);
|
||||||
assert.match(rsyncArgs, /--rsync-path=sudo rsync/);
|
assert.match(rsyncArgs, /--rsync-path=sudo rsync/);
|
||||||
|
assert.match(rsyncArgs, /--exclude \.project/);
|
||||||
|
assert.match(rsyncArgs, /--exclude \.classpath/);
|
||||||
|
assert.match(rsyncArgs, /--exclude \.settings/);
|
||||||
assert.match(sshCalls[1] ?? "", /bootstrap-server\.sh/);
|
assert.match(sshCalls[1] ?? "", /bootstrap-server\.sh/);
|
||||||
assert.match(sshCalls[1] ?? "", /sudo chown -R ubuntu:ubuntu \/opt\/boss\/data \/opt\/boss\/public\/downloads/);
|
assert.match(sshCalls[1] ?? "", /sudo chown -R ubuntu:ubuntu \/opt\/boss\/data \/opt\/boss\/public\/downloads/);
|
||||||
assert.match(sshCalls[1] ?? "", /npm install --omit=dev/);
|
assert.match(sshCalls[1] ?? "", /npm install --omit=dev/);
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ test("folder conversation page wires folder thread ids into realtime refresh", a
|
|||||||
|
|
||||||
assert.match(folderPage, /<RealtimeRefresh/, "expected folder page to render RealtimeRefresh");
|
assert.match(folderPage, /<RealtimeRefresh/, "expected folder page to render RealtimeRefresh");
|
||||||
assert.match(folderPage, /projectIds=\{folder\.threads\.map\(\(thread\) => thread\.projectId\)\}/, "expected folder page to scope refreshes to folder thread project ids");
|
assert.match(folderPage, /projectIds=\{folder\.threads\.map\(\(thread\) => thread\.projectId\)\}/, "expected folder page to scope refreshes to folder thread project ids");
|
||||||
|
assert.match(folderPage, /deviceId=\{folder\.deviceId\}/, "expected folder page to scope device-only refreshes to the folder device");
|
||||||
assert.match(folderPage, /"conversation\.updated"/, "expected folder page to listen to conversation updates");
|
assert.match(folderPage, /"conversation\.updated"/, "expected folder page to listen to conversation updates");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,48 @@ test("shouldRefreshRealtimeEvent filters scoped conversation updates by project
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("shouldRefreshRealtimeEvent ignores scoped events that do not identify a project", () => {
|
||||||
|
assert.equal(
|
||||||
|
shouldRefreshRealtimeEvent({
|
||||||
|
eventType: "conversation.updated",
|
||||||
|
eventData: JSON.stringify({ deviceId: "mac-studio" }),
|
||||||
|
projectId: "project-a",
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
shouldRefreshRealtimeEvent({
|
||||||
|
eventType: "project.context_risk.updated",
|
||||||
|
eventData: JSON.stringify({ deviceId: "mac-studio" }),
|
||||||
|
projectIds: ["project-a", "project-b"],
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shouldRefreshRealtimeEvent can match scoped device events when page opts into device scope", () => {
|
||||||
|
assert.equal(
|
||||||
|
shouldRefreshRealtimeEvent({
|
||||||
|
eventType: "conversation.updated",
|
||||||
|
eventData: JSON.stringify({ deviceId: "mac-studio" }),
|
||||||
|
projectIds: ["project-a", "project-b"],
|
||||||
|
deviceId: "mac-studio",
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
shouldRefreshRealtimeEvent({
|
||||||
|
eventType: "conversation.updated",
|
||||||
|
eventData: JSON.stringify({ deviceId: "windows-box" }),
|
||||||
|
projectIds: ["project-a", "project-b"],
|
||||||
|
deviceId: "mac-studio",
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("planThrottledRefresh coalesces bursts into one delayed refresh", () => {
|
test("planThrottledRefresh coalesces bursts into one delayed refresh", () => {
|
||||||
const state = createRefreshThrottleState();
|
const state = createRefreshThrottleState();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user