diff --git a/apps/presentation/dashboard/package.json b/apps/presentation/dashboard/package.json index f5b0da5692..241b6c2819 100644 --- a/apps/presentation/dashboard/package.json +++ b/apps/presentation/dashboard/package.json @@ -38,10 +38,10 @@ "smoke:home-route": "rm -rf /tmp/loopx-home-route-smoke && tsc --ignoreConfig --target ES2022 --module CommonJS --moduleResolution Node --ignoreDeprecations 6.0 --skipLibCheck --strict --outDir /tmp/loopx-home-route-smoke smoke/home-route-smoke.ts && node /tmp/loopx-home-route-smoke/home-route-smoke.js", "smoke:delegation-preflight": "rm -rf node_modules/.cache/loopx-delegation-preflight-smoke && tsc --ignoreConfig --target ES2022 --module NodeNext --moduleResolution NodeNext --jsx react-jsx --skipLibCheck --strict --rootDir . --outDir node_modules/.cache/loopx-delegation-preflight-smoke smoke/delegation-preflight-smoke.tsx src/data/delegation-preflight.ts src/features/personal-workspace/delegation-preflight-status.tsx && node node_modules/.cache/loopx-delegation-preflight-smoke/smoke/delegation-preflight-smoke.js", "smoke:delegation-preflight-browser": "node --experimental-strip-types smoke/delegation-preflight-browser-smoke.mjs", - "smoke:personal-workspace": "npm run smoke:conversation-history-order && npm run smoke:goal-order && npm run smoke:goal-activity && npm run smoke:proposal-recency && node src/features/personal-workspace/conversation-order.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/steering-recovery.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/since-you-looked.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/goal-suggestions.test.mjs && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/font-token.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser-smoke.mjs", + "smoke:personal-workspace": "npm run smoke:conversation-history-order && npm run smoke:goal-order && npm run smoke:goal-activity && npm run smoke:proposal-recency && node src/features/personal-workspace/conversation-order.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/steering-recovery.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/attention-notifications.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/since-you-looked.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/goal-suggestions.test.mjs && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/font-token.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser-smoke.mjs", "smoke:workspace-locale": "LOOPX_PERSONAL_WORKSPACE_SCENARIO=workspace-locale node ../../../examples/personal-workspace-browser-smoke.mjs", - "smoke:personal-workspace-packaged": "npm run smoke:conversation-history-order && npm run smoke:goal-order && npm run smoke:goal-activity && npm run smoke:proposal-recency && node src/features/personal-workspace/conversation-order.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/steering-recovery.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/since-you-looked.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/goal-suggestions.test.mjs && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/font-token.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser/server-startup.test.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node ../../../examples/personal-workspace-browser-smoke.mjs && LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/team-evidence-return-smoke.mjs && LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/team-feedback-recovery-smoke.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/goal-workspace-craft-browser-smoke.mjs", - "smoke:personal-workspace-packaged:catalog": "npm run smoke:conversation-history-order && npm run smoke:goal-order && npm run smoke:goal-activity && npm run smoke:proposal-recency && node src/features/personal-workspace/conversation-order.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/steering-recovery.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/since-you-looked.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/goal-suggestions.test.mjs && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/font-token.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser/server-startup.test.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node ../../../examples/personal-workspace-browser-smoke.mjs", + "smoke:personal-workspace-packaged": "npm run smoke:conversation-history-order && npm run smoke:goal-order && npm run smoke:goal-activity && npm run smoke:proposal-recency && node src/features/personal-workspace/conversation-order.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/steering-recovery.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/attention-notifications.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/since-you-looked.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/goal-suggestions.test.mjs && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/font-token.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser/server-startup.test.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node ../../../examples/personal-workspace-browser-smoke.mjs && LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/team-evidence-return-smoke.mjs && LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/team-feedback-recovery-smoke.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/goal-workspace-craft-browser-smoke.mjs", + "smoke:personal-workspace-packaged:catalog": "npm run smoke:conversation-history-order && npm run smoke:goal-order && npm run smoke:goal-activity && npm run smoke:proposal-recency && node src/features/personal-workspace/conversation-order.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/steering-recovery.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/attention-notifications.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/since-you-looked.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/goal-suggestions.test.mjs && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/font-token.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser/server-startup.test.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node ../../../examples/personal-workspace-browser-smoke.mjs", "smoke:personal-workspace-packaged:team": "LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/team-evidence-return-smoke.mjs && LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/team-feedback-recovery-smoke.mjs", "smoke:personal-workspace-packaged:craft": "LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/goal-workspace-craft-browser-smoke.mjs", "smoke:todo-resume-condition": "rm -rf /tmp/loopx-todo-resume-condition-smoke && tsc --ignoreConfig --target ES2022 --module commonjs --moduleResolution node --ignoreDeprecations 6.0 --skipLibCheck --strict --outDir /tmp/loopx-todo-resume-condition-smoke smoke/todo-resume-condition-smoke.ts src/features/personal-workspace/todo-resume-condition.ts && node /tmp/loopx-todo-resume-condition-smoke/smoke/todo-resume-condition-smoke.js", diff --git a/apps/presentation/dashboard/src/features/personal-workspace/attention-notifications.test.mjs b/apps/presentation/dashboard/src/features/personal-workspace/attention-notifications.test.mjs new file mode 100644 index 0000000000..29635462a7 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/attention-notifications.test.mjs @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + attentionNotificationContents, + notifiableAttention, + attentionNotificationKey, + attentionNotificationSupport, + attentionPollIntervalMs, + newAttentionItems, + shouldNotifyAttention, +} from "./attention-notifications.ts"; + +const item = (goalId, todoId, text = "Approve the cut-over window", goalTitle = "Billing migration") => ({ goalId, todoId, text, goalTitle, blocking: true }); + +test("a needs-you item is identified by Goal and Todo, never by text", () => { + assert.equal(attentionNotificationKey(item("g1", "t1", "a")), attentionNotificationKey(item("g1", "t1", "b"))); + assert.notEqual(attentionNotificationKey(item("g1", "t1")), attentionNotificationKey(item("g2", "t1"))); + assert.notEqual(attentionNotificationKey(item("g1", "t1")), attentionNotificationKey(item("g1", "t2"))); +}); + +test("only items absent from the seen set are new, in projection order", () => { + const seen = new Set([attentionNotificationKey(item("g1", "t1"))]); + const fresh = newAttentionItems(seen, [item("g2", "t9"), item("g1", "t1"), item("g1", "t2")]); + assert.deepEqual(fresh.map((row) => row.todoId), ["t9", "t2"]); + assert.deepEqual(newAttentionItems(seen, []), [], "A shrinking queue never notifies"); +}); + +test("notify only when enabled, granted and the owner is not looking", () => { + const base = { enabled: true, support: "granted", visible: false, focused: false }; + assert.equal(shouldNotifyAttention(base), true); + assert.equal(shouldNotifyAttention({ ...base, visible: true, focused: false }), true, "an unfocused visible window still needs a nudge"); + assert.equal(shouldNotifyAttention({ ...base, visible: true, focused: true }), false, "a focused page already shows the lane"); + assert.equal(shouldNotifyAttention({ ...base, enabled: false }), false); + for (const support of ["default", "denied", "unsupported"]) { + assert.equal(shouldNotifyAttention({ ...base, support }), false, `${support} never notifies`); + } +}); + +test("support reads the browser answer and tolerates a missing API", () => { + assert.equal(attentionNotificationSupport({}), "unsupported"); + assert.equal(attentionNotificationSupport({ Notification: { permission: "granted" } }), "granted"); + assert.equal(attentionNotificationSupport({ Notification: { permission: "denied" } }), "denied"); + assert.equal(attentionNotificationSupport({ Notification: { permission: "default" } }), "default"); +}); + +test("hidden pages re-read less often than visible ones", () => { + assert.ok(attentionPollIntervalMs(true) > attentionPollIntervalMs(false)); + assert.ok(attentionPollIntervalMs(false) >= 15_000, "never hammer the local service"); +}); + +test("content is owner language, bounded, and summarizes a burst", () => { + const copy = { fallbackTitle: "LoopX", more: (count) => `${count} more decisions are waiting` }; + const items = [item("g1", "t1"), item("g1", "t2", "x".repeat(200)), item("g2", "t3", "Pick a source", ""), item("g2", "t4"), item("g2", "t5")]; + const contents = attentionNotificationContents(items, copy); + assert.equal(contents.length, 4, "three items plus one summary"); + assert.equal(contents[0].title, "Billing migration"); + assert.equal(contents[1].body.length, 160, "long text is truncated with an ellipsis"); + assert.equal(contents[2].title, "LoopX", "a missing Goal title falls back to the product name"); + assert.deepEqual(contents[3], { body: "2 more decisions are waiting", tag: "loopx-attention-more", title: "LoopX" }); + assert.equal(new Set(contents.map((row) => row.tag)).size, 4, "tags dedupe per item"); + assert.equal(attentionNotificationContents([], copy).length, 0); +}); + +test("only items that wait on the owner may notify; optional actions never do", () => { + const gate = { ...item("g1", "gate"), blocking: true }; + const optional = { ...item("g1", "optional", "Consider renaming the export folder"), blocking: false }; + assert.deepEqual(notifiableAttention([optional, gate, optional]).map((row) => row.todoId), ["gate"]); + assert.deepEqual(notifiableAttention([optional]), []); + assert.deepEqual(newAttentionItems(new Set(), notifiableAttention([optional])), [], "an optional item is not a new decision"); +}); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/attention-notifications.ts b/apps/presentation/dashboard/src/features/personal-workspace/attention-notifications.ts new file mode 100644 index 0000000000..5eadc1c961 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/attention-notifications.ts @@ -0,0 +1,95 @@ +import type { WorkspaceAttention } from "./personal-workspace-model"; + +/** + * Browser notifications for items that need the owner. + * + * The preference is a per-browser convenience, like the theme: it lives in + * local storage, defaults to off and never writes to the service. While it is + * on, the page re-reads the workspace in the background so a decision that + * appears while the owner is elsewhere reaches them; nothing is sent for + * progress, runs or counts, only for a new item that waits on the owner. + */ + +export const attentionNotificationStorageKey = "loopx-pw-attention-notifications"; + +export type AttentionNotificationSupport = "unsupported" | "default" | "granted" | "denied"; + +export function readAttentionNotificationPreference(): boolean { + try { + return window.localStorage.getItem(attentionNotificationStorageKey) === "on"; + } catch { + return false; + } +} + +export function writeAttentionNotificationPreference(enabled: boolean): void { + try { + if (enabled) window.localStorage.setItem(attentionNotificationStorageKey, "on"); + else window.localStorage.removeItem(attentionNotificationStorageKey); + } catch { + // Storage may be unavailable; the in-memory state still applies to this page. + } +} + +/** The browser's current answer; "unsupported" when no Notification API exists. */ +export function attentionNotificationSupport(scope: { Notification?: { permission: string } } = globalThis as never): AttentionNotificationSupport { + const api = scope.Notification; + if (!api || typeof api.permission !== "string") return "unsupported"; + return api.permission === "granted" ? "granted" : api.permission === "denied" ? "denied" : "default"; +} + +/** + * What may notify: the items the attention owner classifies as waiting on + * the owner. An optional user action is listed in the workspace but is not a + * decision, so it never interrupts; seed, diff and content all read this set. + */ +export function notifiableAttention>(items: readonly T[]): T[] { + return items.filter((item) => item.blocking); +} + +/** Identity of a needs-you item across reads: one Goal, one Todo or gate. */ +export function attentionNotificationKey(item: Pick): string { + return `${item.goalId}\u0000${item.todoId}`; +} + +/** Items not present in the previously seen set, in projection order. */ +export function newAttentionItems>(seen: ReadonlySet, items: readonly T[]): T[] { + return items.filter((item) => !seen.has(attentionNotificationKey(item))); +} + +/** + * Notify only when the owner is not looking at this page: enabled, permitted, + * and the document is hidden or unfocused. A visible, focused page already + * shows the item in its needs-you lane. + */ +export function shouldNotifyAttention(input: { enabled: boolean; support: AttentionNotificationSupport; visible: boolean; focused: boolean }): boolean { + return input.enabled && input.support === "granted" && (!input.visible || !input.focused); +} + +/** Background re-read cadence while notifications are on; slower when hidden. */ +export function attentionPollIntervalMs(hidden: boolean): number { + return hidden ? 60_000 : 30_000; +} + +export const attentionNotificationBatchLimit = 3; + +export type AttentionNotificationContent = { body: string; tag: string; title: string }; + +/** + * Owner-language content: the Goal title names where the decision lives and + * the body is the item text. Beyond the batch limit one summary stands in for + * the rest, so a burst never floods the owner. + */ +export function attentionNotificationContents( + items: readonly WorkspaceAttention[], + copy: { fallbackTitle: string; more: (count: number) => string }, +): AttentionNotificationContent[] { + const shown = items.slice(0, attentionNotificationBatchLimit).map((item) => ({ + body: item.text.length > 160 ? `${item.text.slice(0, 159)}…` : item.text, + tag: attentionNotificationKey(item), + title: item.goalTitle?.trim() || copy.fallbackTitle, + })); + const rest = items.length - shown.length; + if (rest > 0) shown.push({ body: copy.more(rest), tag: "loopx-attention-more", title: copy.fallbackTitle }); + return shown; +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index c47489e0f9..6a305b4e72 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -1307,6 +1307,14 @@ const en = { "cadence.retry": "Refresh policy", "settings.workspaceDisplay": "Workspace display", "settings.workspaceTheme": "Workspace theme", + "settings.attentionNotifications": "Desktop notifications", + "settings.attentionNotificationsToggle": "Notify me when a decision needs me", + "settings.attentionNotificationsHint": "Only while this page stays open in this browser. Progress and runs never notify; only a new item that waits on you does. While on, the page re-reads the workspace every 30 seconds.", + "settings.attentionNotificationsGranted": "Allowed by this browser.", + "settings.attentionNotificationsDenied": "Blocked by this browser. Allow notifications for this site in the browser's site settings, then turn this on again.", + "settings.attentionNotificationsDefault": "The browser asks for permission when you turn this on.", + "settings.attentionNotificationsUnsupported": "This environment has no notification support.", + "notifications.attentionMore": "{count} more decisions are waiting for you", "session.closeRecord": "Exit run record", "session.details": "Session details", "session.record": "Execution Session · Run record", @@ -2830,6 +2838,14 @@ const zhCN: Record = { "cadence.retry": "刷新策略", "settings.workspaceDisplay": "工作区显示", "settings.workspaceTheme": "工作区主题", + "settings.attentionNotifications": "桌面通知", + "settings.attentionNotificationsToggle": "有事项需要我决定时提醒我", + "settings.attentionNotificationsHint": "仅在本页面在此浏览器中保持打开时生效。进展和运行不会提醒,只提醒新出现的、等你决定的事项。开启后页面每 30 秒重新读取一次工作区。", + "settings.attentionNotificationsGranted": "此浏览器已允许。", + "settings.attentionNotificationsDenied": "此浏览器已拒绝。请在浏览器的站点设置中允许本站通知,然后重新开启。", + "settings.attentionNotificationsDefault": "开启时浏览器会请求通知权限。", + "settings.attentionNotificationsUnsupported": "当前环境不支持通知。", + "notifications.attentionMore": "另有 {count} 项决定在等你", "session.closeRecord": "退出运行记录", "session.details": "Session 详情", "session.record": "执行 Session · 运行记录", diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs index 05c8e70057..3fc1a60c18 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs @@ -600,6 +600,26 @@ for (const key of ["home.firstGoal.title", "home.firstGoal.lead", "home.firstGoa assert.equal((i18n.match(new RegExp(`"${key.replace(/\./g, "\\.")}":`, "g")) ?? []).length, 2, `${key} exists in both locales`); } +// Needs-you desktop notifications: a per-browser opt-in. Off by default, the +// page neither asks for permission nor re-reads the workspace in the background. +const attentionNotifications = source("./attention-notifications.ts"); +assert.match(attentionNotifications, /export function shouldNotifyAttention/, "Notification admission is one typed rule"); +assert.match(page, /const attentionNotificationsActive = attentionNotificationsEnabled && attentionNotificationSupportState === "granted" && !readOnly;/, "Polling and notifying share one activation predicate that includes read-only"); +assert.match(page, /if \(!attentionNotificationsActive \|\| !backgroundRefreshRef\.current\) return;/, "No background re-read unless notifications are active"); +assert.match(page, /Notification\.requestPermission\(\)/, "Permission is requested from the owner's toggle, not on load"); +assert.doesNotMatch(page.replace(/async function updateAttentionNotifications[\s\S]*?\n }\n/, ""), /requestPermission/, "Only the toggle handler asks for permission"); +assert.match(page, /seenAttentionKeysRef\.current = new Set\(model\.userTodos\.map\(attentionNotificationKey\)\)/, "Enabling seeds every listed item so nothing already listed is replayed"); +assert.match(page, /const fresh = notifiableAttention\(newAttentionItems\(seen, items\)\);/, "Only an item first seen while it waits on the owner notifies; optional actions never do"); +assert.match(page, /const remembered = seen \? new Set\(seen\) : new Set\(\);\s*for \(const item of items\) remembered\.add\(attentionNotificationKey\(item\)\);/, "Seen identities are kept for the activation lifetime; a partial read never forgets one"); +assert.doesNotMatch(page.slice(page.indexOf("const remembered = seen"), page.indexOf("const fresh = notifiableAttention")), /seenAttentionKeysRef\.current = keys/, "The seen set is never replaced by the current read"); +assert.match(attentionNotifications, /export function notifiableAttention[\s\S]*?items\.filter\(\(item\) => item\.blocking\)/, "Notifiable means the attention owner's blocking classification, never the whole list"); +assert.match(page, /window\.addEventListener\("focus", readBack\);[\s\S]*?document\.addEventListener\("visibilitychange", readBack\);[\s\S]*?navigator\.permissions\?\.query\(\{ name: "notifications" \}\)/, "The browser's permission answer is read back on return, on settings and on change"); +assert.doesNotMatch(page.slice(page.indexOf("const readBack = () =>"), page.indexOf("const attentionNotificationsActive")), /requestPermission|setAttentionNotificationsEnabled\(true\)/, "Reading the answer back never asks for permission or enables on its own"); +assert.match(dashboard, /onBackgroundRefresh=\{\(\) => loadFromUrl\([^\n]*\{ background: true, readScope: "all"/, "The background re-read keeps snapshots on screen and re-reads every Goal"); +for (const key of ["settings.attentionNotifications", "settings.attentionNotificationsToggle", "settings.attentionNotificationsDenied", "settings.attentionNotificationsUnsupported", "notifications.attentionMore"]) { + assert.equal((i18n.match(new RegExp(`"${key.replace(/\./g, "\\.")}":`, "g")) ?? []).length, 2, `${key} exists in both locales`); +} + // Since you looked (RFC goal-workspace-surface §5.7): a browser-local marker // per source and Goal, typed counts and ids only, no cost, no service write; // the line appears only for a material delta. Mark seen acknowledges what is diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts index 8569de6346..358774771f 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts @@ -569,6 +569,8 @@ export type PersonalWorkspaceCallbacks = { onReconcileStatus?: (options?: { invalidateGoalIds?: string[] }) => void | Promise; /** Full refresh by default; error recovery can explicitly read missing Goals only. */ onRefresh?: (scope?: WorkspaceReadScope) => void | WorkspaceRefreshResult | Promise; + /** Re-read the workspace without a loading state; used by background freshness such as notifications. */ + onBackgroundRefresh?: () => void | Promise; onRetryGoalArchive?: () => void | Promise; onPreviewAction?: (request: WorkspaceActionPreviewRequest) => WorkspaceActionPreview | Promise; onRequestGoalCreate?: () => WorkspaceActionPreview | Promise | void; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx index fe661ef37b..21abec98d6 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx @@ -89,6 +89,17 @@ import { ManagerBrief } from "./manager-brief"; const WorkspaceSettingsPage = lazy(() => import("./workspace-settings-page").then((module) => ({ default: module.WorkspaceSettingsPage }))); import { UsageStatisticsNotice } from "./usage-statistics-notice"; import { readWorkspaceTheme, writeWorkspaceTheme, type WorkspaceTheme } from "./workspace-theme"; +import { + attentionNotificationContents, + attentionNotificationKey, + attentionNotificationSupport, notifiableAttention, + attentionPollIntervalMs, + newAttentionItems, + readAttentionNotificationPreference, + shouldNotifyAttention, + writeAttentionNotificationPreference, + type AttentionNotificationSupport, +} from "./attention-notifications"; import { compareProposalRecency } from "./proposal-recency"; import { buildSeenMarker, compareSeenMarker, readSeenMarker, seenMarkerStorage, seenMarkerStorageKey, writeSeenMarker, type SeenMarkerInput, type SinceYouLookedDelta } from "./since-you-looked"; import { deriveGoalSuggestions, type GoalSuggestion } from "./goal-suggestions"; @@ -977,6 +988,10 @@ export function PersonalWorkspacePage({ const restoredProposalIdsRef = useRef(new Set()); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [theme, setTheme] = useState(readWorkspaceTheme); + const [attentionNotificationsEnabled, setAttentionNotificationsEnabled] = useState(readAttentionNotificationPreference); + const [attentionNotificationSupportState, setAttentionNotificationSupportState] = useState(() => attentionNotificationSupport()); + // Keys seen since notifications were enabled; null until the first read seeds it. + const seenAttentionKeysRef = useRef | null>(null); const [goalContexts, setGoalContexts] = useState>({}); const [larkConnections, setLarkConnections] = useState([]); const digestInitRef = useRef(false); @@ -2053,6 +2068,52 @@ export function PersonalWorkspacePage({ writeWorkspaceTheme(next); } + /** + * Turning notifications on asks the browser once, from this click, and + * only keeps the preference when the browser granted it. Items already + * waiting are seeded as seen so enabling never replays the current lane. + */ + async function updateAttentionNotifications(enabled: boolean) { + if (!enabled) { + setAttentionNotificationsEnabled(false); + writeAttentionNotificationPreference(false); + seenAttentionKeysRef.current = null; + return; + } + let support = attentionNotificationSupport(); + if (support === "default") { + try { + support = attentionNotificationSupport({ Notification: { permission: await Notification.requestPermission() } }); + } catch { + support = "denied"; + } + } + setAttentionNotificationSupportState(support); + if (support !== "granted") { + setAttentionNotificationsEnabled(false); + writeAttentionNotificationPreference(false); + return; + } + seenAttentionKeysRef.current = new Set(model.userTodos.map(attentionNotificationKey)); + setAttentionNotificationsEnabled(true); + writeAttentionNotificationPreference(true); + } + + // A notification opens its item in place. Selection is scoped to the + // current Goal, so an item in another Goal waits until that Goal is shown. + const [pendingNotificationItem, setPendingNotificationItem] = useState(null); + useEffect(() => { + if (!pendingNotificationItem || pendingNotificationItem.goalId !== selectedGoalId) return; + setPendingNotificationItem(null); + setSelection({ kind: "attention", item: pendingNotificationItem }); + }, [pendingNotificationItem, selectedGoalId]); + function openAttentionFromNotification(item: WorkspaceAttention) { + window.focus(); + if (item.goalId === selectedGoalId) { setSelection({ kind: "attention", item }); return; } + setPendingNotificationItem(item); + selectGoal(item.goalId, "overview"); + } + async function sendMessage(messageOverride?: string) { const pendingImages = messageOverride ? [] : imageAttachments; const message = (messageOverride ?? composer).trim() || (pendingImages.length ? t("composer.imageAnalysisPrompt") : ""); @@ -2231,10 +2292,92 @@ export function PersonalWorkspacePage({ onClose={closeSettings} onThemeChange={updateTheme} theme={theme} + attentionNotifications={readOnly ? undefined : { enabled: attentionNotificationsEnabled, support: attentionNotificationSupportState }} + onAttentionNotificationsChange={readOnly ? undefined : updateAttentionNotifications} /> ) : null; + // The browser's answer can change outside this page (site settings). Read + // it back whenever the owner returns or opens settings, and when the + // permission owner reports a change, so a grant can be turned on from here + // and a revocation shows as what it is. Nothing here asks for permission. + useEffect(() => { + const readBack = () => setAttentionNotificationSupportState((current) => { + const support = attentionNotificationSupport(); + return support === current ? current : support; + }); + readBack(); + window.addEventListener("focus", readBack); + document.addEventListener("visibilitychange", readBack); + let status: PermissionStatus | null = null; + try { + void navigator.permissions?.query({ name: "notifications" }).then((result) => { status = result; result.onchange = readBack; }).catch(() => {}); + } catch { + // No permission owner to subscribe to; focus and settings still read back. + } + return () => { + window.removeEventListener("focus", readBack); + document.removeEventListener("visibilitychange", readBack); + if (status) status.onchange = null; + }; + }, [settingsOpen]); + const attentionNotificationsActive = attentionNotificationsEnabled && attentionNotificationSupportState === "granted" && !readOnly; + const backgroundRefreshRef = useRef(callbacks.onBackgroundRefresh); + backgroundRefreshRef.current = callbacks.onBackgroundRefresh; + const openAttentionRef = useRef(openAttentionFromNotification); + openAttentionRef.current = openAttentionFromNotification; + // Background freshness: while notifications are on, re-read the workspace + // on a slow cadence so a decision raised elsewhere reaches this page. + useEffect(() => { + if (!attentionNotificationsActive || !backgroundRefreshRef.current) return; + let cancelled = false; + let timer = 0; + const schedule = () => { + timer = window.setTimeout(async () => { + try { await backgroundRefreshRef.current?.(); } catch { /* the next tick retries */ } + if (!cancelled) schedule(); + }, attentionPollIntervalMs(document.hidden)); + }; + const onVisibility = () => { if (!document.hidden && !cancelled) { window.clearTimeout(timer); schedule(); } }; + schedule(); + document.addEventListener("visibilitychange", onVisibility); + return () => { cancelled = true; window.clearTimeout(timer); document.removeEventListener("visibilitychange", onVisibility); }; + }, [attentionNotificationsActive]); + // The effect keys on the item identities, so a text or ordering change alone + // never re-notifies; the items are read through a ref for the same reason. + const attentionItemsRef = useRef(model.userTodos); + attentionItemsRef.current = model.userTodos; + const attentionKeySignature = model.userTodos.map(attentionNotificationKey).join("\n"); + useEffect(() => { + if (!attentionNotificationsActive) { seenAttentionKeysRef.current = null; return; } + const items = attentionItemsRef.current; + const seen = seenAttentionKeysRef.current; + // Identities seen while on are kept for the whole activation: a partial + // read that omits an item must not turn its return into a new decision. + const remembered = seen ? new Set(seen) : new Set(); + for (const item of items) remembered.add(attentionNotificationKey(item)); + seenAttentionKeysRef.current = remembered; + if (!seen) return; + // Every listed item is remembered, so an optional action that later turns + // blocking is not a new item; only an item first seen while it waits on + // the owner interrupts. + const fresh = notifiableAttention(newAttentionItems(seen, items)); + if (!fresh.length || !shouldNotifyAttention({ + enabled: true, support: attentionNotificationSupportState, + visible: document.visibilityState === "visible", focused: document.hasFocus(), + })) return; + for (const content of attentionNotificationContents(fresh, { fallbackTitle: "LoopX", more: (count) => t("notifications.attentionMore", { count }) })) { + try { + const notification = new Notification(content.title, { body: content.body, tag: content.tag }); + const target = fresh.find((item) => attentionNotificationKey(item) === content.tag); + notification.onclick = () => { notification.close(); openAttentionRef.current(target ?? fresh[0]); }; + } catch { + // A constructor the environment refuses is not a page error. + } + } + }, [attentionKeySignature, attentionNotificationsActive, attentionNotificationSupportState, t]); + const composerSuggestions = !selectedWorkspaceRef && (!conversationOpen || !conversationMessages.length) ?
{ if (event.key !== "Escape") return; event.currentTarget.open = false; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css index c0725acbf4..de2222b052 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css @@ -604,6 +604,8 @@ button.personal-execution-chip:focus-visible { outline: 2px solid #0070f3; outli .personal-settings-body > .personal-settings-header { min-width: 0; } .personal-settings-page *, .personal-settings-page *::before, .personal-settings-page *::after { box-sizing: border-box; } .personal-appearance-settings { max-width: 680px; } +.personal-attention-notifications { display: grid; gap: 10px; margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--pw-line); } +.personal-attention-notifications h3 { margin: 0; font-size: var(--text-body); line-height: var(--leading-body); font-weight: 600; } .personal-settings-choice-group { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-top: 14px; } .personal-settings-choice-group button { display: grid; grid-template-columns: 34px minmax(0, 1fr); align-items: center; gap: 10px; min-height: 60px; padding: 13px; border: 1px solid var(--pw-line); border-radius: 12px; background: #fff; color: inherit; cursor: pointer; text-align: left; } .personal-settings-choice-group button:hover { border-color: var(--pw-line-strong); box-shadow: 0 2px 8px rgb(30 28 20 / 6%); } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/workspace-settings-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/workspace-settings-page.tsx index 7886491a2a..4d3850175b 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/workspace-settings-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/workspace-settings-page.tsx @@ -17,6 +17,14 @@ import { ConfigurationBackupSettings } from "./configuration-backup-settings"; import { OperatorCredentialSettings } from "./operator-credential-settings"; import type { PersonalWorkspaceCallbacks, WorkspaceGoal, WorkspaceGoalNotification } from "./personal-workspace-model"; import type { WorkspaceTheme } from "./workspace-theme"; +import type { AttentionNotificationSupport } from "./attention-notifications"; + +const attentionNotificationSupportCopy = { + default: "settings.attentionNotificationsDefault", + denied: "settings.attentionNotificationsDenied", + granted: "settings.attentionNotificationsGranted", + unsupported: "settings.attentionNotificationsUnsupported", +} as const; type WorkspaceSettingsTab = "ownership" | "steward" | "provider" | "machine" | "capabilities" | "cadence" | "lark" | "appearance" | "language"; @@ -44,6 +52,8 @@ export function WorkspaceSettingsPage({ onClose, onThemeChange, theme, + attentionNotifications, + onAttentionNotificationsChange, }: { callbacks: PersonalWorkspaceCallbacks; focusGoalConnection?: boolean; @@ -55,6 +65,8 @@ export function WorkspaceSettingsPage({ onClose: () => void; onThemeChange: (theme: WorkspaceTheme) => void; theme: WorkspaceTheme; + attentionNotifications?: { enabled: boolean; support: AttentionNotificationSupport }; + onAttentionNotificationsChange?: (enabled: boolean) => void | Promise; }) { const { locale, setLocale, t } = useWorkspaceI18n(); // Preserve existing Goal-settings links while presenting one capability entry. @@ -256,6 +268,24 @@ export function WorkspaceSettingsPage({ {t("settings.themeHighContrast")} + {attentionNotifications && onAttentionNotificationsChange ? ( +
+

{t("settings.attentionNotifications")}

+ +

{t("settings.attentionNotificationsHint")}

+

+ {t(attentionNotificationSupportCopy[attentionNotifications.support])} +

+
+ ) : null} ) : null} diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx index b81a558a3b..bd9f064f12 100644 --- a/apps/presentation/dashboard/src/views/dashboard-page.tsx +++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx @@ -944,17 +944,16 @@ function buildPersonalHomeModel( const listedOpen = group?.items.filter((todo) => !todo.done).length ?? 0; const open = group?.open_count; const count = typeof open === "number" && Number.isSafeInteger(open) && open >= listedOpen ? open : null; - attentionSummaries.set(item.goal_id, { - blocking: count === null ? null : blocking ? count : 0, - optional: count === null ? null : blocking ? 0 : count, - coverage: count === null ? "unknown" : count > listedOpen ? "partial" : "complete", - }); - return (group?.items ?? []) - .map((todo, todoOrder): PersonalNeedsYouItem & { sourceOrder: number; todoOrder: number; projectedDone: boolean } => ({ + const projected = (group?.items ?? []) + .map((todo, todoOrder): PersonalNeedsYouItem & { sourceOrder: number; todoOrder: number; projectedDone: boolean } => { + const details = attentionDetailsFromSnapshot(todo, item.user_todos?.items ?? [], item.goal_id); + return { projectedDone: todo.done, - details: attentionDetailsFromSnapshot(todo, item.user_todos?.items ?? [], item.goal_id), + details, actionKind: todo.action_kind ?? null, - blocking, + // Explicit task classes own each row; the queue answer only covers + // legacy rows whose interaction was not supplied by the Todo owner. + blocking: details.interaction === "decision" || (details.interaction === "unknown" && blocking), goalId: item.goal_id, sourceOrder, taskClass: todo.task_class ?? null, @@ -962,12 +961,23 @@ function buildPersonalHomeModel( todoId: todo.todo_id?.trim() || `${item.goal_id}:user:${todo.index}`, todoOrder, updatedAt: todo.updated_at ?? null, - })); + }; }); + // The owner counts all open User Todos, not just gates. Queue-level + // waiting cannot classify unlisted rows in a mixed gate/action group. + // Complete classified rows qualify totals; a bounded read keeps unknown. + const blockingListed = projected.filter((todo) => !todo.projectedDone && todo.blocking).length; + const complete = count !== null && count === listedOpen; + attentionSummaries.set(item.goal_id, { + blocking: complete ? blockingListed : null, + optional: complete ? count - blockingListed : null, + coverage: count === null ? "unknown" : complete ? "complete" : "partial", + }); + return projected; }); const projectedUserTodos = attentionHistory.filter((todo) => !todo.projectedDone); const projectedGoalIds = new Set(projectedUserTodos.map((todo) => todo.goalId)); for (const [goalId, summary] of attentionSummaries) { - if ((summary.blocking ?? 0) + (summary.optional ?? 0) > 0) projectedGoalIds.add(goalId); + if (summary.coverage === "partial" || (summary.blocking ?? 0) + (summary.optional ?? 0) > 0) projectedGoalIds.add(goalId); } const pendingOperatorGates = rows.flatMap((row, rowOrder) => { if (stoppedGoalIds.has(row.goal.id) || projectedGoalIds.has(row.goal.id) || !personalGoalHasPendingOperatorGate(row)) return []; @@ -1208,6 +1218,7 @@ function PersonalGoalHome({ onSelectWorkspace, onReconcileStatus, onRefresh, + onBackgroundRefresh, onRetryGoalArchive, payload, progress, @@ -1233,6 +1244,7 @@ function PersonalGoalHome({ selectedWorkspaceRef: string | null; onReconcileStatus: (options?: { invalidateGoalIds?: string[] }) => void | Promise; onRefresh: (scope?: WorkspaceReadScope) => Promise; + onBackgroundRefresh?: () => void | Promise; onRetryGoalArchive: () => void | Promise; payload: StatusPayload; progress: WorkspaceProgress | null; @@ -2988,6 +3000,7 @@ function PersonalGoalHome({ setCapabilityRevision((revision) => revision + 1); return result; }, + onBackgroundRefresh, onRetryResumeRun: retryManagerSession, onSelectAgent: chooseAgent, onSelectAgentModel: chooseAgentModel, @@ -3553,6 +3566,7 @@ export function DashboardPage() { return { ok: false, error: formatStatusError(error) } as const; } }} + onBackgroundRefresh={() => loadFromUrl(source.kind === "url" ? source.label : (statusUrl || defaultGlobalStatusUrl), { background: true, readScope: "all", rejectOnError: true })} payload={payload} progress={progress} rows={goalRows} diff --git a/docs/assets/personal-workspace/attention-notifications-settings.png b/docs/assets/personal-workspace/attention-notifications-settings.png new file mode 100644 index 0000000000..6db614d943 Binary files /dev/null and b/docs/assets/personal-workspace/attention-notifications-settings.png differ diff --git a/docs/guides/personal-workspace-user-guide.md b/docs/guides/personal-workspace-user-guide.md index d40dd1f3be..b0bba7fe55 100644 --- a/docs/guides/personal-workspace-user-guide.md +++ b/docs/guides/personal-workspace-user-guide.md @@ -558,6 +558,29 @@ Goal 的 Topic 路由,不删除 Goal、Agent Session、历史 Todo 或其他 G --- +### 7.1 桌面通知 / Desktop notifications + +![设置 → 外观 → 桌面通知](../assets/personal-workspace/attention-notifications-settings.png) + +「设置 → 外观 → 桌面通知」是每个浏览器各自的开关,默认关闭。开启时浏览器会询问一次通知权限, +只有允许后开关才会保持开启;页面会回读浏览器的答复(已允许、已拒绝、不支持)。开启后,页面 +每 30 秒在后台重新读取一次工作区(标签页隐藏时每 60 秒),当出现一条**新的、等你决定**的事项且你 +没有正看着这个页面时,发出一条桌面通知;点击通知会回到该 Goal 并打开这条事项。进展、运行和数量 +变化不会通知;开启当下已经在等你的事项也不会重复提醒;可选提醒(不阻塞 Agent 的用户操作)会列在 +工作区里,但不会打扰你。关闭开关后立即停止后台读取。如果浏览器曾拒绝通知,之后在站点设置里允许, +回到本页面或打开设置时会重新读到允许,开关即可再次开启;反过来在站点设置里撤销,页面也会如实显示 +并停止后台读取。这只在本页面保持打开时生效,不是服务端推送,也不替代飞书等渠道通知。 + +"Settings → Appearance → Desktop notifications" is a per-browser switch, off by default. Turning it +on asks the browser for permission once and keeps the switch on only when granted; the page reads +the browser's answer back (allowed, blocked, unsupported). While on, the page re-reads the workspace +every 30 seconds in the background (60 seconds when the tab is hidden) and sends one desktop +notification for each **new item that waits on you** while you are not looking at this page. +Clicking the notification returns to that Goal and opens the item. Progress, runs and counts never +notify, and items already waiting when you turn it on are not replayed. Turning it off stops the +background reads. This works only while this page stays open; it is not a server push and does not +replace channel notifications such as Lark. An optional reminder (a user action that does not block the Agent) is listed in the workspace but never interrupts you. If this browser once blocked notifications and you allow them in its site settings later, returning to this page or opening Settings reads the answer back and the switch can be turned on again; a permission revoked in the site settings shows as blocked and stops the background re-read. + ## 🛡️ 8. 安全与不可逆操作保护 LoopX 控制台严格遵循 **Human-in-the-Loop(人类介入)安全模型**: diff --git a/examples/personal-workspace-browser-smoke.mjs b/examples/personal-workspace-browser-smoke.mjs index cf9400de8a..85701b5248 100644 --- a/examples/personal-workspace-browser-smoke.mjs +++ b/examples/personal-workspace-browser-smoke.mjs @@ -8,6 +8,8 @@ import {goalWorkspaceSelectionScenario} from "./personal-workspace-browser/goal- import {sinceYouLookedScenario} from "./personal-workspace-browser/since-you-looked.mjs"; import {sinceYouLookedLiveScenario} from "./personal-workspace-browser/since-you-looked-live.mjs"; import {firstGoalHomeScenario} from "./personal-workspace-browser/first-goal-home.mjs"; +import {attentionNotificationsScenario} from "./personal-workspace-browser/attention-notifications.mjs"; +import {attentionNotificationsLiveScenario} from "./personal-workspace-browser/attention-notifications-live.mjs"; import {firstGoalHomeLiveScenario} from "./personal-workspace-browser/first-goal-home-live.mjs"; import {goalSuggestionsScenario} from "./personal-workspace-browser/goal-suggestions.mjs"; import {goalSuggestionsLiveScenario} from "./personal-workspace-browser/goal-suggestions-live.mjs"; @@ -118,6 +120,8 @@ scenarioCatalog.push(goalWorkspaceSelectionScenario); scenarioCatalog.push(sinceYouLookedScenario); scenarioCatalog.push(sinceYouLookedLiveScenario); scenarioCatalog.push(firstGoalHomeScenario); +scenarioCatalog.push(attentionNotificationsScenario); +scenarioCatalog.push(attentionNotificationsLiveScenario); scenarioCatalog.push(firstGoalHomeLiveScenario); scenarioCatalog.push(goalSuggestionsScenario); scenarioCatalog.push(goalSuggestionsLiveScenario); diff --git a/examples/personal-workspace-browser/attention-notifications-live.mjs b/examples/personal-workspace-browser/attention-notifications-live.mjs new file mode 100644 index 0000000000..7ca0f6ee2d --- /dev/null +++ b/examples/personal-workspace-browser/attention-notifications-live.mjs @@ -0,0 +1,101 @@ +import assert from "node:assert/strict"; +import { execFile, spawn } from "node:child_process"; +import { once } from "node:events"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { createInterface } from "node:readline"; +import { promisify } from "node:util"; +import { resolveTestPython } from "../../scripts/test-python.mjs"; +import { outputDir, repoRoot } from "./fixture.mjs"; +import { fetchWorkspaceBackend, openWorkspacePage } from "./scenario-context.mjs"; + +// End to end against the real service: a disposable real Chat/Todo owner +// serves status; a user gate is added through the CLI while the owner is +// away; the enabled page's background re-read hits the real status and +// notifies once with the gate's text; clicking opens that real item. + +const gateText = "Approve the restated FY2023 operating cash flow"; + +export const attentionNotificationsLiveScenario = { + id: "attention-notifications-live", + async run({ browser, collectCoverage, url }) { + const root = await mkdtemp(join(tmpdir(), "loopx-notify-live-")); + const python = resolveTestPython({ repoRoot }); + const child = spawn(python, ["-u", resolve(repoRoot, "examples/personal-workspace-browser/goal-workspace-todo-backend.py"), root], { cwd: repoRoot, env: { ...process.env, PYTHONPATH: repoRoot }, stdio: ["ignore", "pipe", "pipe"] }); + const closed = once(child, "close"); + const lines = createInterface({ input: child.stdout }); + let diagnostics = ""; + child.stderr.on("data", (chunk) => { diagnostics = (diagnostics + chunk).slice(-4000); }); + let session; + try { + const ready = await Promise.race([once(lines, "line").then(([line]) => JSON.parse(line)), closed.then(() => { throw new Error(`todo backend exited: ${diagnostics}`); })]); + const backend = `http://127.0.0.1:${ready.port}`; + session = await openWorkspacePage(browser, url, { collectCoverage, viewport: { width: 1440, height: 900 }, beforeGoto: async (_api, page) => { + await page.addInitScript(() => { + window.__loopxNotifications = []; + class FakeNotification { + static permission = "default"; + static async requestPermission() { FakeNotification.permission = "granted"; return "granted"; } + constructor(title, options) { this.title = title; this.body = options?.body ?? ""; this.tag = options?.tag ?? ""; this.closed = false; window.__loopxNotifications.push(this); } + close() { this.closed = true; } + } + window.Notification = FakeNotification; + document.hasFocus = () => false; + }); + // Only the status read is proxied: the document URL also ends in + // "status.json" (its statusUrl query), so a glob would swallow the page. + // The page aborts superseded status reads; an aborted proxy fetch is not a failure. + await page.route((target) => target.pathname === "/status.json", async (route) => { + try { const response = await fetchWorkspaceBackend(route, backend); await route.fulfill({ response }); } + catch { await route.abort().catch(() => {}); } + }); + } }); + const { page } = session; + await page.getByRole("button", { name: "设置", exact: true }).click(); + await page.locator(".personal-settings-tabs").getByRole("button", { name: "外观", exact: true }).click(); + const settings = page.getByTestId("attention-notification-settings"); + await settings.waitFor({ state: "visible" }); + await page.clock.install(); + await settings.getByRole("checkbox").click(); + await page.waitForFunction(() => document.querySelector('[data-testid="attention-notification-permission"]')?.textContent?.includes("已允许")); + await page.getByRole("button", { name: "返回工作区", exact: true }).click(); + assert.equal((await page.evaluate(() => window.__loopxNotifications.length)), 0); + + // An optional user action raised in the real Todo owner is listed but is + // not a decision: the background re-read sees it and nothing notifies. + const optional = JSON.parse((await promisify(execFile)(python, ["-c", "from loopx.entrypoint import main; main()", "--format", "json", "--registry", ready.registry, "todo", "add", "--goal-id", "progress-projection", "--role", "user", "--task-class", "user_action", "--text", "Consider renaming the export folder"], { cwd: repoRoot, env: { ...process.env, PYTHONPATH: repoRoot } })).stdout); + assert.equal(optional.ok, true); + await page.clock.runFor(31_000); + await page.waitForTimeout(1_500); + assert.equal((await page.evaluate(() => window.__loopxNotifications.length)), 0, "An optional user action does not interrupt the owner"); + + // A decision raised in the real Todo owner while the owner is away. + // A second optional item first seen in the SAME read as the gate must + // remain optional too; remembering an older optional identity alone is insufficient. + const simultaneous = JSON.parse((await promisify(execFile)(python, ["-c", "from loopx.entrypoint import main; main()", "--format", "json", "--registry", ready.registry, "todo", "add", "--goal-id", "progress-projection", "--role", "user", "--task-class", "user_action", "--text", "Consider grouping the export notes"], { cwd: repoRoot, env: { ...process.env, PYTHONPATH: repoRoot } })).stdout); + assert.equal(simultaneous.ok, true); + const { stdout } = await promisify(execFile)(python, ["-c", "from loopx.entrypoint import main; main()", "--format", "json", "--registry", ready.registry, "todo", "add", "--goal-id", "progress-projection", "--role", "user", "--task-class", "user_gate", "--text", gateText], { cwd: repoRoot, env: { ...process.env, PYTHONPATH: repoRoot } }); + const added = JSON.parse(stdout); + assert.equal(added.ok, true); + await page.clock.runFor(31_000); + await page.waitForFunction(() => window.__loopxNotifications.length > 0, null, { timeout: 20_000 }); + const [first] = await page.evaluate(() => window.__loopxNotifications.map((row) => ({ title: row.title, body: row.body }))); + assert.equal(first.body, gateText, "The notification carries the real gate's text"); + assert.equal((await page.evaluate(() => window.__loopxNotifications.length)), 1, "Only the gate notified, never the optional action"); + await page.evaluate(() => window.__loopxNotifications[0].onclick()); + const drawer = page.locator(".personal-context-drawer"); + await drawer.waitFor({ state: "visible", timeout: 15_000 }); + assert.match(await drawer.innerText(), new RegExp(gateText.slice(0, 24)), "Clicking opens the real item"); + await page.screenshot({ path: resolve(outputDir, "attention-notifications-live.png"), animations: "disabled" }); + return { coverageEntries: await session.close(), note: "real Todo owner and status; CLI-created gate reaches the page through the background re-read" }; + } catch (error) { + if (diagnostics) process.stderr.write(diagnostics); + await session?.close().catch(() => {}); + throw error; + } finally { + lines.close(); + if (child.exitCode === null && child.signalCode === null) { child.kill("SIGTERM"); const kill = setTimeout(() => child.kill("SIGKILL"), 5000); try { await closed; } finally { clearTimeout(kill); } } + } + }, +}; diff --git a/examples/personal-workspace-browser/attention-notifications.mjs b/examples/personal-workspace-browser/attention-notifications.mjs new file mode 100644 index 0000000000..2da8b5ad9f --- /dev/null +++ b/examples/personal-workspace-browser/attention-notifications.mjs @@ -0,0 +1,188 @@ +import assert from "node:assert/strict"; +import { resolve } from "node:path"; +import { outputDir } from "./fixture.mjs"; +import { openWorkspacePage } from "./scenario-context.mjs"; + +// Needs-you desktop notifications are a per-browser opt-in. Enabling asks the +// browser once and seeds what already waits; from then on the page re-reads +// the workspace in the background and notifies only for a new item that waits +// on the owner while they are not looking. Turning it off stops both. The +// browser API is stubbed so the scenario observes exactly what the page sends. + +const gateText = "Approve the restated FY2023 operating cash flow"; + +function installNotificationStub(page, { permission = "default" } = {}) { + return page.addInitScript((initial) => { + window.__loopxNotifications = []; + window.__loopxPermissionRequests = 0; + class FakeNotification { + static permission = initial; + static async requestPermission() { window.__loopxPermissionRequests += 1; FakeNotification.permission = "granted"; return "granted"; } + constructor(title, options) { this.title = title; this.body = options?.body ?? ""; this.tag = options?.tag ?? ""; this.closed = false; window.__loopxNotifications.push(this); } + close() { this.closed = true; } + } + window.Notification = FakeNotification; + // The owner is elsewhere: the page is visible in a window that lost focus. + document.hasFocus = () => false; + }, permission); +} + +function addGate(fixture, todoId, text) { + const first = fixture.attention_queue.items[0]; + first.waiting_on = "user_or_controller"; + first.user_todos ??= { items: [], open_count: 0, source_section: "User Todo", total_count: 0 }; + first.user_todos.items.push({ done: false, status: "open", goal_id: first.goal_id, index: first.user_todos.items.length, role: "user", task_class: "user_gate", todo_id: todoId, text, updated_at: new Date().toISOString() }); + first.user_todos.open_count = first.user_todos.items.filter((todo) => !todo.done).length; + first.user_todos.total_count = first.user_todos.items.length; +} + +export const attentionNotificationsScenario = { + id: "attention-notifications", + async run({ browser, collectCoverage, url }) { + const session = await openWorkspacePage(browser, url, { collectCoverage, viewport: { width: 1440, height: 900 }, beforeGoto: (_api, page) => installNotificationStub(page) }); + const { api, page } = session; + const notifications = () => page.evaluate(() => window.__loopxNotifications.map((row) => ({ title: row.title, body: row.body, tag: row.tag }))); + try { + // Off by default: no permission request, no notification, nothing stored. + assert.equal(await page.evaluate(() => window.__loopxPermissionRequests), 0); + assert.equal(await page.evaluate(() => localStorage.getItem("loopx-pw-attention-notifications")), null); + + await page.getByRole("button", { name: "设置", exact: true }).click(); + await page.locator(".personal-settings-tabs").getByRole("button", { name: "外观", exact: true }).click(); + const settings = page.getByTestId("attention-notification-settings"); + const toggle = settings.getByRole("checkbox"); + const permission = page.getByTestId("attention-notification-permission"); + await settings.waitFor({ state: "visible" }); + assert.equal(await toggle.isChecked(), false); + assert.match(await permission.innerText(), /开启时浏览器会请求通知权限/); + + // Timers scheduled from now on are fake, so the 30 s re-read can be fired on demand. + await page.clock.install(); + await toggle.click(); + await page.waitForFunction(() => document.querySelector('[data-testid="attention-notification-permission"]')?.textContent?.includes("已允许")); + assert.equal(await toggle.isChecked(), true); + assert.equal(await page.evaluate(() => window.__loopxPermissionRequests), 1, "The toggle asks the browser exactly once"); + assert.equal(await page.evaluate(() => localStorage.getItem("loopx-pw-attention-notifications")), "on"); + assert.equal((await notifications()).length, 0, "Enabling never replays items already waiting"); + await page.screenshot({ path: resolve(outputDir, "attention-notifications-settings.png"), animations: "disabled" }); + await page.getByRole("button", { name: "返回工作区", exact: true }).click(); + + // A partial read that omits a gate already seen at enable time, then a + // full read that lists it again, is not a new decision: identities seen + // while on are kept, so nothing is replayed. + const partialOmitsSeededGate = (fixture) => { + const first = fixture.attention_queue.items[0]; + for (const group of [first.user_todos, first.project_asset?.user_todos]) { + if (!group?.items) continue; + const before = group.items.filter((todo) => !todo.done).length; + group.items = group.items.filter((todo) => todo.todo_id !== "todo-browser-user-gate"); + if ("open_count" in group) group.open_count = before; + if ("open" in group) group.open = before; + } + }; + api.transformStatus = partialOmitsSeededGate; + await page.clock.runFor(31_000); + await page.waitForTimeout(1_000); + api.transformStatus = null; + await page.clock.runFor(31_000); + await page.waitForTimeout(1_000); + assert.equal((await notifications()).length, 0, "A gate seen at enable time is not replayed after a partial read omitted it"); + + // A decision raised elsewhere arrives through the background re-read. + const readsBefore = api.statusRequestCount; + api.transformStatus = (fixture) => addGate(fixture, "gate-restatement", gateText); + await page.clock.runFor(31_000); + await page.waitForFunction(() => window.__loopxNotifications.length > 0, null, { timeout: 15_000 }); + assert.ok(api.statusRequestCount > readsBefore, "The page re-read the workspace without a user action"); + const [first] = await notifications(); + assert.equal(first.body, gateText, "The notification carries the item in owner language"); + assert.ok(first.title.length > 0); + assert.equal((await notifications()).length, 1); + + // The same item does not notify again on the next re-read. + await page.clock.runFor(31_000); + await page.waitForTimeout(1_500); + assert.equal((await notifications()).length, 1, "An item already seen is not re-notified"); + + // Clicking the notification returns to the item in place. + await page.evaluate(() => window.__loopxNotifications[0].onclick()); + const drawer = page.locator(".personal-context-drawer"); + await drawer.waitFor({ state: "visible", timeout: 10_000 }); + assert.match(await drawer.innerText(), new RegExp(gateText.slice(0, 24))); + assert.equal(await page.evaluate(() => window.__loopxNotifications[0].closed), true); + await page.screenshot({ path: resolve(outputDir, "attention-notifications-opened.png"), animations: "disabled" }); + + // Turning it off stops the background re-read and any further notification. + await page.getByRole("button", { name: "设置", exact: true }).click(); + await page.locator(".personal-settings-tabs").getByRole("button", { name: "外观", exact: true }).click(); + await toggle.click(); + assert.equal(await toggle.isChecked(), false); + assert.equal(await page.evaluate(() => localStorage.getItem("loopx-pw-attention-notifications")), null); + await page.getByRole("button", { name: "返回工作区", exact: true }).click(); + const readsAfterOff = api.statusRequestCount; + api.transformStatus = (fixture) => { addGate(fixture, "gate-restatement", gateText); addGate(fixture, "gate-second", "Choose the segment disclosure source"); }; + await page.clock.runFor(70_000); + await page.waitForTimeout(1_500); + assert.equal(api.statusRequestCount, readsAfterOff, "No background re-read while notifications are off"); + assert.equal((await notifications()).length, 1, "Nothing notifies while off"); + + // The preference survives a reload and re-seeds instead of replaying. + await page.evaluate(() => localStorage.setItem("loopx-pw-attention-notifications", "on")); + await page.reload({ waitUntil: "networkidle" }); + await page.getByTestId("personal-goal-home").waitFor({ state: "visible", timeout: 15_000 }); + await page.waitForTimeout(1_000); + assert.equal((await notifications()).length, 0, "A reload with items already waiting notifies nothing"); + const coverageEntries = await session.close(); + + // A browser that blocked notifications can be allowed again in its site + // settings without reloading: returning to the page reads the answer + // back, the switch is usable, and turning it on asks nothing more. A + // revocation while on reads back the same way and stops the re-read. + const denied = await openWorkspacePage(browser, url, { collectCoverage, viewport: { width: 1440, height: 900 }, beforeGoto: (_api, page) => installNotificationStub(page, { permission: "denied" }) }); + try { + const { api: deniedApi, page } = denied; + await page.getByRole("button", { name: "设置", exact: true }).click(); + await page.locator(".personal-settings-tabs").getByRole("button", { name: "外观", exact: true }).click(); + const settings = page.getByTestId("attention-notification-settings"); + const toggle = settings.getByRole("checkbox"); + const permission = page.getByTestId("attention-notification-permission"); + await settings.waitFor({ state: "visible" }); + assert.equal(await toggle.isDisabled(), true, "Blocked: the switch is not offered"); + assert.match(await permission.innerText(), /已拒绝/); + await page.evaluate(() => { window.Notification.permission = "granted"; window.dispatchEvent(new Event("focus")); }); + await page.waitForFunction(() => document.querySelector('[data-testid="attention-notification-permission"]')?.textContent?.includes("已允许"), null, { timeout: 10_000 }); + assert.equal(await toggle.isDisabled(), false, "A grant made elsewhere makes the switch usable again"); + assert.equal(await toggle.isChecked(), false, "Nothing turns on by itself"); + await page.clock.install(); + await toggle.click(); + await page.waitForFunction(() => document.querySelector('[data-testid="attention-notification-permission"] ~ *') !== undefined); + assert.equal(await toggle.isChecked(), true); + assert.equal(await page.evaluate(() => window.__loopxPermissionRequests), 0, "An already granted browser is not asked again"); + await page.getByRole("button", { name: "返回工作区", exact: true }).click(); + const readsBefore = deniedApi.statusRequestCount; + await page.clock.runFor(31_000); + await page.waitForTimeout(1_000); + assert.ok(deniedApi.statusRequestCount > readsBefore, "Enabled after recovery, the page re-reads in the background"); + // Revoked in the site settings while on: read back on return, stop quietly. + await page.evaluate(() => { window.Notification.permission = "denied"; window.dispatchEvent(new Event("focus")); }); + await page.waitForTimeout(500); + const readsAfterRevoke = deniedApi.statusRequestCount; + await page.clock.runFor(70_000); + await page.waitForTimeout(1_000); + assert.equal(deniedApi.statusRequestCount, readsAfterRevoke, "A revoked permission stops the background re-read"); + await page.getByRole("button", { name: "设置", exact: true }).click(); + await page.locator(".personal-settings-tabs").getByRole("button", { name: "外观", exact: true }).click(); + await settings.waitFor({ state: "visible" }); + assert.match(await permission.innerText(), /已拒绝/, "The revocation shows as what it is"); + assert.equal(await toggle.isDisabled(), true); + return { coverageEntries: [...coverageEntries, ...await denied.close()] }; + } catch (error) { + await denied.close().catch(() => {}); + throw error; + } + } catch (error) { + await session.close().catch(() => {}); + throw error; + } + }, +}; diff --git a/examples/personal-workspace-browser/goal-attention-fixture.py b/examples/personal-workspace-browser/goal-attention-fixture.py index c836502995..ffd91e7a4c 100644 --- a/examples/personal-workspace-browser/goal-attention-fixture.py +++ b/examples/personal-workspace-browser/goal-attention-fixture.py @@ -2,13 +2,21 @@ from __future__ import annotations import json +import argparse from pathlib import Path from tempfile import TemporaryDirectory from loopx.status import collect_status +from loopx.control_plane.todos.list_readback import list_goal_todos def main() -> None: + parser = argparse.ArgumentParser() + classes = parser.add_mutually_exclusive_group() + classes.add_argument("--mixed", action="store_true") + classes.add_argument("--optional", action="store_true") + parser.add_argument("--full", action="store_true") + args = parser.parse_args() with TemporaryDirectory(prefix="loopx-attention-ui-") as directory: root = Path(directory) state = root / "GOAL.md" @@ -16,7 +24,7 @@ def main() -> None: "---\nstatus: waiting\n---\n# Synthetic decision scope\n\n## User Todo\n\n" + "\n".join( f"- [ ] Review synthetic decision {index}.\n" - f" " for index in range(21) ) @@ -35,6 +43,21 @@ def main() -> None: assert len(item["user_todos"]["items"]) == 12 assert item["project_asset"]["user_todos"]["open"] == 21 assert len(item["project_asset"]["user_todos"]["items"]) == 3 + if args.full: + # Join a real explicit cold read to the same known totals. This + # complete fixture is separate from the native bounded overview. + rows = list_goal_todos(registry_path=registry, goal_id="progress-projection", + role="user", limit=100)["todos"] + assert len(rows) == 21 + assert all(row["status"] == "open" for row in rows) + # The cold API uses lifecycle status; the status projection also + # carries its compatibility boolean for these authored open rows. + rows = [{**row, "done": False} for row in rows] + item["user_todos"]["items"] = rows + overview = item["project_asset"]["user_todos"] + overview["items"] = rows + overview["projection_view"]["item_limit"] = len(rows) + overview["detail_pointer"]["full_list_included"] = True print(json.dumps(item)) diff --git a/examples/personal-workspace-browser/goal-masthead.mjs b/examples/personal-workspace-browser/goal-masthead.mjs index e735236a48..3c9d82a80d 100644 --- a/examples/personal-workspace-browser/goal-masthead.mjs +++ b/examples/personal-workspace-browser/goal-masthead.mjs @@ -207,6 +207,13 @@ export const goalMastheadScenario = { const native = JSON.parse(execFileSync(resolveTestPython(), [ resolve(repoRoot, "examples/personal-workspace-browser/goal-attention-fixture.py"), ], { cwd: repoRoot, env: fixtureEnv, encoding: "utf8", timeout: 30_000 })); + const nativeCase = (...args) => JSON.parse(execFileSync(resolveTestPython(), [ + resolve(repoRoot, "examples/personal-workspace-browser/goal-attention-fixture.py"), ...args, + ], { cwd: repoRoot, env: fixtureEnv, encoding: "utf8", timeout: 30_000 })); + const mixed = nativeCase("--mixed"); + const complete = nativeCase("--full"); + const mixedComplete = nativeCase("--mixed", "--full"); + const optionalComplete = nativeCase("--optional", "--full"); // Exercise source -> parser -> adapter -> digest -> packaged rendering. // Counts are removed from a real producer payload, not a prebuilt UI model. for (const locale of ["zh-CN", "en"]) { @@ -257,7 +264,7 @@ export const goalMastheadScenario = { assert.equal(ui.errors.length, 0, ui.errors.join(" | ")); } finally { coverageEntries.push(...await ui.close()); } } - for (const locale of ["zh-CN", "en"]) for (const mode of ["bounded", "optional", "empty", "unknown"]) { + for (const locale of ["zh-CN", "en"]) for (const mode of ["mixed", "bounded", "optional", "complete", "mixed-complete", "empty", "unknown"]) { const ui = await openWorkspacePage(browser, url, { collectCoverage, viewport: { width: locale === "en" ? 390 : 1512, height: 982 }, @@ -268,8 +275,9 @@ export const goalMastheadScenario = { api.transformStatus = (fixture) => { previous(fixture); const queue = fixture.attention_queue.items.find(item => item.goal_id === goalId); - Object.assign(queue, structuredClone(native)); - if (mode === "optional") queue.waiting_on = "agent"; + const source = mode === "mixed" ? mixed : mode === "complete" ? complete + : mode === "mixed-complete" ? mixedComplete : mode === "optional" ? optionalComplete : native; + Object.assign(queue, structuredClone(source)); if (mode === "empty") { queue.user_todos.items = []; queue.project_asset.user_todos.items = []; @@ -285,16 +293,23 @@ export const goalMastheadScenario = { if (locale === "en") await ui.page.locator(".personal-mobile-menu").click(); await openGoal(ui.page, title); const needs = await vital(ui.page, locale === "en" ? "Needs you" : "等你决定"); - assert.equal(needs.value, mode === "unknown" ? "—" : mode === "optional" ? "0" : "21"); - assert.match(needs.sub, locale === "en" ? /oldest unknown/ : /最早时间未知/); - assert.match(needs.sub, new RegExp(`${mode === "empty" ? 0 : 3} ${locale === "en" ? "listed" : "项"}`)); + const expected = mode === "complete" ? "21" : mode === "mixed-complete" ? "1" : mode === "optional" ? "0" : "—"; + assert.equal(needs.value, expected, "Only complete classified rows qualify a blocking total; open User Todo totals do not classify hidden rows"); + const partial = !["complete", "mixed-complete", "optional"].includes(mode); + if (partial) assert.match(needs.sub, locale === "en" ? /oldest unknown/ : /最早时间未知/); + const listed = ["complete", "mixed-complete", "optional"].includes(mode) ? 21 : mode === "empty" ? 0 : 3; + if (partial) assert.match(needs.sub, new RegExp(`${listed} ${locale === "en" ? "listed" : "项"}`)); if (mode === "optional") assert.match(needs.sub, locale === "en" ? /21 optional/ : /21 项可选/); if (mode === "unknown") assert.doesNotMatch(needs.sub, /Nothing waiting|暂无等待/); await ui.page.getByRole("navigation", { name: /^(Goal 视图|Goal view)$/ }).getByRole("button", { name: /^(概览|Overview)$/ }).click(); const rail = ui.page.locator(".goal-overview-attention"); - assert.equal(await rail.locator(".goal-overview-count").textContent(), mode === "unknown" ? "—" : mode === "optional" ? "0" : "21", "Rail and strip retain the same owner total"); - assert.match(await rail.textContent(), locale === "en" ? /oldest unknown/ : /最早时间未知/); - await rail.locator(".goal-overview-more").waitFor({ state: "visible" }); + if (mode === "empty") { + assert.equal(await rail.count(), 0, "No unlisted row is invented as a decision card; the vital retains unknown and its partial qualifier"); + } else { + assert.equal(await rail.locator(".goal-overview-count").textContent(), expected, "Rail and strip share the same qualified count or unknown"); + if (partial) assert.match(await rail.textContent(), locale === "en" ? /oldest unknown/ : /最早时间未知/); + await rail.locator(".goal-overview-more").waitFor({ state: "visible" }); + } await ui.page.screenshot({ path: resolve(outputDir, `goal-attention-${mode}-${locale}.png`), animations: "disabled" }); assert.equal(ui.errors.length, 0, ui.errors.join(" | ")); assert.equal(ui.api.turnRequests.length, 0, "Reading attention never starts a Turn");