From 7791ca145fea684d10a192035700533e1e314a67 Mon Sep 17 00:00:00 2001 From: song Date: Thu, 15 Oct 2026 21:36:30 +0800 Subject: [PATCH 1/7] feat(workspace): notify the owner when a new decision waits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workspace only re-reads status after the owner's own actions, so a decision raised while they are elsewhere stays unseen until they refresh. For a product whose promise is long-running work with human judgment kept in the loop, the item that needs the owner has to reach them. Add a per-browser, default-off "Desktop notifications" switch under Settings → Appearance. Turning it on asks the browser for permission from that click and keeps the preference only when granted; the page reads the browser's answer back (allowed, blocked with how to fix, unsupported). While on, the page re-reads the workspace in the background every 30 s (60 s hidden) through a new `onBackgroundRefresh` callback that keeps the current snapshots on screen, and sends one notification per new item that waits on the owner while the page is hidden or unfocused. Items already waiting are seeded as seen, so enabling or reloading replays nothing; progress, runs and counts never notify; bursts collapse into three items plus a summary. Clicking a notification returns to that Goal and opens the item in the drawer. Turning the switch off stops the background reads. The admission rule, identity, cadence and content live in a pure module with a node test; the browser scenario stubs the Notification API and uses fake timers to prove the opt-in, the background re-read, the first notification, no re-notification, click-through, off-state parity and reload re-seeding. Read-only sources never offer the switch. Co-Authored-By: Claude Fable 5.1 Signed-off-by: song --- apps/presentation/dashboard/package.json | 6 +- .../attention-notifications.test.mjs | 62 +++++++++ .../attention-notifications.ts | 86 +++++++++++++ .../src/features/personal-workspace/i18n.tsx | 16 +++ .../personal-workspace-contract.test.mjs | 14 ++ .../personal-workspace-model.ts | 2 + .../personal-workspace-page.tsx | 113 +++++++++++++++++ .../personal-workspace/personal-workspace.css | 2 + .../workspace-settings-page.tsx | 30 +++++ .../dashboard/src/views/dashboard-page.tsx | 4 + .../attention-notifications-settings.png | Bin 0 -> 55605 bytes docs/guides/personal-workspace-user-guide.md | 21 +++ examples/personal-workspace-browser-smoke.mjs | 2 + .../attention-notifications.mjs | 120 ++++++++++++++++++ 14 files changed, 475 insertions(+), 3 deletions(-) create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/attention-notifications.test.mjs create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/attention-notifications.ts create mode 100644 docs/assets/personal-workspace/attention-notifications-settings.png create mode 100644 examples/personal-workspace-browser/attention-notifications.mjs diff --git a/apps/presentation/dashboard/package.json b/apps/presentation/dashboard/package.json index 7412a42580..1aec107ee1 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 && 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 && 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 && 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 && 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 && 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 && 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..a5c3747fd3 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/attention-notifications.test.mjs @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + attentionNotificationContents, + 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); +}); 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..aa15cbdd2d --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/attention-notifications.ts @@ -0,0 +1,86 @@ +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"; +} + +/** 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 4db768d258..7b6c176505 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -1291,6 +1291,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", @@ -2798,6 +2806,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 2efa09d6c0..6042cd1e5d 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,4 +600,18 @@ 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 the current lane so nothing already waiting is replayed"); +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`); +} + console.log("personal workspace drawer contract smoke passed"); 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 d982251735..b86bd08051 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 @@ -87,6 +87,17 @@ import { ManagerBrief } from "./manager-brief"; import { WorkspaceSettingsPage } from "./workspace-settings-page"; import { UsageStatisticsNotice } from "./usage-statistics-notice"; import { readWorkspaceTheme, writeWorkspaceTheme, type WorkspaceTheme } from "./workspace-theme"; +import { + attentionNotificationContents, + attentionNotificationKey, + attentionNotificationSupport, + attentionPollIntervalMs, + newAttentionItems, + readAttentionNotificationPreference, + shouldNotifyAttention, + writeAttentionNotificationPreference, + type AttentionNotificationSupport, +} from "./attention-notifications"; import { compareProposalRecency } from "./proposal-recency"; import { WorkspaceShell } from "./workspace-shell"; import type { StatusSourceControl } from "./status-source-switcher"; @@ -972,6 +983,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); @@ -1925,6 +1940,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") : ""); @@ -2102,9 +2163,61 @@ export function PersonalWorkspacePage({ onClose={closeSettings} onThemeChange={updateTheme} theme={theme} + attentionNotifications={readOnly ? undefined : { enabled: attentionNotificationsEnabled, support: attentionNotificationSupportState }} + onAttentionNotificationsChange={readOnly ? undefined : updateAttentionNotifications} /> ) : null; + 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 keys = new Set(items.map(attentionNotificationKey)); + const seen = seenAttentionKeysRef.current; + seenAttentionKeysRef.current = keys; + if (!seen) return; + const fresh = 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 d4172394a9..2cedf347e0 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 5877abf0dd..8309bbefd8 100644 --- a/apps/presentation/dashboard/src/views/dashboard-page.tsx +++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx @@ -1204,6 +1204,7 @@ function PersonalGoalHome({ onSelectWorkspace, onReconcileStatus, onRefresh, + onBackgroundRefresh, onRetryGoalArchive, payload, progress, @@ -1229,6 +1230,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; @@ -2977,6 +2979,7 @@ function PersonalGoalHome({ setCapabilityRevision((revision) => revision + 1); return result; }, + onBackgroundRefresh, onRetryResumeRun: retryManagerSession, onSelectAgent: chooseAgent, onSelectAgentModel: chooseAgentModel, @@ -3542,6 +3545,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 0000000000000000000000000000000000000000..6db614d94321c699b4f9dbd0e89d7728c2ece1e1 GIT binary patch literal 55605 zcmd@6byQW~7e0(03_?Tw4(~qt z`Tp*E?|*lUd&jtA-2I1a*?X^?Yp!QL&s>D4D9KN-NnUCu^k=+EYEj^}%74r~_l&1_p-n}ll(&~=OaYC9OQcGQ0TBxx~!ZG)-wY!!+X z>4H{An>0ga{npd?-bWc19vVtewzIw1%I-Jp=hSiSJA4_nb9a6A1jM>FS)|6n!SMuW z8gSX!)II-q2l6MzLPGiPe$XA2|G!(K9*K6O|L(ac9%21=n{MXyGSj$YKwuuZ0PvXIDer{yzVnIgd_ zS4XAna6Pr=`Beg@^}wkcWHPdx(9EB2Gn{AAd^5J$Dsd{cnB%S0pK<&*`2N;gDJW!i zb-Gz9dSlGAedm|e5eHprCBx^%Uzn z`RGWpNAw)xf}9l^rWGfnb zY83vntTzsrk0TQj2vY1ewzdpDVqz^F{Pf_L`tY$*i~bq1MDkaKa@E(JV5Yka-&Dhl z=t81?tlaQ-B@>FcbPiqAbgTXW`GjF-crr^T+Lt;)i=nXQj_$ZR3dc(_q1ESaG4bse zmyOYJ*EptZ>cS~lD|h|w2hy`-ey_aI)c`Z~>uB4^V%X+XR5Z9Q$M`m==@^unp#Ik~ zJrfAy$`7ctnwpxc>&0ViVFxKL+@(*&MK<6d3VFis-#1aQ(++N3YonC#tj;;|2~g0m z$V$A+?QEQ%$JFkG#B0uQo15Y-Fp!W64NDeO^u6cTxdf*@H-LG$n~y!(bRfsBe9wwi z5xIS%&7kq{$Xef^@y`zzUI+!0;Z!QnLnoK?zm`BcdC>T!$0P3Uyq)s8{*=aYl5g*E z9a>jO8Q^_-HUP0GGBsWrtG{pfpj7mczQpZ$x{g*_-iwKExwT90$_b7vOBNxkSxg$w zb}bu2NglsLup1kfD15gV+*aDkzW0;Q*KO8>CGFS&?&b%%nqcdVF4;V| z0$Nd_tYm9Uza1#i1`kU4H;xL%DUTzCj+)O(4DWa6?c02gS}uJznG72o9wjUAp1TB` z3Te+1$J7?)%1*!6o^TBM#;vW5*Go?vNg|xk!UmI*5^w93LzmcK?89}QPT1pL<2%^j z!5iQq4qFcl3=VY~Bji>k_Sq2~)2crDHpDj0tesk5Q&F~OQ|29F_^|T~#_^0R^m4;- z?)?n78qx}{if) zLY#*<9|^X0)>t_gEn9!XM()-vmhK88nL3iBz7NgNEnKBPJW7cM|-x!6)U?Y|B4s**EYREo76lQcP7= z)oF2vgg=2%?b$gTV++x@9MgYgY;>Fzx%6AX!2bqY&9&?i7VaN@_lyG7?rf>B7tgxY zEzSfK&d{Th7xdlr0v~NqZTdXQXW39iErKI!t2T7ELiSpz{lh*99j) zy53J^H`Adlm_b-R>}+JwQWI?i?i;(v5vFe{Sg0v7G`s-(C-giFUzUGGdb@_ggW`7W%9pAf7rmL5)PR3M9;#JTSOlki?7@lfT)|5*6Jd7Pwzf@CKhRGzPpWj!N#i-}3Kk~I1_Sdu!V`PIkK+gE=rS1-nz z)Ir7S96xIJRhY5@nFsX;+Fe41T{hlN(zAZ1dfwuV8oCCwnxDru;_sQGz6Fyeyn^LD zlrw<%xF9F_b<(}on@T!S%AC(TOPga?KKA);G{|InGhENMjIg4CwMZOhJ9&y|D=QTy zX_0yzddXu)L`Bj6R=uEON5hVm{MJ8`YbAlh)X(BbH*!EJ|J^&|{5hMh`I8cvw#r>d zFZr~OGQ4z5l(saGlneufOhIqZRN_?>t1flS51)S)q4(L^AmA~`&f8$GHC=}0-o9(= zZe#X{E~*C`38Fg!-+@f4jQn8Kw;4>DeH1Wl*Q??*ZmQZfYIJn69@Ez~23uz!q`%+w zs>Iqesvk$r2~GbMgj{UJQefK+_G$|JWjr{;SRA%RO7*rc=Ak8V$uYuDfZk_Q+3%;r z+wkXJPkrM5?THF#kFfl86E4#Y;l~y<5o*R<3EMkCq>&CXes`VQkuwKJ&7#7!p;rB; zfsS|tFUNc?x0RVQ#ZJACcbu2|{9Nqo-1a3opx6(`tJfxmA&T%SD}Kggb`|FT+vHf2 z?A|Y8Q!aXV^Bbv%-0jP=-FqI%FHd1YYJb8#r>e}Ncmhk@WC_Z>ufBK(w34O57ZXWH z{bF-SJoOVOJS8p{Ei$ik(r~1NPsWvRnNsAO37Y39U16D54Q!* zObE;UpoGh^Bu%nMCy*#DnM@~{%N8?y+{iaZv@58i?m3X%q=EIlobzer`X{R=0PaDk|ieOTpkiNUYloh8 zV?cA6$i%o&JjiqTW=a>Y#5_%S-1OY{dce>Z22qR(GB|b>ZrW{-jmmFK!zkahv?M^o z{a<<9s!0B-5*Ky8!gIHUrmJ`(@Rfl$jEuMC{(C#a>9(tp+_Yy$ZGP^}@o`7ZX5{2b z2|+K}T6~4?k{TXXH|1hWbO`_UccBrO%64{$P@6Yw3=+&_&wlx^LAb2gmOV#Gc2QoZd_0V=wE%WO&ovDW|%@b>e#Dbpe?^rluoa)_1V&}&?vqocGpqiTSa?V-9t()lzC^SYnogfOsStiCS|*z3dhI%v-5@OX8vEfcFVow^lz;9O{t$bg zBNZjFjVE(8l!Mc5E%1!^zo3KSW(8D_FV$`4eHpwwL`+c~&)yhpXekWaJ5u~K`u&a3 z1`=UC`ySD*h+SeX+X!kmPHf6fIy<@?{A zXAFk7-rWE86MwluK|i=Kl7ARKcvoFv(1PssIr7%Q%bBvBA=f_!>Y9QIz2;q=e`i%`Nr^^VcOnpclzO z^=|)O`EEB)X2O2S5bdui>(K!VIngN&g-p||Z%LdB0 zblc_XG9xH6D2|~1+YR8K31SC~S!_F2ZB2_P5mP@f1lfmwoygcN@$PKBr~T`vWV5Cv z-714=p3O%Dz9rwq>=laC-r|A zxtFFSsfChq#<7+Z;>Z#B%d90P!K^%Gw6F&vg%_AiNrR`F%Qkj)z6aMT7cqK`VWM7V zQw5yR_jJjd6=+8-+6{toNurT4IR9(yZx>$Gkbwo2vlP=sn$(DZU|vy!balL<9`U$u zZ*KC%$Y!E(rGl7+DBOQ!r^?cI;Vs}`O4ct0k>fMdta`Gr-lW_cfdlyx9NtD?zMbhI z$9I*+{Ye^l`RG565%kj7^LFJW$IaAyEm*8-EX{PZgiW=Zn$c<@RpK*D6bW z!+BMStK7izQ=|qWKR@2Z2F)}MMM2IvgB0UCMLfeN=FOCqRB6`#SJbCx{LL4vw<-#H zdNyR3yxG}7>))tqKgvk3`9yy&k3yGuj)5nUgPE9Dz6O;*N*utKy-j4Rh1XZDXWJ_A zn@EK?R`Jm$>%x!c-@9=CLtDU<<2krDxqTP@)hl``{OREHV^UgRBu3OzQP5Pk?X}{_ zutc&af<>I*ALdJGYqV4`S zKUCGjnPSSKTooR@jTTs*5-jf<56D6k4fAU#ajP&t3szfdd+u9GQiVSn@RUnywFH+7 z((}I7CM{ZA71X_Ec>36nx8*~_r!uM!?&ZVF@16aNXRDZBSh2ruxS2sall>n-)*hdF zp{g#8Hl$?y>uGAOyBDSVg5}W;y{d?5lI4r}jb?3{NYV-c#P!!+X zKmPT(MePwR{y-34N7T=*lWMqT?rHEbP>llvM!ZnbrRx#_=1Z zdHDGwWjP1xf&#*+5*s|%Cn93RO&+^eYZp#Cju9{x{{H9tKp@R6o%7$nP3`8YKCn50 z$xG)|D_bi0-WZ1Qln&FOToOg#W1I18*uPp!i^w#~%M9YoLG;~G6otk>qx@S!umlqaT!Q)Z&-@jyk`A+l* zG(nAnI;s+2Ks#nl6C(MAi5BpU#8lpX#!nOlca|L=fgJXdd4}99gJ*(<%<#eFH2uhN zrmyCHew6Utveb{DO59Xg%n|61t6-D{E- z_S4&Y?y>syNFl^544P8<-El++m~s-gd=dvbMps=fh3^7cwa6$ZRo}yu#|%<9kU|eO zxcFJgHyj=Ij>uuX5&scZirpv^RnW&SV+5D(pB%OA5xKtOgRb85#9#Lh^RG&j`md>? z09V_S%w@4Pl!}1XD_d)>O*uI^s!>3AO5W#!WcSIp?cswZ5Uqk7(2a?L;8_eHP#p%a z-PM)9dHE7IWR&wM@L9g(L)S=#$ZedmC>8J=8S%XESnKDG#tYynz4XIqFp59oOAzA8 zE6;`i=?284iQH^wERDbXe>~ulp~XcgGxbGdVwZsHUm^JH|9FK!ssj91cUgcTaB11& zZc=~U7YI?tUl$4ZQ4jv%{@5W5*!J&i>$0!_4BM_432!LygQ0=iSwP+YqlEw8RBw)+ zNvjHgcV8gYs}7~|MFGtMuJ*FsXOFO6{6|uOR(ihDGyZ!5`v0o*6M_nApW5Bqv$nFj zsd%`lfWv(g2R6^HQ9*h#oXHAB>c#4e0l%Zu)61r;5Uaku%xe1jY#R-v*HxreU~FvM zH`KRxJ`D6jk0rs3&wj~7~bBL!AxSE=TgoMbkdNUvHw&~;(fTwpmGGZx0 zZmbQA+(maAampQ=Or9wdnRSan$YmhNR6(XMNhIOp&mbR0f-q>mEMQVjDglpI-m3(d ze1XH^e)kZ+`-x1r%XYfk*47sMZd;52K&76_l}zs-u>LzVl7R`>fQD&SjDtxwW}8Z1Ys zk#q+#TIdh`@-4IN*Gq2=ukZT>LQb)l7bYy@9&$eTdRcN%>O4$-Jh(NQGIi{rF1rkpX5iXA70qO0WDH#hfAra3fj;#HhJsCTcZrzQt6;yG8tX)zGJ251y}!|g5ay*)wmaWqNsH6agJ zL>yr&O;eN6kS6g+aDmvyBks1VQLfvCt9f;9vScp4ge>B|G$A!Ef{a=Lvf@dvM+GP% zXGa6|UKd{J9mje^PRMrJ1YzA)HLQB|4tvvOPeA5hEHZsA7TuN+0*9cWAk6c=n;soG z;8*k4o@)?SH@Vxw>bd79>g(Cw$fuzCYlR~|ljC0p_rIO1t{S$HZKgk0r(AcU$wc2C z=MMc&PsL+uc8fJ}xg5mSJVwnRckx~!!jRo4ZaNH2r)hS*kSLkr&^?n9kg=drxVvjQ zOrv?`R6D#LSxt-ctCn1(WIS)zn2zvpXusjI{OZGW`uYVU(F%X$VDgam^8LDF?~fxg z$Gh`@24~`qr(B^A}QNGdHi{9UE%lMucd4|gyR~`7>m%S)TLc(N<6y6!w z28jUvxZ_g zxLD-CG%-ZAjmRD9G_L)gobf+6xW_y~qe4M*^5eNbF?H}gbfdVV#&aMmTU@^0jtg*O zNh3ATv5`Irxa=ciY4cdmU6)`j4AoD%)QB3Dq}$tz<5PVux7E`#AVLv5#O0c!l!?{F zB}k6R`Gq2NimKG}_-fuZJgBu7li%j8e7tijD4B_5F$!B$n5luwHF5`bERSn`xFApu z56}g+r5o6Ua$H;-8fcL0_3PKL!MyDwy8NteTJ9~BByG#~)yrF8i_$Y9K|lEXyDdXl zk@ie#AvoZ(fU`LYaw+fOp}N4TlJmAXSFunas`!hNSox(}cJ9Y~?t*W;BgNppZmI0I zpPZC<-7m<{o|083UtKleKL2REJt!*oNUcbOs^P2^CG3dwL1)z2+fP4Ll6oQSS%&Df zLE%aNenYX=>}<6-iEdZPR}wPu;Z-3o)>qAh8%qx+XJhMn`*x5|FA!nK)LL0 zswSAbXsgVmsa|j6M{yKb@Z5J5VtG2&fs5pyyq@88c;YT>P;rSn@dFv9Jshw6H-OJp z9! zT8ko1y-fkzUji7)ungJVSzqd1rmxqx>jgQxxDH1|C`ZH(3EUdUA9!*0?;m?@4{Kw? z=@(+@MyD?zw_(oUzW=4~*u9$2crV8EOD5T8yTuJN@qT;3<5E9-%1XrJCgavvjq;Ab zLW|tsV=twsOeDedk1CNslEGE0$GF|A#aT+6Wi@owXoba)%qOfx} ztbyr1EjnHO8qlEDt!G-jBt}6?O;z%PN%tiE_EBV=HCi%D1JmB#*%9f!_iaQ@noT4# zjuH3ewvQCvtgD9fVSpY#yMWux)qMVMAsNQxZ<5Yie}IrzVW^kt^P3f5$EVB0Y|*8Q zkv&AE0y&ucV7OxF{HpZ3!2Du=whp_K7!yEt6PDB9!c_t?;icnSUe=3@@JXSNgAw~S zH=TGj*bh7Y%$G>R&IyAtTobc(QY)S7_LMbP$iq$rAzQ!gMTm%hey|)qy~SSRI(#lr zdgSMDL+0$J^!xrKz|T9U`;XjKvCHN1gVeIz-ioZx(o2i#jE#*maPinX((3IcAH&`B zop0*|j=PgZNl8RbT&JEbXF%+*)^&1n0^4qH2PIcsF8e*CyDbSgtbhip3Y0VHGlu{_ ze$;w<7)w z$f3-^$GB?+_nvqq`R*YKmjWB}DI9*;#H+J6 z$y^mJ7jwXP_5Z4xKUhWK-jD7#Eu6(-$Dc3==-d{PZxb=z?(gSrxa6?g;|o4~7hOQV znv0yo2mowQ(hJ7GNA27~UKjfh_iYb}3MDZv0kQA$5Wyjm|K1^BJm3c80=~r`VwrH; z?>5sb7DuHhKr`$dfk4VA<>@1&1hak^ajKn)apl$DNX=m}P&YsN6Rr|p2jhjjr>*Ab z05O+%kcN;~QAx4X^*Bo16(tw(iJk4>F7Ic8Ntk->NN*_SqN z@7WMBxI+!%yNJCGm&YJcwoLESe%oq`7$`f{BI>PGBQp*vf_w8X2?WyS>{Mg{==F+w z1t+ZF<8h7LL_}LNncM9_UCuc%@RBzHBKDL1>hgF+T4Ex2zT5b8R*xCz*>eQR&~EV+ zQ3OAqb|Z(WnAZ7Vq+r-QyRpje&kwiWoo6~t1ES2Z<_?uQa9TlElq_v4HeZ--$jf2v+hhGK@I89L7 z6(AVUikbdgL{FhCEhJDFHF=?Q-n(Kn%-Drp8|knA_z3xID;LaXUv3f{7n$^Z z+*TLk^Ac7y-A_T`21v8Xy4`!&56hsv9IJwrP)7tmub8zuvsWzW#Cult3 zkNm1f1CcTm#XH{ymcc)&UaG$#0XSFT<9gMq($n^G>VqWeINH-VI8%sx|KRsJ5{fHzL2WZk8@%mHk0qST?OOty$FAR=|u!NNvTfD_dnz!p^>)2hsQ0ns%V=S7loT~O6y-hB zTYE|!H^V!@8@B4w(*w=0XqWauP9JEHice1QA6ZVwR+Fo6YtGBeEViPvI-7p5hP`*p zP?@kw1GgUJwK+5eQ$7$y;-YaW=wvEiFQFK026hFjDsIlbP>%qNyFTP45ag0}fEDnxQF$aBa|YnyI@E^k1l0A#2P{gOsUwh}L zV6f*8Y8(gc;#zxbE&jFLx$1jjE|DVRU-Dt6#3mZuVT+D{R#pj3e(lJeuwnV5FB#fg zIglZy(tsS#2drm1L4+%Db`{ah$Ed_)n?A<{kCi_jAuJR)C;`>}Gq^F@?8d+#x_x|{2=r*m%}`y}sKmL8%ij`|b| zs_Ta4_7ZVU>AS}MYK+Zj;E>R78u&vox4KIXFNZExpTsndQcp>ZXqJzyhKe3-jCUjFQ*Dngs@wG>%mq+8s-I&CuVpxR; zw&gpwUj(y2G2*W8IOaj;yyKWjuk%F~aU{DxO-XD?Y`r<1v)xR_P1N5JRFD02F$4gN z&U}{;TAu;Rw-$fioCVQpY91N2&+FISCtG9`%}kKTr6nhqCZOI&)5{@yHM}YljpJmV z>RI{QwNHqZI5Gr2(r~ftBSpmtp=BLMmsVH6kf3yWxUaMrhv$z{h-%H(&NfXTxwN@ZrrBI$^f;MP{9X9|x6eu&QM2Qa|aU0&La>&U!atr>1 z1%(+d8OR^K{1^*|ma{3{c)p`O0feF8Mmh9b2@j1y!GW< zr*+Z10VGvjJtdX);||L<3vv*9vNgoKXFQkoojz=mJ|Nd?~IKqha#*JkKC5 zKC(kzh{hE8;j>(O_ZsbE^Ud9j%KMwxB-oTS*W;ez#YKa^bUh*LH&VLK*RB6?0ieu) zz3+DQ%x^OT6gIRGa*p}fF03)MCt}Z7PpZE>h_J*wb~kRPIxS8I{= zTF&W zo?06&nLWUJ0EQKPERgo~!?k{D!uYu6!bU}KoqBq*>2hni)6&zmS=#!Jaf1Exp8kxKFpq7%*u=a^dIdQq=r&ETTYTF%*$u52N0-0-=ber7QTl6gF;e2{-DT3CaFtZE(An!Z9+HY+Y-Pn^la$5-As(SjNJ_qPSbm zIB)9+ZbI$sl70KUS1q|?rDm!njrO&OnR^SY+@*cVwz8g9GCe~n=sqe2Yg&-N8+lYI4qYU? zEf-cuf^WViuL$Onev!qCAKP}6`<`3qqzOH)AL6NyW4wrd)bi5`Y_4kOc_co3tmWZh zuJB#63_?GP&fC)bdV!TLuds^P10a*y70v#i1OdC@KLZ98ZcEJ`YH9qUq8V^9BBG=} zT|PcO09T&K=Iui=f!P?^)g{TPhS9JefYi}d%t1k?=awkq0j*nj(e~_bw6p%A+F~T* z_{JrV^taJ*+rw?!!}h~nrY1LJ6elF*G=ztn`vrdB1dbiDnwYapew%iM1qjsh788Ji z6n1Hiq?rMQ{O20e_n`XE3%<7pna9m8-t8O_U_R074ZOx@PJ!jGDggc)fmAx#D?tuJ zi{nt__MtEN*mn%SD=QT22fmk}|Bd>k5$nks9r5&YK6a+>4YY6K(~%p3M8pmG3YZ^A zj|H$l_945bgIXXdJBZRS%!P&s!(awTPSUe>TX0076y|$D;%YiKftfkddv=#5;Ead} z+NXe&O3+03Qb5b?VN*X(Nz-S~eNZN0-g}QfJ~uky$!jix&<=pm0s<3%ATY@%ZW(Ga z?hdtvaBl9;R*#L10i3^3mT!Vy!)8ntT7_Sai;0oZ%COK;8jm#r&^6E%#=n3INyg5u z9I{8Jc~}Jv>Rr#0VxBZXxoYlWAHSV2Fe zEAHo0nqoIQG&gS;<9+vZmm9a-9IroL_>4>m;U*GVl;pmZ_Z=RuADLt`{P{}bHKrof zhkUcVlXU44AzX_m_xD)JqwAmMbk`LShi=d+-`i`Y_2Gbsp4%B6IW?@UiB4#tfgD7} zoD<;qtu9AIfRKzJFzuT#(<2`b62a1>MtDG=xW*4iy<+fnvBpKa3WzmtstCnM4HiHD z#kUYl+z#HUpOTE4?>vT`gIW)vpTx!`zG`lS8^#kHvDZ?ZNHWDMTv;inL`T1QI1#v+ z88!am8#S@6Yy=|?!5}FIg=aTNvlH#_?&>tUyhidTUTe8r3d3sX;^v0`u;9bw0Eko^ zSX#PgT`M4!ez!i~Op1vS_nWifC2>d-^UKf^67D6jzT^cvp=r~3IIXwRlOAo~`HIC@ zl<8-7^Jq+gJ--=`h1Lk@jMrrbk?3@u0!gY3KeeAla4)1s)Yv`&(f>f|U4DRH9v<2O zYlI6GlMb4`B(K>FSU&qYu&_Y&NuS_Ock|2e>Cw?Qevv*W%MbU>13RP8!ue=23wZ&o>bH%X>`u^y4@YM?0`jko(;WP1&Yy#(1(iJV} z4L`Vik&Z7^mqi1G=Rx*_-l778F)U34ygLbLgP+SQNQB9Ke&Y{tmEw=FLL>p>0k~B% zhhM*bxeI3iQcj>Qa)y}S(yBwDdT~sFd38fg&H3%}gJ?3Varc*Q!B2TbMfuJPd%m?b zLAK<+)3fU9iJ~pOCvJZT(Um>QE6OTsTU*3rH^Qb9@C#mdiS}0duuW-u_D!>=d)Q0Wovzs$@ex1RtUA(H{qcFXo~ z%0`&#zkR*l8x{A7Aymmw!+b@E1K@MFdvh{4F-*G_x1oz3W zwzG)nm*$qA7E?`$dqo3spdzuwz{DgM^`5ce#RJt>-fN=X58TFO-fdLj$>)s&a_X26 z4j_Zt04xxih%QtBz_Ui269f)GKB>&Zb>>6AW7_-yeU_vNAQ~za#EH7{ligk%pl_4I z^aU}~IfcLN|H5H%3A#(;-t2DMOYRC!BWq!#7v&MM7|Y)8=)mZHBO`-dF~Bw0H#K$%v8p9NX1akMxrO8NJNf z(JkXIcT2@OZlEqH>bAeOyEq64-9U*y0pA-Q9zF|fhM8i^rksFEd~ye<(uvYJ4fhxG z(Ajq*v#*E2^R{(9tLTP6V&aPyF$ExbKw?DEGw9FesY93(hNnTPVtCcMdt!!9lN7Ki0h9v<3^mqKk~3trOYiW)cRcMpT`n-+Lv zr!A%Q!}^ydBK39u=c1YRH)17xK|p>-oW6D#Gl}ipnb^ZwtIut-!4U!*086t12DmBG z9w2j|)$PcjV1k&G^!8@2!f>qYX^P`$MH4U%#*+*eG7;f^dOA zCW_7sIo#@4ko6zSS=?hp9wLB7bhiRX=@3qL0z3~Z)sH=v@_6Odt+lo=TQlj_&q;GB zR*_d|s`(Yn+F8&NkKca^)#s#&g{{qe7M(}H=G)uE@9~~HgA|MbG~;(Ok=c6Las(LX z6~MRxGU^gQML_18YGGR1tW5Es_6^G-C|}$4P?kinfhUyCvvoqg99#72M{RAw5_dk|H$<`r z%v+r?9sm`7xAV(8B1^!T!n*8iYCgWp>IM1ecXo)ja z+i;qI1i-l9;^CbIP~N>{V`Jmsh*1cRiN!}?og@MC#ql~b??a?E^{i;B5&tkmiVcfE zbZQJ_FP+bx0(i+kby3k<Ib(v#Ej9 zt(q^tR{&7>r4x~K2wD+L;jvDzoB`k)R2cz997;OkaX@->ZOae+44GdCGy#MKZ$98v zfA+Y(T=X6`;m}Nk`ea9~QcFQu`tJww2+*h*o>0UenG+(dpn!qoKZ31@Z;y>8b`Nmq zEe7^3F5KhNK-u+AS0AB*$_Ih!l`=&|fK!7)hxGw~^ZJ?*&zU^9elq$Z7SZ1YV+0n% z|B*+ZV)yFBGoY%!4rB;$J!5Ath*1!)Sp9uvg&%9^J&@S|SP4)X72@N%zh@0JeA^6Z zakCJK5l0vE_L)GCDPr*#xw1S4)O7_pb|OUn&FB$wyMp-02ZZXLi1>Pe1X1qyG=NQ? zF4YFI6tMnZAC2C&Q$I&eKu(g|5eNfvXn-NxL$DWWU;G~%_590qu6yx96J+!bC~A-& zAmxDFJmJgQGmt_*AV7vKQRM!Q3jlx@xD{lM`+uuLMGs*+zy+MC=K=V<6!*Qdx?0=$ zX$Zs(kTf~TvvYFr@bCaF>qe!rvOqC+hMn2Ktxii*HBem?c6wy8Oj>UdaS#A zGd-Lxgy=bd+T9W9h!gwAtDU-ab`fgPLl1&p{XW&-&+-{$!P5sK;})|{SDIqi{mQhs z{cdfy`u~-W0kp&@r3E%F53`V(-OpZ_ort%K-ZD4)mHmpRfZ9vM=Yy{9?SA!027sym zRt5Ts2vR;>B(~iJ0%=NY%F7UW=d-@fhxqzv`2Nw+!zP>GC(dL<)Bys$K?Ml&By+Of zu}rr<^O#Q@^ou_>s)%l!9UP7UXSrHT{jba?K0r4yZM_>g#5FYu|BVwWV#N81K!YI7 zIs3^0<$cxdM)rKYF+IJMRtp*5OU-FJ zWOoV$4#)vP(db{WfC(jS=ON*x3zg&urxkFU&;Q~e@tx}ScW4ASHfih+F~_c+#CvcJ zQ*>3n@I+O4mMSj0J=}aTNnvAj%btQdJnQtpwvtd+x!tbZx3P3BTI3V>IiI9)_CG7o zv-rzjgXzY4{DhO}@6-+uVwF(Z;5vl#;HwmaIdio zUnH%rtVg8s;dKyYj5}*A`{t&6r*tnf8dOkK{faIkt5Z7{{o=5Yo8J6qdBDt2M1W+A zjoM~yO|m1P0U|C@21FOnxHfl5H52Sq0(wGarpeb>1r*13AKM3c;Y|^Iw=d+|I8#{rJpA_GwvzV^*((z7UojL*-2`Bhx@)_SWAWqJ8@r;MKg9 z%2nG)b7=dcT56f%L`-X=N;;n7B{neg{k+ctFPhMst*sG}gw=a`=}X9~J)7S8kVsa_ zRm|3KN2Rn=B$3Vg!W8&30$EvEg|D8F|7~z)@ZNI=L=G}<>OrK?;kU3bARBz6xN)hT z(bWVjRimGtMn-C-j!U$EP}_?^Z^23`G^PT{=*<-6e${NhRJpEVXC{7lX5acC+{u$L zso1KORXtucB%PiAyjK>IBVhw@?u`9G`tedax&IF3LS}0)`5%3L_=^fH5fpHMh9MX?`S3_2KU;X>$rZ`fJJ70Eo0KHn;oR9xIdWA66{+q z7rftK(W_qy7_njV8e`V3-7LyQ4l~<`XgAWVSS+VU{cqk;fRn~69&i9$iUHw$PCSz) ztJcpIM7BWN1Kb_}I6xZQC9vP-(%EsQf64IG%~Yp+U36VX|<__Z%{0s#w^)8zi3uT$*ye2)u5;{pYrhyVh`4M>vNC50WMMJbY$O^ znc?kSEvbtr-|w0Hz8{-AwHjKMVzglY7kO_L6<5j)qY~1|lSvveArxqW(iD}p@iKFcyv3+H}a{s#Bzf`B1&kxq1a}4NUYgwa-Mqex@ z_8$mX2=rVPqKB=N`Uo;oeVtB98k&}1wS=jo6r=FG^r4R%YRRwj44u2!F?RH#u1YB?k213P-w%!bawb{<9d-bOsDY*qo0v-QDjWuh;OU zS%IqL#a{zhD!IA2+dDh0r@h#STqo_!mp$la1{23d+{B_BOU06A>k*aPy@Oa9i`g>N zWKv@-4*Uvd2DVud=OY}OTb)W=E~B%%Hd8o$nGug(-g`=?xd;K|>fm1}_w4zBEQd=+ zzXxY`iM{ed1%`=f2Q{*y_CHT)Y6wb>s_tJqbEFz~j-}-#C#Z($ZWoFb>i+C0bm)rq z8-FrKqmkp_T#~XJoGvDTj*7IxmfF_8x1n+8iGO}#ZM@Xs8DfOA`!dI4ahlBQTiMco0@Z#XW{B9VU?pWLGE}2 znra95TQ4J{$y~{S+kWTc2>j&KRF_Pe^sWfLr^CaIEtZW*jr!dnWGWx9(U3k{IBm{f z>#cnI$X^4WUy?yCcEFQ;DM@d#aQ{YvO=f&-W{zCBWF(~aj|Vs$KA>()agOf6oKwE) z{HMqF__ks7EQOGv))>-@VWd(mU8W=pHSxSY16!(#yLY@xOzk;Fd!L)T%qmpfEK)LQ{{5AuNzP>gu4$plhZVxZ3FoAQz0AM6yeq*X*MD0lA+|%x*e7Y#k9p$X5RY3CnRNb8(7_+ zcW)62KTm(xf0qp;pukv;ELCbpI?~&G$+7Z*9jLD3n`-&D*Phf-A7)wt0|NR1Wa6U7 za1o`9OJl`WIA>U0EqUE~b(KQ9yt(@q>?O`V^UUmZgDYY9nCdfrNKfizoUwD|VvQjt zH>b0@B8g1wey+u#JvTyU4EPh1T|?~r1J3TXecq0;^78CH?x>+Z>?e#g8+2EC$!~ZOBZcnH%~!Yow&8yXUBxE^q{bZ$A_nTwtnW`C=656ae-x z&7dNGBfO3%7CW*lt1<){W#X(5OI|x-b5FI3aOY5+CNB@APe>?IT*iL6A2hn)N}SD7 zLOprz_{WcJ-v9Z4G_=F6Ja*vak97cz?rlg1A!iL(A? zGHq2`6eL*rJ8$)JT17tDO-(6&>Up8fS#t+nh^yYuo=K8Ub6U<(5PpHT3K8RYL?-5o zbH)8A!6)2OGu+t$>5By{ZX`$%wAbs7%9dSo@-Icb&71<17pF{XF7B(<8T!;M zys|W_$@zqri9)W~7p0h2eoAYpDMurXT_2Wttc!ZzICpVUmpD|wNX#be9AF#?K8~6x zXdDEQsN-TbUBS5t!P}t}TAZY2m#t+>&Y5E(seQ$W3y7jHP=}I|y{YvCVXr^ffkl%e zzmOd9xJ7bFBbI61IwVQ+z2b>ESiJmKqb_HainULyt^zR0DxD`pVqjnZWfcJFrzf&S z&}lVY07NLtl>a=;$*G?CxLREm6-kJ$p9-&}V`83_Ydh#pUB8X30>HE{{q*277Xu{q z`^W4+b#rs}k9?<;uNwJEUSAb+k;B}z9_4t&>cjs$Ijvt*>Q2Z{0 zvqm%%iTf6w%82@^)n6K`1Vq`G&tzMpBI7b+qb6ScFhT^?mFN6X%)|^)1HlN;mavy{F zB4~Q)!`MhgYM;O>9;*M0m~6Bpz1wv`DKBsZt$MWy0LTIOI_!I2P>NLc#EnPmON2Ev zsL>|y0(h3e;>oDALcKA36lqg2v&?#d=d6Q~2Rs7B7_|SF?s+pvLLM5b;W!Ros+sB* z!_oaf%xsN}mq>5B*CoGp0uR$i%$8^075-s@Wg&~P_XQE);bmjp{Yx^Mw?E- z8$vz8&9&ej#>@!#OlSpCxw4QWll+AM1(ZS73%6wJP#88*n~mn-emc_T`Com#m6PKk zgogY-BwaG_o2~{xu5z}vnbOS#mfliY(64WBzWiJKJ_m+?KN0WKX#~LVQ#Uf&A&TJQ z&$zn2#^>>(m2O66$9 z#(#PDws?Ykba0T4LaO>U2XA&ili`PWO1rA+7$%u{jd-pa$w5`OUWFphLamVbiyak1 z!aPE0lw$FnP^B5;rHc?dHmSp3w_6_vjY4{x>u z)6bPXe}u`a!hlaVCq`lItIjhfq0Bc?vIb@u_e&Y!@MgQtl;))lF{R)M+iyRZ$?T*P zKV;{(DXb*~d6WBDtSDj4K}4?`BnpJ~&y@~@8kHt&F@D@CecX?dv9dKR~_ zu>nZ)CQLTlzrU?panjJ$%@im3D8~UH6VIo-PtZxq&1+;zG{C|XFw1%k?YZxSngN;k z9~QvI-r4`NX^m7d>X&PvXqnQd3=s@8WvYF1u<1U2+lN|k0Ip}A+LNH#=*$Ipzyux> z2Dc=whNb36h_W=hXN%xWNX<0!Jyr6@w91v~g>?dEJ2jh*_HyReYG01`_P`~Dm%Do| z68ibTZh)d@Az~Re9FvP$(MuX572A3>AUKoDD$@AvcF=|h_b(pV+0;B$>Fi#`_8i10 zU}Axe6r^3wHCaH$$fx1yiTJu67Km?=2s~~h&h#=3yM>^#>J8zwre`7V$+FKtW94Pg z&sIa>;Co)kX=&j~vEsRIMal3!PFvMoUtAEAl0E@aD8jjD)u?!#WMLPPY%v!~B^qs| z#zcIRrKTytoJEJQrWnVw1^*((PW|fWR5G1m%%lZNS4abv@%|l|G9=F_|2Enn$d!ad z6R;(7k15gGJ0`3t&!Lw;5Qqo5W2R<~la}f^3Yr)h^rlq732f%!7r=f}JyZh$iBZAO&!$j!7K*TH&5*9}CcM4t+R0#{) zJ%$!59{?zf_f=H}#pmFFfB@cyb;OIFA3uJ0Kka&x&b@|btK~H&mJmNvti4j!&u1c^ zF-RyKo4cWLr!wc@l6U<i0(J-Y}F;%FKVTX7$W(4D48slMZP0lG^%u zRj^|8fgrJp8FG&}rfsDskJ8;)vxh)Io}c7%8dI?8tHr5kLk3{{X7&Mi z+@_Jie75+7bjlhj#(<_c z<_ib{D<~pjj{_{2Ug0r?0NuazgOYc^wP=1{0(c8>Iyarb=DYW(3-qw?+^i;<6loL2 z0J-K%SS`OGtLe_Enu(r2*4U&UVgNW7}&OK;&SA{s?$z-R2AzS1EH6%0icdbfegaGkN*$B^uYZ58~@LHvjQCe zdZ>Kqv=>CxMnCay7sBVo>a{)gH~&Ac11kfQtbY~OtN+*TN6m!itrXz&9TO-z)y0DRrd zw3*%(_)bvxk9{>uT~zTy=*?-SK&1V=g_qJvrNnN~+F?0+)?-4F^f2*WY*zSHGM3@O zUFuLS>AyRAMK6^{^PA1{j``6QXx;(Bz!YH&2n2$jB2Swz<+GU`!IRwT<68xqnXdBS zZ(Ox523J$NUQBQyjWF&#do5M8gh?HOc%$sqSDYtF6?Z>{^l$Trj2mn|HB9@RV)m@r z*6VQa*NLdLQilBlae-%kC}oe2GYyUJ@r@aa4#`H6DP2FM$V^gNjz6BStzD+rqAzOE z$dX`%iC5#AKbJm?tEIr8XG1W+#b&AISnDd{vW-`o|e_te zBm!al=n6z*^{2aeh(qhuqB)?f@z&4)5-43wO-&bZ4){mcCCe#hpO7`%VZHMyZd>(z z^Ahypk#_Hj#bGlre;np!5f0)@tGAP6fwTJu^ zV8EnWWx9I=urDvaD&ttN8r)VV5}ez7t@JLG#)iZE-47f(X^rN>SPW=IHxl0Q-B_^V zNBRGP+sE8=@l7~D6HB&je!4r*xgAuIS5O#BW!DF#EPT4N;^*(2%#}C>%nVS-q#sTr zygvsCI=C^?V5}5Qc7C%=aj(}FmL-m(>mQP@cBs@V(JqU=!?noIZK+a7PE0T+zmMqY zvOf!tA3T)O6kb5-QBOLlw!OD6AzBYi{E6YIty>=)&@{3#z`?9Gq_XE1&f?*DzYyi) zyIb6ARK(2U3sa|yxkyo6Vu50t+mER#uj{ghZ}9 z1OVck?=EIkbRN6#pMFh2ynuExK>B&w)Ua-;alf{@6?MDJQACqTTWn#)Uc^O)}M>6v@MIIF9pA zRPyJQ$fD0tDpxh|C;u|%SFcLrAb)&`*ohx*sDPP#zW07U1`Jxm!@_cTGhBBt?_fP2 zJD~B2=U(G)S=2HKHWU=dmi-!gw5k(LVQA(3m~PL(PB=qOX8pA@=2w;LuDzvvW{`NK z{k_~uVA*)K#x^XMMY`p(}$t3wDetzw9em@>N7%t98r`ki}X^>u{+%0Y2jHBwV@ zWFv#YCvq@a;)s_CEpO;%8*gM0y!P{~6jQSRX#!I83y#4in#(X{*8trayV8ZIcFE^RfF<^Kd`YZjbk@;X1Kz0W z%F1YXH2%66$tEx9olaa5K{a;rDD-(O4?Q)wHr&X_Ccg5E*1%i zDwH*Ujh@R2Tr^X?T7umP#{a#o<_3Wce0MaBD6>QF8DMWz*VaBQ0lC5p%&|77N)?IN%E zZ-VBgFY1!VO_&!J7PjtS!{j>cXCIZ^m;6VCP(x#N1^GlYrdD}2c)M(^0(mJ<;hrC7 zwKv7$Yg~WGTk1jvLvL)(zQhdOh0TygdI<}&>`6`c z%3-yl?u1|{{bT?8x9v5|maqGO`-Zxk+P0$sdJP#*kPKRe?e_Ne{);JrLdj{9SosRY zY#FI)u_Phag+)Pm#ZDId3XZfWvFgTRDCg7Tr(2n3#ul5NVCuuf4ux(z&nr82x&29p z!hv2zBor`zE=w=_pP;;J=Z|ij7GzYm-$q*;&8JHdDKjbB?hRkL zkKoJFwu#Lgmr3sDG3^!(Ukl@6(_6327pz=(M8$JHNG>>4Id?k9?Pt-%nadLnomB>g zwJ;1MxmkYvw|iljzfgJym;u*4Z`ZS}0kC9JZ0yDJqxUoZ>-u$o3`$A5vqLTE-m+?w z(5}J>=)T_~vUGFTmUx&h_lJaXu=mPe%{T_;(Hnhw5lvZEaN^zWq+2<9ZChuEA6c2~ z*)5DXhOHpi7>v_W^QSd5dJN@d#h#S4JC^_X4j4{JS>w$|GKOh=2AUbmTmQ3rs0tcH z^g8kF=;(;xx!aqJ83D3!Ce$Jzsr^FW$JADTl!-2)Bd+fGyFE{;43`}KV;kcZM}13i zBd6(MtyIIlE!dA3L;<3;8ZvJy)3&_-9~NLZsJ%r@49fi`5ZrMVT9N$jCwDtBy5(Xm zSd=%9@aM{;&Ajx+B&JH>0*?9*gMv8=BM~#tZb*pot{IZ&-zBgdo!5XEKK}{Go+-*& ziZ~VPez-LKdEJQtQnDHkw1#2W-}>yhr>kifo4bTJ$QJNNSYO0vfO${94+=3eh~$bk zBrQa}F*7xQ#OXXd_z+r4J3O-i=CSSG=2Ey;8ad&1x;CG2b1!<{cP8*Xq&O_XyNaPX za^68evaTXQOk7#wP0LD4w1?-5X$>%ZG^Q_{8DU?smSwK49N@W4lmh*{xDT%dqOX64 z8zV%we{-(WYPo7p(O~lT>oQzuJxX!BVmMFI(KPn5{qrMQ8Q;YoZmRtBdvN&p`MjY9 zNR)c#sQzI_NG!gwH@x1k;k}rORyO-6s<^mjFM81kvFl-}{M?k`PA-eJ%1$qQMN`|Y zPtrp9!B=pj{^r{VjDK0>awSkXy;2|z(j`WAtmLuB@vKzmC!S7(nJMyKx_!b2kw{<& zY-d=$J;qlpiSI_6aaiOqKby&f$(9E(BYylizv@9PW|uV6h+tJ#P(_AnxfqFwjVhG& zbIZ>D?_+4EdH+J<7<~8U$|_`m1*H4oY!K28r5}zZl6>z#h$k}px77hU+gCOuZ&urd)?gzC zR%0UTi8DJz8r@*yX!V$Y&Se>>0495EO|})JfLjViLM@V(77xPJqcN%)ZxCr0vi~&L ziSW;x^404X>ImvjjYc<=3U_~T?3KX%(AA5qEXhpHa7lqs4koL~558iQ^X1^5klzTn z94-w#h+GZDi_{$pIc zdMP)9h8~7{jfYF4cE7j(k}#3iV@OF7E!E{wt>dPx@>o)!&^P{Q(_4AZY?#@XfUPG6 z2&igUhe($2T?(YNv>TF5=bIO}ykJY`mWL*Awjh1i#52W4o#tQu&h#swX!=G;EGce8 z3OFUa9Dw(C8_tl8sGo#l)gltwM?l90cU@I7k^wKKZ^2@GBKP)YAMW z*%Ic{GN(t{;u}S+*Kbw2RPopYfM^6Q0IfhwzBQd78l!8`&Qa1%{;PGQ0UD}Fwy42= z7pfuZPU3?WowX9H$9-lnI;#pz%sWwQtp(Nteg)0aAO9E>%iTt407?2)yHM)OhDaK) z9164yn_bWII4&a}e_w^6&h%>Qa>ZgC{7$TRR`Ed+$P_wiu3ez|PmbqXfIgMu zS2~t8-H?kN&{(+t=Jk)-ms%d!n1D!12*AQYf6++S#?%A5;%DHEI#71f~<~ zTBtd7p_{efo3I<36SNEG8XkF*bWgz;rE@Lw@D??Di8A}I+PA)By zii3$_n2vVAZhwjeuDaZvQnb)ZDM@p`Ir6zEb4Xo=3(y8cjgx5}XZl{{XbK<;h<<60 z%E>xoanKKFbV{%+ZE{FO&ZV8NEKyY5i3CEou&8KpYMzE#S4Es-bFsyhNTqsFLkX9B zv9^SnNqT-s0F|UErncouai^cueg)ng0iBvgG^k-ZnOGnm7yEFyNF$*)3Jw8fX@wJm zT@rYacnz<=DBV^flh}>cSC0xul*Go4)w9FSlASGOIi7*jOtUqOr?q?>-!Bo0R?C_&V21ELje=kyxQZxi*@ehZJHPj&cXhi79j0f z;o}6%}ok3N9c64RL+LOml?i?3(gnA)%F1D{!DAuchmb(Be9?EudH`1 z@mD39sI0oauf>}|u6Ujf4+SLPE&$DHrbgSr$lxhKzkd@&P%tuxm)>AkFW%CVl9_!a z$Z<()J&`y#NU6^ee_4AXQ`x;qPuZGsvQJDjfhw|9cPcry;AFOhlkIbTmM7W2dBpiU zj}rOV*9FJ@^_+QkhffkRm^~&67jlc^`qnB+`t4sOM$i@4BI(VT5i-)o?0Nw)Z6l8TGHq&g@N=X{F(*J19zO=I9Cd zuz;@iGuC1;`?`BRm4(obWhy>xAQDCTk%UutE*sE- zR`XfXGFg+RuINGF1t}d|XlSjx;1$9mqzwGIi=Qz zs6Wl)xZby^zo{yzN&a|(zRMgAK|KQRdb#1GwTYybw-$bQW+kOCnaN1a_ZYtpK{My) z=a1=^w>!&K7hWR()s^)}gT0_s#B&TONF1AP=?42sKBl+eGHHLD$i2&|$?dYXrs1w2 zLRhJ{fRdT20IAWF|eF_k0OrG_z#3_0^2y*p-dMm@D}>X05opPxK>Z3u1okRE~{47{mmU4ukg z3Nq2O6a})8g0*|e7mrV^&yT(hWv!a+4u>~8y|OVl>n5TYmUhuwSu=RVlJfF)sj^7= z{Mg9^!1nos4j%2iT!hdjh6T=uNo*e3sv$Tp9MUFGedAf~>Fz}m(>ywlTM{<2`A9}8 zV9~V`9I$FN+vE1uH#*o+D+#-nmMA*sp$}9t5nyJ6pJ&G_we)irJ& zw(Fx?=TXc$&yFh1uf!=#fk6>zzJ4^ zr0TSyW0@xnNGNHYr$k3SlokzHI5{@G`OfCJlbkygAX0djm7{b*v9ivRZZ>FVuw(VXZ!s?GEdYl5^+`OEnkGdqrC_!oaK!e1CESnx#9l+o9<&1&f6S|hzWikA##WEW#tviwv3@TVC%fJFg%%aVuT2nzh##tFRsHQ%TdV`Z`|6zZFNRU z^&N+*LFqN#oXuHi*NGZ7Gt0Kp06kBoSn#ucK>)Ok_$hN zK)iF#TFw(P+zZUF8-a8nz_Ki!ENKYAZmf;NXgtD}g-sJWgLX+56IamCgDm?ypL?0% zInkeRw#NWV4g0RwK121{IUTeU`R`~OpAv`jwJq_Q*g0DpCbXXxYlCKR_ZIPkiql?v z+C!wI7qza&kH%y8NGTK(1WZl{i@9fqoG`+mvG@1#wyjh(FtOriQgR{|SIYIXSCCbn z_T!h=ZB_ng?R*tCb(8Bb&n#tYj?c$;D_BIW%d6b!JQ2hgb&9AkF_kMQ3d>v-m_aSI z!C^KUUV`g3w3?5+9S`T;$)cgqPnbEmsjFL3xFDu&D5(C4S1~+Hn?NikF^oMoV;Ik) zM30>H(KV=UX&CjeP}2y5@T!lhQU5J%RfURHIS0icqb+vXxBs3-#X&M!YE9BA+s#os zJO8NX$x)MFRwM7O_D=ob4YbI0vkNn`|5oX;wdZ3{(p>F{qA9J}l4(c}GK?4iPm+O{H zRQAL15K z%iy7lZ5-37SIE-BreE58XJ_Q^4b4ElDX+@izWHTh#nlD>aC&Y1v-RnV+ixIHxNetL zRd=qAF`oLmbd&*__0F^F@$+Myb>VrRY1bvJpB)`aOl*ULBz$l+yZtGek!46_L&{4{*+ zhfk3?z12u<{$xXArM4raG=Dli>;MZMJAKg5k3nx?JxY=vuF5&lU2A29#MQ!!&q~rc zjr;soOGuxy^Cy$VZP$GqgB(Ba=Rh%Q7dQlEu2$Cw|AHBVaqLfKG8SHW<_oj=jBj^* zoi39_lXUhJb2?M2*K6S2hIWpv*K|u=%`Cdt56uct_9YbCTudP6?=wXrw+wWLD?T^ zCSLyy} zD;#EN)%JB@#M9I7Kd230ll(U)w`?-~iwPxJOGYKF&(e1n`DM?C@Iw@KsC!yoViw{? zJ9{>S+IF&fI7~u{;*cW!y9FFbqaD8xbY{v}7K2QUxwd;*yL?UQaITIey^4psyQb;3 zIkxaF7Vh)bSyr`Bob|3X38Pzd6 zLA_ZHKMbtJ5|xyn=oh7m63Ef6byBDoP?+oJa17koJEovzVsh@+-mPpBXSKw@TW}t{ z=Mnr+X!kPv?KMsU@1Rg6g)?0-W@~1X&Z&VT%^cbA%qs83g zPb!V<`*gFp2ir9PjyL*}CBxA|#pbtfkXfxUbkl6*UA$s)D);u(=Lb<2dXCFP5YB6r zgxsP!b9(y9HwmuS;T-y9E31Z(u*tI02LV?;`S!_r{rhmoXU$A) zVv^z=$u?;i$>+tGD=ba^c;f-3UnQSxw$^Jj8k?xE2(qq|omHA(op9;roh`IgjBI-v zoQ@IpX7&@wgT`A#3%bVFpLpt5X{|#$delwC`4Sdc_OjY2?Z1c@u*anHBkoNDa{9k) zU`^<$7?gMH=3PfH;D zwY)|UG7YKa$`DMP-+HJ?>3j@z8L?ez684hXUp^~AQD(0BAMUYQe7;-O>c>b)f73c+ zD-BWsx$fD}w%dMZcZTdteI{aa@pyjX9aFn2f%dQ7{4=n#R&4{PQe1B>IfiAt=B+>0 z5%XQ5SjNe@Bbf^x8KHhhI*kJr1x3J0_&eH>0O$c-{U|XYP=EK@ z{PQzqk)q)yuF%nBn{jtQ4&nkSqk#trqUoJ`gLj7USbI|Gcy7vch+o+dEyy!_1JK}* z5f1apFNJ@-3ZDb>WB`?2&H*g(fgd&E_)Dwn224Hvjc)<}A2`X{=w13QjOOTwd+f zHD73T8+s9IDJhjfA&^wLmq{-pyJQ8736zMQod$X`g=~^k}ck$y0wU-C3Q@i5C?cX)^F-rS~%s=vJz`s zFmUabYi-Gf^vJvNXKiHJBUI0%bT4eqp)WAi=7Q;LUBi_@1N%5Uw{it)#JfLZR1=tYwTXvkadP+)uYaZKyS=ujd@4>T?4gQBYW|x*?274n}>0o zssZpI7EeEVUz1vVj5!KwjOQBxkp=IljisEvB>Zv=_Z&>MMmXuuUhRvJsQmpsUJz-n6ie=E;o;9Sknk-!{;wg{L~= z36GkA=xpP2-o5zj`zQ!iCksSaS~u7G2UV&M$sbpy5r69~k579|X`aHHEyVC{=)PopLeiQ!8wRcspOtfcOI|t^gY6qa$;^c>+ zBiJhmwx3p>>v}VBN0*j?QkE7zXZhgUHTbTiF3FWOG|y&06l+ND)UYf#yRKW z8Y#54Jw_EDzPhOq$@-ie^t?P9FfX%zeqSeYiPv{cW+zXuz66_iDoIeJqPFh$eCqrB{fnt z(eYQM#W)0{n#NN+yj38PpA;?;OEeY%8TR2zo^CFxw#s5pH;nYW--=D`ass?DmtQ>| zM)Rb^FvNbQlNDz#ih4ThB028Q*X|YNb~jJSsh*}uwt3f=igCfDt}(-ZhBtHew(shQ ziT!pW=)#}M{5hKGP+EJp+4LI{fAxuwM9K**Ur(&(9*osNfB=dAt4}n^-TYsC`n92#g#Nc zK0-SnmTH!><(yvbqUfroKiGcZ@aNklW>8npfawAr!-##jY8sEQM{oTzYKUZ}+85)4 zlzY^|cMU(}%$Q=Caek=<#uzK|S0s9srj7PP53~OMjY0DG=@zFiITVD?4dY2!5-m1u zj#Gk`+wlt-q3T)Co@qd%jS#ufmYZgTY*O5@a2(mb|c`tbbt$<+L2FNGDqcm8UG`kv$P-OzWzNmVLI&>nA8{eDv0Jkm-c(h57QC-GgJaEkbp?z-jL#gkMfnT{n& z5g_bw6LwahHef^-bmB>t#x*^*J(JIMnm`s%zd1&Hv=?ZjW;ZV=St7iPGB|Vtxn=&}H#%os!2l70nS9a*<#&FfEchVw$xT

Zs>5$+*=wn8QOk5AV^bDQ-IC8LLJYE%Y>tF(kK3wL;N z9Mw4;xh=)#E)ByH%g_0x{j)l<8suctX3>rD7dWVfXKUzvwAeZcE!pX~bmWM;vuK0`QxdhQjtPb< zPx*si%h-b#7eV=J$C=j*xMMY9LZMbdwrFdnl^hLTZiq8$)fq}=3M`Av0Y?TJRbr_^ z){H|r6QQcq5`Y^$v$~n3d(Wn7b1K@ii%7^4-6#c4xZKZ(9eOX<(=)C~O|^pyod>Mt z-MtFiLF)ByQfIU>Lz(;o6a;dMQw#c+O!C58C!#1-iBzpQ=tY&vsK{`;kZdURczX_< z+8sCSV;c@pd&`W(fqq1lhIOESQmw;tL`NVMlx$a9uyC) z?F=P%SK55j+`bxd>}FzhvtH@Q=B8M)ct#aJ??5`ds#y^>cZm3G-h`(vns>g(Dby5k zxYr)U)(q^_f2JH(R6uX_-AY2gN+Bg-u;!?aTt&)`inm&#x*K&%wI~if`VATSbU@ap zyCymX)9YE)Zh77jgfo;LgzTUyVbXIOL88{mx3v0->S@xT#yaV|n2e)Cq&{<{?tW6V zGOaD^5-VN1iy*5Mc^%D4y^HGI6|&@g?geu)54CX}J4<6TNFj5DWKF%xj9%S#oTq2N z25J!F-HfeZX6$_lT6A2#SSZ@He?(i;m{C>YU2ZZn^nt9IqI`bM*vM@-Glx)ASr~Y^ zA>Xv)$*As$GgnVPhLeR%#;T-jw-R5A4#dKKQN6|87-dz|lRZ4BGWo7|`pDp-Tel_v zF;SIL@nGDDZp>_V&!_@T0$+qT69L+~c$YdWivIgGCkJN(lxv963qK#(a5u=G5}NSN zua0G|nw`m{ogwaVko+m!t2%iBLvJPv2TL6Wdco)rqF?f;R|4WoicgE9teUCvyz5jd zDlK>Wu4=n*98ceN9?I<46ATlQUln%SxbCW^e!8f)k~5G>ZQJ;i=O@$J+v?Z6olquB z=$9Kek_w_CO1nr>zh?iLMJ*!qY&U&e%uXQ1LOtoqp<(WH^*KJyAe1&V6LW1)P-%+6 zRN0I?WoI6Q#c?YpYf*(`Z&sFthx8(>cwvs99-CNK3{=^fbf^@^Y5Wi`3D*udLkJYZ z0^d``m|w7iVFQlM!L<+x|Ku$qc+k%X`+EqtWRiG7-Up?geDq)`k-M?Tr%^8@+Z*X5 z8$?O0R!zrhMP)4ZfK`dlNY0kIEDmp@h)hX%VRgFB!u4xLidAW|{iQ(#(`w0ASSzWb znFb$PD)L9Dw2P5tblvw#o_5?o`i8aTlfTBfdb|9^*SK0FRHpVMX=wEoc+gR*vdoh+XSEBaldj%C}%IY?^iiD#BA7!ykr`~$pjmlSGFASp@t-lw zxdQ~RDe*DcA8`fRy7W#>2wA%yL#NObAr6`PU7SlM%%8z(zYokgH-62{!$!r#_nVpu zU=4n)_u|DLH0xv!nliQL;2s&_Y0*_`;2Y))Wf?JW70XHTA1j$aA`wXiMLB(^jgq4j zVCi%PcTH1BPeV!zwcR4Mn^lgSZ}=k~@$v)zc#LMLE7mgxqLJNmmN1WkS64}qC%?4S zOwLln;_?Znw3+$O?i$EkjAP%*`%jx%I2Bf7byj-jG^`>Slt{a(#}jax7tUSj2^-UY zKnzB?^bBJWHq_2swcgmHx!YhUk=!FAw*4Mnftg}i*laRaXRK6*eiM&KDIWxK?XZ*%yHK3C(LwBAv0Yp*kbVkf@DV9 zfv^F~L5q*+Cl3gltHL3cKG}vklZu;TgF1pah3f;wHTP)-i;}xn<9c4o8t{+sNGX%D z{D@iiKW2i+S_(7QI1+BgPcDGn6bHNE_Ac!5@=^ToP43#Fy18b#U2bRhpgcS#v-JjU z-Ym)^rUDF`OPfw|MWx?r+AfN?OY*9!Di4cBSui?!LbyanJ<5o1gi+5MC``85#Q~}s zt69oFDQir@3-n-l z%sXl+$2J#4?v=z)>bPl{R0PCFJK5kBKq`%p!4pNA9?UGzDm~Si^x3t?jiTw<-`ji_ z)ec;6n_D84x>V9ng${eV)wtQfqd(y5+hf&ZYSp8;e`DGoTF@fUHtauM_W#EIOX=*+YtR#@$H)UR;-Z!~!TPN9p! zI_Hk8P$hp0nu4*cCH&l+|}R5iFo3qG!42w6fNh8IKfc19i*C{a|Hh z&yxS7E>BXp&N;#;RDc%NTLK<&o7c2oU?$$Q^`H1~{RWs0V1WWCBO=U)FIRq#GwX1;sKRCRN6OjV;u9Vaw1$8Qy97ZX)wX%VcRPU)1G z96=tEK!ush#`4`1zpjNa`O}B>k2KOh)2Qpd@X|#^k)V}rn5EE!Lv=Ym8J-4ZF77rr zm6Wg~MrG?RTh@@p!YPM2*P^^X2#Y43sn z-;9$cf2SwHh};^5W^zuYddxre?msniS-9?UzORm!fouEWSc)rbH&$%@K~z-o&RXQA zvn*B3qEyRuhg+l<>x9#=1tnGvw5m$cWFA?2QU}O+xwU3ZVhJjV3269@3MEyucC*u5 zzS7gij_LGDmCV9fB$RD@>qgs2dK^Ws#2ETWbkN8w-K=HuTFzXNc*O* zw1$cEASRRZlQ^YbJ#{lhjc{drhP= zn~G++Zl3jk)hr$-!6cpZ^rSx0p%F%#aK6I3B}vby8iqxo!It}4Yyo$y&--RU7(zMV zAk`!#$+xmzh4&=#^xYlm>>J0<_B^$H`)E*}^0z`}@Cp@*(anF4k64XBg$lDQlZVr8Sz#8<||9P#HI_b z7YQ?S{S4~4!!pm^)ctf0t%?%*laoECKLITt?VJCjy|0XFtNYh%-wG|-mg3ezaVzfd z7B3QtLy)w%26w9g#Y-W$ltKtWizIj`Zp9q}6nBEV%;CNF&WD*bcjnHTHJ|=`U}Z(l z*=Ng7p5NZjbK~(pyczK<(JdU2?~34ugIWz~Y3olse+I%$rF~0VB_=nbppLKCckU&9 zQFFZ(9BxS-TH&1ob9ZgFWVE%R?@?4+5!9rEO0Vaq$QQ*nuDXi2E8cEsFPc>4vOC7? zh}W+)NXlp~o7<}w6*~EJp0&;yZJ7mQs^o3ADqMV{UTNg7r;>d(AmW3CIow!;O&(G#Gta)jCmLS;S+B~Mgu*A++1&eAMM;8X zMgE&Pg&X=T1nXftTCgC7l0H57* z5Xk5-5HyUk&up$LK}RK!*nSw^BAhgR3;*HVN{HwmWY+NnSDUE?-ERCI<5(!oXo?Y* zaWQVh4(QEx+3M$-{!O8LQOn+L{Oz|0Nb2z+OpgoNFw zCctigF_}8ZUC2K`C<^#!WlJxuhkdaKi{rHyHhIb@oX#KtSFp~3>*?0 z7M!woc(wWD6Mx4&g;;hmbSau!9Pws$Lav2TW~!=u$VBT~93}L<1#-6X`fg^PhPgwO z?o_DLvj|eMi4Ggu$u8Q1=zy_|8aw=zniZjFCvo)tZlwpWbwXolY5@CQ?oJ^|2 zS+I4G&7Y~6x-KuRt*?ZcPybuWMMsDmasUJ60iVC*H4f7IMoM2Xj*uq|G<+wHf&7k4*6Vut<5~M@JG~Jkh;?eVZ)+Bz zm0%-IH#n?-Z;SXDp*6jy>ysCh{CU>v5?X1H@+E6xs)TT`Q zX&fl4(xw#W_`aBONsUdb1u1)tiS@ZpS+1!y7)y70#)MHI+s`x_IDZv#l z3SRb=mK1B2@&}cXKS-?pN*YEr9X?m7u|(L8Ka z$9yFIg-V$DH*PY4hg3eN;F!{hEEAB^%)=rk#aU}v?#6&%L|v5db5Iz3_oaoQ9asWSMR8+p zXzwFZI|WvfO=I?0Sbo){MF3l?oWpcGsNqu_2N`2lxIYP^leg1M2OMkJfDC1p7dp%k zZIMlU6xla8$^>thP`A;*RRC6V)I||E#Wy$p;mt={?h2FfsAp0;v`iJ|^V3p_2`|`4 zpFYfJTc?IT6u^Y3tU~P>x!sKzm43iSvU_;-?r99`(QA^2ii^RK3f(i8qu&iU$j+1= z#aw@hXyrFtq?eN7v|F{+tTAP9Sl@g+RK#n@J8fig_{~k>b*Xel!a2oKBKH_bI!EnC z|K^7kg9Xm0HhMh6pLVgrHnv#YVsnFAsj$#1>b3i!dsv4PUHgBK%3+EW8M)t>W*5jaL9C*&4nl}Y*jmjyH z7B4SzmnWs!iL4~m?B2OxqV0=p9kgKNb{)ee{r0>}thx0tJ(`jMTcAwOF+_Oa=W%Edf|><&bRV0&1pByERTsRP;P*?wmhW6H}p#jkVvL3`*rE8oDs2Y3{d6gujb#?Xwh2-k?`N=oB$KvGC?;g2^~vyY7|x4geg@x3QJc7fp8=pY0}Yv($-0NlQ~is`&DkN^QFPl`0yx{V9>hWD zDN)1V|5BNhc6U5mslkJbK9+@3ZW3MD#W=c1H4isN^nV`gOGzo(Tk?&B1C+P_$ik^u z;KP1P<+@^yk8Q93$4;FNL-X%?Kj)87<)*)3P%-;&5FG26}}mJFg0%(W9^c zVUh-$-!$4&84r(ly)!deE#}`aNgv;OCyV0DQHj#BJ3Ma|YCP@qwRqI#wB;g7#fs31 z4w|sz2N!awc9T!sC!6z^tpAIIL+dc7yxLU&q1?^MVf-B&57DAz=N+6H5o-FvWg8ny z&Q++)CK67zYgVZ~PPsL&E1o6tDRKO+*vi3vq%^N_*e8Yuxyvw%^T>0q$Tw~hn*L2c zx_E~Lw#|*Tolk`K60g|x*`DZ>E|#s5iPhs%s54tUVkf860(e2(kW96_R)gUT>ZIWs zFR-^GAXq1+*cpY8g0B&%5LslNC$2Z0AL;$-Id>lZ@gj;tL!F?6SJ!llNHsIgBV5N& zE338da{xXp>?0_mwWP-kBv{FBsOnmEw5*4C&Z|1^nI)xMu9RBj22M)T4gS1PEu2DP zau;WA6r3SLuvXHJoWh0NplCl&za!tk2&B}$yBaQ{Vf!`-iNz2u9Y$(t=q}YP zyLQDUu!=c3vK#FB!mP3t+WPA_JA7;wSrQemzTkZd%zRU!5~XMPG+}*;#t^BNHNate z0ZT_zR2e<>;be5Admy#Y(xnjGeHd1z!(3iDYQ=Xd*lNH3W6C~-D(m*0>L;=*nNlc} zv8jz9fp29Ex-+wsUyJo`t`y9crlmy=T{$6}U@s`cN@fB%*oy&W5w&%cz>W>3xCMW` zQKxkaUFLFhluu3(WqCnxKY&(AM-`u_7Y(j_GZm86uF?F!{5aC7Yfc~e6yL>^-e_u~ z9a;RHI5g7gwqRepN;{O8!rj~3F(35mld>W^j4$%H2=@197DhUdA=-HHJ8Lk%uKmQA zjmlH}^Cs>5@KZvDM%Nh;V^6d(^093FUS7p2T_D3qJ7+)Y_&4u}J9Sf`f^<;xOI@ zNT65dVN1sYnMOW!9h;Y%(#_!3z#mWZw+sysat>uYDsd1Ocmjd(RjtC8J0Gnb$VW3% zq`(8eog9V0%_d*_*+m=rGr|-BXV(dArfiE^KwGterQ#eePzye;6VoBwIRArFX zR6L4Ur8_W}B1V^6mB{e&!ZexL;G`v6^0L>yH9F}XTVC$b0p=W20*d|C_D+uPmb&;v z)_@Z1nJ^14WbU9_Rw`gnVvx=(J;7c?Olv=)+g;Yye3LJh^C@FP&mfbQVr6AUf8j6( zj00)#z$0b)Y{?c})ZBGOVY!ymYp$M9AuBaCoNN5;xxON21#&``nK)T=%gkJpAAI?O z$-Q5pQoO>)@Jcx0-5E_PDEpASc)xwGU|i}Cv=3_yXq@9tax z1QQg#4_3$tT5lVg^?E%C&K7ycF?Z#2GRo9$L7~^!(+k}ifr?%86?z#A9#riBV?_B55{l}58kmQpZc`Ku zBKQJ&4(Fgm#}b0#SyQifPXZ*~m7NGN$(=+7bPvp6J1vTcoZgF?W@Z)6K)T~UhkC}g zYD^vV6XsP`ycWF`)Yd4%Uo zpMwAJ`P8_DclgTM62@pXc6Xl<{$@!bpL*)FbZVA`zBi=m3iI8yRCc*`I7Rw=HvZ9> ze)odAu!wlhtH;eosrL4MIUvtpD^7OqxzTjb6TOm-Mrz7c^r%*k!}?x-V$7oc&dA68 zp~MktlGCXXKR}48w){Ibn=ak@zZh0*K#sB2G_kee<+7p!nv@r7A0Z$5ZqMD;h@&~l>G3u=;WIh)8BIir zdm5n|kr-!w7}Z$V#N|~VI};Ms_eebN4;=`h@gn|v1;@SSfua#_+O4KXT#U$msAS{o z%ud~W&RRYt+@u5-s>kui=C?X|JR&pq)6B>;m9i?k9T^>; ztFPHq*Ypi{@YifA^nw?ZA4d(4CD^vgT2qDN4G0{Ra6N{IVk#F7ak(|<{YG- zo9E0$MgQhaduf<45bgG9S#tK?|FW<-7TkYwo>3wR(Z3?1&31gr8xBVGkJ_Gr9 zcCMz|CkvN!{H(NsWdCq)t?KKxo}v%@UJi>AE*w^lso+DN`m{_!?P5SeYA5n%P3kY@ zWlI>)w|T}Zu0OL4zw9PmC;QB>%7>DOk5J8gK*lClf0)t|Qa3ED?d@4P(v+_gcaN6h z5%qV2Fdo&QFcrF3o?sNme4edkD>G-s;O)w~I2%+=*uWiJ<)P>k0{wn9Gf~Q%S^8j3 zN)nfHmIRx!99g=b%K<3I7aIdyMJil<`9(-1EAt$&$7*6^>*{On-ebWro%2IinSrkb zjHzNl?Vrsn?|)-YM=X}ja%V9c zN?dY$((>KyH3xcXD)&LoWSSWZzv(v~U1c#J1hNmQ zrar>n9?X1T%rp>S4NS^T_pa&!JL ztM}#82z{6(p~1Snsc)8m^^!`~F@B9?DzdaxLnVF?lW^HoBneLw5Zb0Fwu6~Niy>xy zSI-4@owz|PLM1Pqa?%(RSQY)$q7I3(HYx*YUYq!bIPR+o1#;H^dNILFwaf2P_dHa7 zGA&8`a!?Z7pL1`YY3qO(<=wak3$`V+j>RW1uc?8+Xf<8$(5|B;+rpG%i53y zk@NWqEuhmj836OU+|Y+ViPeI!S&Oc;ia&gs!ZI=`sA|Jy=HPjEA>H^oOj(=Q*L>7Y zENgJ>$-}A1%{R-IL*EiZ1HTqRFV7^wtHV!2z0rwx z8Y^V_(=FQkJU@jl{N9g*da1@|>K!?I@*$4JNXyZZAIv5hG(SrVQ;Q=Ug<_dwq&Wme zwnfBRE_=?;bF#AJ55Z^14S^9{rAjb0DX5{X2aJ{H@RX<$&`Q-^U-T(eLC_hoSPB}3 zI6!6bN@eSFz}YhPmUAb*q7}4;bxqZ7*H`F4U8=|+Fj~pQkD=gqxAqZD>RS4l!a<^y zd6=|LK%;ucsuQAWTmaje2SPANVT~b5-a`kct#gUN^cr^R#%89kT{zYx8g|vZ(;4UbJGtZmJz8clw?1!uh?roups*)GW%W$9j&H z7DZsBoA3zwH4)rbDm!*mZwxIFR)j%A*x`7|u>yPEMS7e>d)nhAu^M2AQ2}zAO0E8c zvbHI-$IcFlK5=h_2ixKYLAqyBwxp19^FI*^g=cR z;=ultvi}9mK(P(EFQ3-r=%gt&dlxSCv&yG{qss5q)WTtUw$9>n9+T7x`yU1#944sqixORbZqRSfO zB{xH|fwX33maf|Fw=@6;Fls&P;C9KZ#Bvt{9?E*0^#PjDqBQcs)u+Is#m1T_kv&5Y@ICeVwJU-iKbX+ z$?f>iPqz65LHg#-=8k^EPiJYqL7&h^*Hmk!k%75#5FM3i9n89H9Em~XR+?;sjeilz zoKb&UP6%Z2EE+XoQS&hF!3xjDJDWhyIVk>Yy;<+1mH7daz(b{R6_VYZlPWbvsa}-^ zkMMJ{nQ_uw&lvxK9~CDNI#fG{bOlbR*V+PvXR%ar&8Ia&6A`4Ai8309z3F+H?^!N+ z5N%&-@F(!)DsM_imlVCO-Kz02EKB3TbP1?ZONM`$|5E#z+H?0`;^{%!9pgk$0q~_t zx~a?(Zw>pypf#J1jy>cJR=0aVPY}Z|aNqu&{s&-9G{y(8WI*a^?S?^-d;Z^<=>h87 zX27NX+aI9gb5s4Pjg|g4l8#%&q>%u>qIPrb1c*k-KdqT+y_#orMRc36y?UBnI?!k0 zthG7)75*m!!Tb9v5$(}M&)lchh&gmrGK36U&sxpz-&@4virQk7v#`1KWZQJGrKbJ! z3$tL)futFhlIT8{rg@#!LyE{;s_H%tLKkT{0*ZpYR$cCa)fu+q!*wZ9`)3ps({tBGqV|6g=?c@L9Y<ug$uVpGLz;uLFzwT`3DSVMUDZ#tejmx~ zhXi9|AK3n6)Q^fJ>niOzkIB<3(tm7kpZywDrXys93qUg+7BR}9p@S|mmXVTRm4ABs z&*Y69kanZtG0xGVneW!S*8tDEYXHp1NWK}uSk{sT3qJEM3a6ZCnMXxj6pe7J0raqr z+UpUjdNxY=uR6;1wCuTwdDI)(1_@7XKYLMDTWOlk%@0shtH8oz1;FVb4m3X~O0iRD zq^>`IfaQx=0vWJNiq+9>fq(Gxg0eZQA+V*mCv%O2L$HfL_K8cKw6(&E!<|DR@k=#O z)+?dc2~CF{zDWm?%q~Wgj`CTDKlL=cAId6_Fkk9tOc_;zs81c*YNqXoBbF<{}V)Dk0U&8*taV_nn)oEGMC9p5gPp zha>vmm;8Wj+Kg#$zn~@z9ff*|XiM3Nm^r9i0PRrfV1HIVRD+V`}N zO6%Ls4~$R!?zMZkxlpGuiqyv!Wz_DeP>M_VipLCy)!h0u3lQtj~~hc;L_f zSp4Syy#KT9Y|=bUqJxbBTlOe;~QW{LPeZ>7>8qHg2ag;?;g%l%ay_f3}V4q=wR+QX=!%8+rd z`P9iQPf`Jtc(z)h=i(bU`n#5dMCskah{|1#!xWoRuTq?0=j`iW%Uz8c7w0{1JppOR z+Ad}AR#4`?q|{pMhb$F++@3ttS#l(SeyYwzaC(A_K`8n6Q8A`oAbF|+`Vuqw=s3=} zT1z^K7g4stAEzMapVSr`dx073-4#=H+E71oqU|g;PFaT}YH&NsY}9DdPM&lmlpf?; z#>u6k)zKxJ#n6q2vI%tDClHHLE`yV4dw#8vr;}-=rwN8Lo#qP^JUu=62spmGGDV0N zJvTR3S+~SpIpb^Ze+rPU&Lwag-AE6n*ECxunj8TyosKpq9zRNJ(ODRmOeoA0SAYjj zEgJ{uaBGZKGGvzhD5|Y2@|5mwJ7l8cwC~BT@C)Es-`wJHoG@PBHld|xxu>7&RX8iG zx85PBqteQzOFjOx_$Ql~shw~CHBm1oz_?^8taF&?)1KbO%| zwyL$iBWlJpvUVh0I z64aIbu70fDm+>JVm5iJ_&dL4#{K2&Ec`NZi(_G{5pRz8wm#^7ruBq7XiSLu(4m zGi(5c+>&4_Vp?SHb8MK zCQ`#YGBe?Mmd?_CA1`EY>7Z$Q?ec>yjBR4jZ7jc4J**R(t90K;zhqr`#ICIkTbg;) zdGY7hTZ|6R;vRf(RJ}G%eQmg)gwiOPa;C=R*+jjoj~_g!0`q(zKH2L6ZNWH_=IErl z%({niQ+c9ZR5a2vg;r;&smTrGAXTR;*xnY6SdpZXNeo$bFt0(BbHmsY>^L-=>&hAQ zVwHt?O?}1&yLx+?*P?Y*?VI&EPUZ7G)*K6JHK-b0x};R99cj>HU~&@({n>_+mi?6t zk`G{acb@5S{!;}owKkjswBP%wvlkP6fqGozPW6YQWAjg8`r4hxC6|-S1FkxmYI+L^ zyV#u*hUt2{cll`ys2;bkZi=0$t>C77BkpG*Ru`-jB&mXmVzrn5@o`5|b28QyEo)3y|n> zFD*ol0|zV^s5(ay+gY=7FFHEp8DrHdE#|XF`#ROs`Nc2Xp^^{>dsSY8M~*=?hetQt@S4zsEEjU4Z zrsmVU(o1x$o|iDpEfuqiLWG`$)J)YNSlPH($+3I8Wa&kya2E&a7FT$c-@+u&DNy4B zC41jO-&+XeCQWz!yx4P?SzulNU_3INgnzWb(Ym^TGf)L>O8y^MKwl=?k<)eoE}vEn z%>p4OB_?*7Ft&jVkH)X9cLFMosS#mlQa|0JD8eZ6wrp;icG6ZXMoYEYn1?)L#xRma z^|JXKEZ%oj7&CbCh6lElcJ+>D34KgfR-eAq_~GK?%yswt8*Yuo5G8!=2087CHgnFl z1jX|+f@GN2VTC$gyxNLmK{rh$5e17;jFc%{hfD9Hht~j zs^8e3jyvD0&f2;JZhn=A0{>3r7;yii?B(uNks+*@Es5ERy&4ER+uRf4q!6a6zl~7M z0ZSyHh14&H)s}_H?i=~(yQ`)DOUrQ>98XQ@_Mz$sk>d9VE!287^v3J(c%JgKkv zEVGhKM7j*>=GwDYe*UmX-B&zgGfyfIE?DL`y}X;SWM2cuhbsv~>A~e?+3YTDI`k-{ z0?Q9n`|@bg;MXm~`gyWkUQq6oUqYsDxJuTMlXiQTgocbu?(?C5^OQIZ8=HM}sXGf_ z%J#b5zR`W)I9oqpXZ827Zhb@~B>Dv-_JF=d2o3a$gz^D(67AGghn$hy(#d?9yQ(%; zsB54zCUYY$Okl=DZBY?%6$IgdY<4A4Jp$!F^>UgJHddz(T3|?g52(kYZ8uvEwF0KD zj}y#zribEY0BZWe6w~-GENyO@N<~a+QQyR`hV)ZEx%D)im;|!R@biy1IOfHhxU{Dn zh3sBM(TtM$>GSlUc}ypLH>+TFVzUi_0dNl=M;%>8zbVHhBAQVs2wPpYl^9dX5fu}! zHQ~-4O__S1Kz+Jk>|k^o1}-aHh((b|3!=?V2k%X6&kPTDRhF>DDzgCl=-@^2h;6dj zr32d#tu>2x=OWS#mm|^f+cl25)<2}vZ5`EM_)9?KoTOQz@W*>O*_s|uzMMUYnpJ-B zq}FqbgFHe?N^0O#pLS+6k+ZM*DnUZu-AZdI3zcaivKxULsIoa+Kf;D6-4}MEcQ7nB zn18=8&sJ(S(geXkx<5`1V~&M)jB zhcYoe%c&+czCGjKYZbF9yO%Y-HDwd-+Rdeg(%{-LY;E2*(ElA!s;dRePk`z0yAW_Qp~Q5)04F-ccMYyBa@8Jm(GEz)$y}~!v?pHA^IpvZ65v-p z4{7n*@z4%kbbjZL;o$2jC*Azrm7>EDJHAX#^3ckd7KY?ejm4vc!4lWc%f;JAqiUW$ z`rBC_dT8#0aqGR_1b?k?#M+sN*7IwwTS(Y+a7ns###JRB%wTqY*JfzaJW+Q{SLgNn z9O8D~sU~Pik>T?-#D^fUSTw@SZSUDWxo3vK0`nEokc+TtRo%xBpQ4GI;c^j>`MA&# z%Vg~U&;Z*UfRiYvPY+giIXh)@%}3PfG*&!`odPXHuVhn8aN8;^nd8L7%O&T*PP|%< zQrcnW-EEHCUI*aQo2Z`0-=pMJz&IewJX&M~@H4Li;?w^qBlL*OUKD%PbNV_C>v<)y zDRs7y-)TD4-JET>-s|R#F^wUn6@BTojeT&-yWWrPTI!B-{+JnYbSkuFt(5|jmSIr= zD2^Wo;*Niesr_J&a|{P=_I?aiGq!&5_`$cww733Vjr?s^_ z<9p`)*G<~=Nb(wQv%jy>}d~(|7Jvn=xaOx;#Bl38Ckqd)j0CbSCLVMB2v;QxclRI=$W>!{l8- z;(^3r0%&~|raPZt_ofL}n0qh6?<@U4_d2YfEJVK$7oRfq3lo9@)hts38ipWXmLXBakOfCE_ZDNtaBIB6JWk1%1-FC zLBpfh=baX2%(K2~I4~94wL+}Nx?k;Te;huut@d1>O@mG#@MJf3n#r1a2IJB^YJ6(h zCm~a1Ys52ffIs~MgORMT4G0P>LmRnRynS1t6L!IH~cvwJJ%gXX7 zvzOJp`pSZp!tgRxUzO}7SN{NaC-$Ll98rWsEGkSA1O!fN;^?3v_opC>SQXp4pzld4 zPcgf}##0~N`Bel_9$xB1`*}~>-I(BE{$gEfTS*KIul4reD$C4D$o2TNHlrIM(5|f{XFrxdbC#urReusZw}RifEdN|YD=F}+ ze10kOkcA*HA&hr`3DXN=uXJ>q^|oDi71}0h4I3QJi%_w(=w{kDYgctq94})|bimoD zGgAZnPDpqAOXt~MSw=FvSp6{M(#6}LtbnC;Bj2(^cpj?OOTp=Qc;Y!xkBJoOeyp;W zr%;qbpO6QdbwAkEZf+3X-b;@J4E|&l({(qVQA?jw^WV+iMux201ubgD^8@V1j$+g^ z#+hR6jIh(APZN`_=JM(#XqL`|kH?~EgJ8j0VgR8mkH4bxEo)Yni&;DDZL}VBoI0|z zt1%XXn%YNhj}%C(FmoW)Y|Uq#tqlDbWqxk09duc)>4*=^q)qxvewg0__IkI!&K(Mq zEw1=pi4Yp;uW{A2T|j4De1vQ7+qXP7Gqv|L)!Cr@^eJGZ7tW2G|I;?TcCP@`8?DI^ z8MUmKqJoGj$y+tpH1^-`+v+(+lKd1wz&!JhGzxV3&ORVN9o{)$Zh_Jr(yrDJ$@ zr+4vWKzoY0N4B7_s1(AcF2JjJa8kHxo1j{&hC7vEybLM4VRPIBzW9>gid|LSt(Y|* zX_6OmS^rez^Iaf(e&tL#sJm-qE4U|y&?>QHQ+d&gkX$7Wi6-+xt4BK;ioD^9T*Ob# z&_z`%sy+tn3H66NC$JF&`N4sw6^NZO%&t0K8UBPwJzc~SZ#1f+5UQ?tnmEn(m|3eQ zU8;T}?8@)>ZF14JVGnRoi-L3si=+>ZbsL(FOEkSUHYYp*VdK|X`AWf zg18N}RJjp!es(Sii&P{A>1mPQI$Jv1HBdM1eNx+QbJYYqS!TOn!(qrCiA!Yz?0^KGgq?n%iC=jV9 zC#DH9V#iHrYa}I;EwFe3KNpPKs0$9*Vbf=s}|_7q%bEfC9<@r`3(N_(l|!thP>Z z9HqjZ$*cQhNJ4!XPZ-K+Su3%e?t*ZAcT$a&=N{DIgzp*;m*#AZ)V#j9=QuMcBaR)D z(YjdbMgzmza37?3yI0on;rLdtj$hwiM)ZR9`xn$kD{6!e6KNVF`l^2I*+;E-Zl9Jc zRDCS=Yp!fM*L!`4!hEJxv&~o12niG8q{3`mXSY8fyoguu!|ruMjW92DsZ~aawUkE! zXnsh`=(R5-beJ-J-Qds^HF&Zec95^b z7+8oYGQ!(iv-CQ!Lae+}hiBJcriw%hjb^yk}KMt)O;;$yNG}){!wKD2c^0KeZBJkLmZ@P46n2!NvZ3xO0*wWm#2kyV>iMN zkmvg6ONs$^jj>ieDP-5T1;xauuKAv}M?bjrU_vA4Y{(9k54}b4{KK8RsD-M{vhMj` zF2MXcalLd|esSkL!7R{+`R#O4zJh`GVj!cy9b%T8FX6!bit1oqZu`hGdNRk1Fq*c~$Io>F3T%W{TS7ELs zKf?wySm`guC*?RrE_$pHf3;@~7PDfm4lfxD4vrQ+0Cx+Bkt*Hr?)#$M=8-6Uc!;`o z-l=F7#6g_*qmnsq0`F&(0<_d<=(O+gv{%Dnk<;ZcLV5&?rFHVzZKVjhSYQa+i_45G zXb0qyYCN|lnUtda=SefaSWRY8D0)dT2sM~@UJUQ5m&aVALSe2Uo{4rJOe%{Fgx7hlLx-?&|}M>w_B@CsTtxu^VW{ ztQUZLHJsNrL^#!~WcW#+4aA@;D*S=AG|T}xd(B#|4GUeD%=uz*^T3?7^l>8@q=ft0 zxz(e4MG28<`>kgC4&-8;sE?s$J%6=RCJ^^2lpm>jJY3ekYMyy9F3>}A(L=GK2(;nv z_g1do21MTp3JUr!3uK1z9s`Fmhh#2?fY~*3gs!|ia=Jd^uEg@!_W{Cmf!D`D*EF*y zih|`T*&32Zn{zhX7o5w+z9)Y&rfa=$Vo#l<-K%ss=)_CM2>LE29P4rQ3#xsa6Z=hB z6G*Z4^Y+b&M8$IM@EduAtd!DUrB1V$DjO~c#vf1u+8rqgJS@mO$F2Y?Vq|=r5>mC- z#re+&oKMY4JOBAL-x?9L^NB%+17_1kOYJkSI!hh3%yqRY7gW9$BvoNGkS6M3JZ7A# zY>sP-!5($?Fu{?R#Rdl-NVqbj&bDN(7R_r(;3)#_`0c}D+~rK95x_Y7{oRVo8OZ5m zu8t?|OQd`cRvWI*8~QS3u4^`mB|JhsHb$jR7vlioE?_>YqX11?sxYgLtd&gfosB;F z=pUaXt~SjfzEjgdaeCPSclFqX|ZX`GvN_a}l zKa8w*p>2P)H-193Ducn``EZv$sbfh%x7B*{r|;pqSKR{L^z<}u{ehOhhwt}xLhrd` zne*|?Ez$9dBVLD-Es}SlHbZ&lpY_fYK z#1MGiX=wno<^8kY;~X3=O-M3fP3capi>urrLjfk3b?5TZ%w$Z%i|o#)lKvJDVYfJs3`wM~z5&GU)K4DLkc) zhHdJ$cD4~2*lSqrwQqAqI>~H5`%+;J9v<^_NyLGFHKd!sws*&NMaUn(q0}RBC z^=1Y3*VG;ZIP`G@FNP8lT?qj?%H`^$X!fkgCb$0QzAD4%Vw+SVkMaI5PQ`F!&0{)h zpY=IRy_=JF-;Ya;1F72mKT1yAQb#S(pL#?Z5I$q1e)bZJnLe)i6>kTApgDi{-?-He zNu8EvGJCNx!y_Y7JHPHApcm)BR7^DWwH+%qsW#q*TrELHOCUk?>x#BxmC}1NQ={7e zL-H~n=43#j#5er4?aCPLda!(ehyjBO!umE}G-qoBtT8dvnJ;9Va^lW+=FQa?gH2+j zPD=Zv(CrpCfWxEcWJ20bi#^Qw_Ia-t#toQg2yb14!kyt@lC;PO{axc;A+`d;SE-v|g%``JN1uExv1rmT!Fx(cef3!Jq(y0-+2gE%` z6V{nh&VT;)M`dNrmA2E{+uKL{sY@;m4%Mg(y1we>ZIA>e)So6#)4$puwwmoK$((5l zr%HKDYKk)4AUy9QCV=K-+p65Ki#bd8EWXGFUfgOR|3Aik{u8)pM(l1h5PCe|QO1Y? zPr%~}#7tH_{Mb(f@lK$ddt=4wxa)4>m#Sx{%RmdNI@0C-aAS)_?L){}~4MU;UW`?}vq(iI*YecN4^${Nad$ z0lEFH2c`k;^paO63&1MC(s^MAMn}ohgMf@TJy=a&iA9rrtDAy2fs?nJJnSD(CSKIT z$kt*(WONHie9P@@?K%FjB0_*;zo}x*HHR(`(9zN9LjpVk0s?pvQ`6EMY;3%Nf|frJ zs41?5neKE~Wr2MG8vOp{aL;hq3v+XG$kkpVWNVaJHR0gsNO1+o%o9oiic0!xE@mWZ zR@5!S2Ky)bio3nA9=I-t=CEmw*2Ylj+UNS0OL&ESGkZu0auIe~`GKp;bKZuFJ)kq* z6Btbs_QpRJiMBhmkU0V7JQ|R;ZEI^=>Q2mTe@rVBSFH{)m54VD9CMtgHxhB^BV)xU zU1FO^f~t)wY5qWFAEV){+Dgnb2ON0$IzK)aH?sdT^Pl}*WalUz>r$H)OQ0++#Ts$Ocoww@vvR3y#*)NFp* z88;1-ORn}wq`Y>>Vze{smu4i$`{Hg;(Pvx&lAShkfOsz8#6lSYPx1?jCxHk)RQe!m z-$aDt^vZ(Ga+W4j|`SC7q^zYe7ttB zNCQ&436pn*drr)XN9+!-&T%q8Kr#iyNM4xzbXGKjr1wt@MmkwdQ6OK7mWq;+zMDuY zQNzmvzE^Dq2lG5-$v<=(R7akzE-qQP9Xl3?ujsa+oyH|~{96y-(SZG+M%6#rKQ5%* zXrn&*Ohw=5ve*#|#2&5O3tgNgn;RSUj*j)qDfYQeH;{ez(jE{d()HxYYa4kvdAusXBa^=G z>DAIjuOrQVr?!%FyesJl8{LgJpA*ALl3FM7ROj|*@*-k#n~ zuxWdtm7K77NLN2y)M+LSrCSQyHHL~qbh=yZ+$Y6YwFz42e{a!t)NhKGC~V(c(F*Gm zq}pKFod94AfxzPex-zLVKyGqju`yaCKrL!l{$*xDul5aclx51sxZ-Svtf*Z>^R#IU zB&7fcbJ8A87)g7y&*y44g=*{E44SdWP~7_rRvPeumVEkxQZ0&=KZ&o5xC`ty$!ekF?gaOWe- zO^M>}JH3DGzUcqHi1A-F;QwCx_}9<=r*hE$&n{_t@(~I+P0(xkS32+iZTSD|^{fA% z4gOanfo1tW77+h60RQTee_81Ni^}`I7S6vG&c7DU|J#K#a}9*L*VnFP&lUksb^j)^ bxO1!A*^No{c{S0E^LniSk}sBh`|-a4G2xh~ literal 0 HcmV?d00001 diff --git a/docs/guides/personal-workspace-user-guide.md b/docs/guides/personal-workspace-user-guide.md index f0f95c9fbf..97414f850f 100644 --- a/docs/guides/personal-workspace-user-guide.md +++ b/docs/guides/personal-workspace-user-guide.md @@ -510,6 +510,27 @@ Goal 的 Topic 路由,不删除 Goal、Agent Session、历史 Todo 或其他 G --- +### 7.1 桌面通知 / Desktop notifications + +![设置 → 外观 → 桌面通知](../assets/personal-workspace/attention-notifications-settings.png) + +「设置 → 外观 → 桌面通知」是每个浏览器各自的开关,默认关闭。开启时浏览器会询问一次通知权限, +只有允许后开关才会保持开启;页面会回读浏览器的答复(已允许、已拒绝、不支持)。开启后,页面 +每 30 秒在后台重新读取一次工作区(标签页隐藏时每 60 秒),当出现一条**新的、等你决定**的事项且你 +没有正看着这个页面时,发出一条桌面通知;点击通知会回到该 Goal 并打开这条事项。进展、运行和数量 +变化不会通知;开启当下已经在等你的事项也不会重复提醒。关闭开关后立即停止后台读取。这只在本页面 +保持打开时生效,不是服务端推送,也不替代飞书等渠道通知。 + +"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. + ## 🛡️ 8. 安全与不可逆操作保护 LoopX 控制台严格遵循 **Human-in-the-Loop(人类介入)安全模型**: diff --git a/examples/personal-workspace-browser-smoke.mjs b/examples/personal-workspace-browser-smoke.mjs index 4c5efde221..b8a1e5ed7b 100644 --- a/examples/personal-workspace-browser-smoke.mjs +++ b/examples/personal-workspace-browser-smoke.mjs @@ -6,6 +6,7 @@ import {agentModelSelectionScenario} from "./personal-workspace-browser/agent-mo import {goalWorkspaceM2Scenario} from "./personal-workspace-browser/goal-workspace-m2.mjs"; import {goalWorkspaceSelectionScenario} from "./personal-workspace-browser/goal-workspace-selection.mjs"; import {firstGoalHomeScenario} from "./personal-workspace-browser/first-goal-home.mjs"; +import {attentionNotificationsScenario} from "./personal-workspace-browser/attention-notifications.mjs"; import { replanCadenceScenario } from "./personal-workspace-browser/replan-cadence.mjs"; import {nativeChildActivityScenario} from "./personal-workspace-browser/native-child-activity.mjs"; import {privateStewardScopeScenario} from "./personal-workspace-browser/private-steward-scope.mjs"; @@ -111,6 +112,7 @@ scenarioCatalog.push(goalWorkspaceM2Scenario); scenarioCatalog.push(goalWorkspaceCreateScenario); scenarioCatalog.push(goalWorkspaceSelectionScenario); scenarioCatalog.push(firstGoalHomeScenario); +scenarioCatalog.push(attentionNotificationsScenario); const requestedScenario = process.env.LOOPX_PERSONAL_WORKSPACE_SCENARIO; scenarioCatalog.push(agentModelSelectionScenario); const scenarios = requestedScenario diff --git a/examples/personal-workspace-browser/attention-notifications.mjs b/examples/personal-workspace-browser/attention-notifications.mjs new file mode 100644 index 0000000000..3ed08c34af --- /dev/null +++ b/examples/personal-workspace-browser/attention-notifications.mjs @@ -0,0 +1,120 @@ +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) { + return page.addInitScript(() => { + window.__loopxNotifications = []; + window.__loopxPermissionRequests = 0; + class FakeNotification { + static permission = "default"; + 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; + }); +} + +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 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"); + return { coverageEntries: await session.close() }; + } catch (error) { + await session.close().catch(() => {}); + throw error; + } + }, +}; From dce3072eeb5dc5b537a397d0ecde6aa35a5d9ce2 Mon Sep 17 00:00:00 2001 From: song Date: Fri, 16 Oct 2026 00:31:24 +0800 Subject: [PATCH 2/7] test(dashboard): prove needs-you notifications against the real service Add attention-notifications-live: a disposable real Chat/Todo owner serves status.json, a user gate is added through the CLI while the tab is in the background, and the next background re-read raises exactly one notification carrying the gate text. The fixture scenario keeps the timing and permission-denied cases. Only the status path is proxied to the real owner; the document URL also ends in status.json (its statusUrl query), so a glob would swallow the page load. Co-Authored-By: Claude Fable 5.1 Signed-off-by: song --- examples/personal-workspace-browser-smoke.mjs | 2 + .../attention-notifications-live.mjs | 88 +++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 examples/personal-workspace-browser/attention-notifications-live.mjs diff --git a/examples/personal-workspace-browser-smoke.mjs b/examples/personal-workspace-browser-smoke.mjs index b8a1e5ed7b..c94e78b9bf 100644 --- a/examples/personal-workspace-browser-smoke.mjs +++ b/examples/personal-workspace-browser-smoke.mjs @@ -7,6 +7,7 @@ import {goalWorkspaceM2Scenario} from "./personal-workspace-browser/goal-workspa import {goalWorkspaceSelectionScenario} from "./personal-workspace-browser/goal-workspace-selection.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 { replanCadenceScenario } from "./personal-workspace-browser/replan-cadence.mjs"; import {nativeChildActivityScenario} from "./personal-workspace-browser/native-child-activity.mjs"; import {privateStewardScopeScenario} from "./personal-workspace-browser/private-steward-scope.mjs"; @@ -113,6 +114,7 @@ scenarioCatalog.push(goalWorkspaceCreateScenario); scenarioCatalog.push(goalWorkspaceSelectionScenario); scenarioCatalog.push(firstGoalHomeScenario); scenarioCatalog.push(attentionNotificationsScenario); +scenarioCatalog.push(attentionNotificationsLiveScenario); const requestedScenario = process.env.LOOPX_PERSONAL_WORKSPACE_SCENARIO; scenarioCatalog.push(agentModelSelectionScenario); const scenarios = requestedScenario 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..beb164497b --- /dev/null +++ b/examples/personal-workspace-browser/attention-notifications-live.mjs @@ -0,0 +1,88 @@ +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); + + // A decision raised in the real Todo owner while the owner is away. + 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"); + 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); } } + } + }, +}; From a71bd5fcfc769a834a0e13c5517897983d6cc972 Mon Sep 17 00:00:00 2001 From: song Date: Fri, 16 Oct 2026 05:45:43 +0800 Subject: [PATCH 3/7] fix(workspace): notify only new owner decisions and read permission back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found two gaps in the needs-you notifications: - The diff read every User Todo of the Goal, so an optional user action (listed, not blocking) notified like a decision. Every listed item is now remembered when the switch turns on and on each read, and only an item first seen while the attention owner classifies it as waiting on the owner notifies (`notifiableAttention`). An optional action that later turns blocking because a gate appeared is not a new item either. - A browser that had blocked notifications disabled the switch for good: the only code that refreshed the permission readback was the toggle handler the disabled switch could never reach. The page now reads the browser's answer back on return (focus, visibility), when Settings open and when the permission owner reports a change; nothing asks for permission or turns the switch on by itself. A grant made in the site settings makes the switch usable again; a revocation while on shows as blocked and stops the background re-read. Unit test for the notifiable selector; contract pins for both rules; the fixture scenario adds the blocked → allowed → on → revoked path without a reload; the real-service scenario raises an optional user action through the CLI first (nothing notifies), then a gate (exactly one notification). The user guide states both behaviors. Co-Authored-By: Claude Fable 5.1 Signed-off-by: song --- .../attention-notifications.test.mjs | 9 +++ .../attention-notifications.ts | 9 +++ .../personal-workspace-contract.test.mjs | 6 +- .../personal-workspace-page.tsx | 31 +++++++++- docs/guides/personal-workspace-user-guide.md | 8 ++- .../attention-notifications-live.mjs | 9 +++ .../attention-notifications.mjs | 57 +++++++++++++++++-- 7 files changed, 118 insertions(+), 11 deletions(-) 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 index a5c3747fd3..29635462a7 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/attention-notifications.test.mjs +++ b/apps/presentation/dashboard/src/features/personal-workspace/attention-notifications.test.mjs @@ -3,6 +3,7 @@ import test from "node:test"; import { attentionNotificationContents, + notifiableAttention, attentionNotificationKey, attentionNotificationSupport, attentionPollIntervalMs, @@ -60,3 +61,11 @@ test("content is owner language, bounded, and summarizes a burst", () => { 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 index aa15cbdd2d..5eadc1c961 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/attention-notifications.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/attention-notifications.ts @@ -38,6 +38,15 @@ export function attentionNotificationSupport(scope: { Notification?: { permissio 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}`; 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 dcc56dd27a..f04f6596d1 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 @@ -608,7 +608,11 @@ assert.match(page, /const attentionNotificationsActive = attentionNotificationsE 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 the current lane so nothing already waiting is replayed"); +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(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`); 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 8f1d4ce8d0..4c16f08699 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 @@ -92,7 +92,7 @@ import { readWorkspaceTheme, writeWorkspaceTheme, type WorkspaceTheme } from "./ import { attentionNotificationContents, attentionNotificationKey, - attentionNotificationSupport, + attentionNotificationSupport, notifiableAttention, attentionPollIntervalMs, newAttentionItems, readAttentionNotificationPreference, @@ -2298,6 +2298,30 @@ export function PersonalWorkspacePage({ ) : 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; @@ -2332,7 +2356,10 @@ export function PersonalWorkspacePage({ const seen = seenAttentionKeysRef.current; seenAttentionKeysRef.current = keys; if (!seen) return; - const fresh = newAttentionItems(seen, items); + // 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(), diff --git a/docs/guides/personal-workspace-user-guide.md b/docs/guides/personal-workspace-user-guide.md index 49caadda5e..b0bba7fe55 100644 --- a/docs/guides/personal-workspace-user-guide.md +++ b/docs/guides/personal-workspace-user-guide.md @@ -566,8 +566,10 @@ Goal 的 Topic 路由,不删除 Goal、Agent Session、历史 Todo 或其他 G 只有允许后开关才会保持开启;页面会回读浏览器的答复(已允许、已拒绝、不支持)。开启后,页面 每 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 @@ -577,7 +579,7 @@ notification for each **new item that waits on you** while you are not looking a 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. +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. 安全与不可逆操作保护 diff --git a/examples/personal-workspace-browser/attention-notifications-live.mjs b/examples/personal-workspace-browser/attention-notifications-live.mjs index beb164497b..3735efd912 100644 --- a/examples/personal-workspace-browser/attention-notifications-live.mjs +++ b/examples/personal-workspace-browser/attention-notifications-live.mjs @@ -62,6 +62,14 @@ export const attentionNotificationsLiveScenario = { 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. 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); @@ -70,6 +78,7 @@ export const attentionNotificationsLiveScenario = { 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 }); diff --git a/examples/personal-workspace-browser/attention-notifications.mjs b/examples/personal-workspace-browser/attention-notifications.mjs index 3ed08c34af..9589328c71 100644 --- a/examples/personal-workspace-browser/attention-notifications.mjs +++ b/examples/personal-workspace-browser/attention-notifications.mjs @@ -11,12 +11,12 @@ import { openWorkspacePage } from "./scenario-context.mjs"; const gateText = "Approve the restated FY2023 operating cash flow"; -function installNotificationStub(page) { - return page.addInitScript(() => { +function installNotificationStub(page, { permission = "default" } = {}) { + return page.addInitScript((initial) => { window.__loopxNotifications = []; window.__loopxPermissionRequests = 0; class FakeNotification { - static permission = "default"; + 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; } @@ -24,7 +24,7 @@ function installNotificationStub(page) { 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) { @@ -111,7 +111,54 @@ export const attentionNotificationsScenario = { 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"); - return { coverageEntries: await session.close() }; + 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; From 28ab8ba671995ba83b123a650e939ad1c715e09e Mon Sep 17 00:00:00 2001 From: song Date: Fri, 16 Oct 2026 06:02:42 +0800 Subject: [PATCH 4/7] fix(workspace): preserve optional attention in mixed decision queues Signed-off-by: song --- .../dashboard/src/views/dashboard-page.tsx | 29 ++++++++++++------- .../attention-notifications-live.mjs | 4 +++ 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx index 6028a2f3d1..acacda9c79 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,20 @@ function buildPersonalHomeModel( todoId: todo.todo_id?.trim() || `${item.goal_id}:user:${todo.index}`, todoOrder, updatedAt: todo.updated_at ?? null, - })); + }; }); + const blockingCount = projected.filter((todo) => !todo.projectedDone && todo.blocking).length; + const complete = count !== null && count === listedOpen; + attentionSummaries.set(item.goal_id, { + blocking: complete ? blockingCount : null, + optional: complete ? count - blockingCount : 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 []; diff --git a/examples/personal-workspace-browser/attention-notifications-live.mjs b/examples/personal-workspace-browser/attention-notifications-live.mjs index 3735efd912..7ca0f6ee2d 100644 --- a/examples/personal-workspace-browser/attention-notifications-live.mjs +++ b/examples/personal-workspace-browser/attention-notifications-live.mjs @@ -71,6 +71,10 @@ export const attentionNotificationsLiveScenario = { 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); From c9d2815827af8b921164f70d1834610ccc81e48e Mon Sep 17 00:00:00 2001 From: song Date: Fri, 16 Oct 2026 06:31:31 +0800 Subject: [PATCH 5/7] fix(workspace): remember seen decisions for the whole notification activation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review reproduced a false notification without any new fact: a gate already waiting when the switch turned on was seeded, a partial status read then omitted it while still counting it, and the next full read listed it again as "new". The diff replaced the seen set with each read's identities, so a partial source forgot what the owner had already been shown. Identities seen while notifications are on are now kept for the whole activation (the set only grows until the switch turns off or the page reloads), so an item that a partial read omits is still known when it returns. The contract test pins that the seen set is never replaced by the current read; the fixture scenario runs full → partial → full on a seeded gate and asserts nothing notifies. Co-Authored-By: Claude Fable 5.1 Signed-off-by: song --- .../personal-workspace-contract.test.mjs | 2 ++ .../personal-workspace-page.tsx | 7 +++++-- .../attention-notifications.mjs | 21 +++++++++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) 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 f04f6596d1..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 @@ -610,6 +610,8 @@ assert.match(page, /Notification\.requestPermission\(\)/, "Permission is request 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"); 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 4c16f08699..567a1fc341 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 @@ -2352,9 +2352,12 @@ export function PersonalWorkspacePage({ useEffect(() => { if (!attentionNotificationsActive) { seenAttentionKeysRef.current = null; return; } const items = attentionItemsRef.current; - const keys = new Set(items.map(attentionNotificationKey)); const seen = seenAttentionKeysRef.current; - seenAttentionKeysRef.current = keys; + // 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 diff --git a/examples/personal-workspace-browser/attention-notifications.mjs b/examples/personal-workspace-browser/attention-notifications.mjs index 9589328c71..2da8b5ad9f 100644 --- a/examples/personal-workspace-browser/attention-notifications.mjs +++ b/examples/personal-workspace-browser/attention-notifications.mjs @@ -67,6 +67,27 @@ export const attentionNotificationsScenario = { 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); From 18e296e4461eee652abe08d0d8b2f78139b0c111 Mon Sep 17 00:00:00 2001 From: song Date: Fri, 16 Oct 2026 06:31:31 +0800 Subject: [PATCH 6/7] fix(workspace): count unlisted user rows by the queue's own answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-row attention classification dropped the owner's count whenever the listing was partial: with 21 open and 3 listed, the Needs-you vital showed "—" instead of 21 and the home no longer knew the Goal waited on the owner. Listed rows keep their own class; the rows the owner counts but does not list carry none, so they take the queue's answer exactly as a listed legacy row does. Coverage still reports partial. The masthead scenario's "optional" mode now makes its listed rows optional by class as well as pointing the queue at the Agent, since an explicit class owns its row regardless of the queue's answer. Co-Authored-By: Claude Fable 5.1 Signed-off-by: song --- .../dashboard/src/views/dashboard-page.tsx | 15 ++++++++++----- .../personal-workspace-browser/goal-masthead.mjs | 8 +++++++- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx index acacda9c79..19277502eb 100644 --- a/apps/presentation/dashboard/src/views/dashboard-page.tsx +++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx @@ -962,12 +962,17 @@ function buildPersonalHomeModel( todoOrder, updatedAt: todo.updated_at ?? null, }; }); - const blockingCount = projected.filter((todo) => !todo.projectedDone && todo.blocking).length; - const complete = count !== null && count === listedOpen; + // Listed rows carry their own class; the rows the owner counts but does + // not list carry none, so they take the queue's own answer, exactly as a + // listed legacy row does. A partial listing therefore still reports the + // owner's count instead of forgetting it. + const blockingListed = projected.filter((todo) => !todo.projectedDone && todo.blocking).length; + const unlisted = count === null ? 0 : Math.max(0, count - listedOpen); + const blockingCount = count === null ? null : blockingListed + (blocking ? unlisted : 0); attentionSummaries.set(item.goal_id, { - blocking: complete ? blockingCount : null, - optional: complete ? count - blockingCount : null, - coverage: count === null ? "unknown" : complete ? "complete" : "partial", + blocking: blockingCount, + optional: count === null || blockingCount === null ? null : count - blockingCount, + coverage: count === null ? "unknown" : count > listedOpen ? "partial" : "complete", }); return projected; }); diff --git a/examples/personal-workspace-browser/goal-masthead.mjs b/examples/personal-workspace-browser/goal-masthead.mjs index e735236a48..36b2f4f398 100644 --- a/examples/personal-workspace-browser/goal-masthead.mjs +++ b/examples/personal-workspace-browser/goal-masthead.mjs @@ -269,7 +269,13 @@ export const goalMastheadScenario = { 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"; + if (mode === "optional") { + // Nothing waits on the owner: the Goal waits on its Agent and every + // listed row is an optional user action by its own class, since an + // explicit class owns its row regardless of the queue's answer. + queue.waiting_on = "agent"; + for (const todo of [...queue.user_todos.items, ...queue.project_asset.user_todos.items]) todo.task_class = "user_action"; + } if (mode === "empty") { queue.user_todos.items = []; queue.project_asset.user_todos.items = []; From 821a3da127a89db48c2b4ff6c164f19d7ec517ba Mon Sep 17 00:00:00 2001 From: song Date: Fri, 16 Oct 2026 06:48:08 +0800 Subject: [PATCH 7/7] fix(workspace): qualify attention totals from complete classified rows Signed-off-by: song --- .../dashboard/src/views/dashboard-page.tsx | 16 ++++---- .../goal-attention-fixture.py | 25 +++++++++++- .../goal-masthead.mjs | 39 ++++++++++++------- 3 files changed, 55 insertions(+), 25 deletions(-) diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx index 19277502eb..2f2a58205d 100644 --- a/apps/presentation/dashboard/src/views/dashboard-page.tsx +++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx @@ -962,17 +962,15 @@ function buildPersonalHomeModel( todoOrder, updatedAt: todo.updated_at ?? null, }; }); - // Listed rows carry their own class; the rows the owner counts but does - // not list carry none, so they take the queue's own answer, exactly as a - // listed legacy row does. A partial listing therefore still reports the - // owner's count instead of forgetting it. + // 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 unlisted = count === null ? 0 : Math.max(0, count - listedOpen); - const blockingCount = count === null ? null : blockingListed + (blocking ? unlisted : 0); + const complete = count !== null && count === listedOpen; attentionSummaries.set(item.goal_id, { - blocking: blockingCount, - optional: count === null || blockingCount === null ? null : count - blockingCount, - coverage: count === null ? "unknown" : count > listedOpen ? "partial" : "complete", + blocking: complete ? blockingListed : null, + optional: complete ? count - blockingListed : null, + coverage: count === null ? "unknown" : complete ? "complete" : "partial", }); return projected; }); 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 36b2f4f398..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,14 +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") { - // Nothing waits on the owner: the Goal waits on its Agent and every - // listed row is an optional user action by its own class, since an - // explicit class owns its row regardless of the queue's answer. - queue.waiting_on = "agent"; - for (const todo of [...queue.user_todos.items, ...queue.project_asset.user_todos.items]) todo.task_class = "user_action"; - } + 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 = []; @@ -291,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");