Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions apps/presentation/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,10 @@
"smoke:home-route": "rm -rf /tmp/loopx-home-route-smoke && tsc --ignoreConfig --target ES2022 --module CommonJS --moduleResolution Node --ignoreDeprecations 6.0 --skipLibCheck --strict --outDir /tmp/loopx-home-route-smoke smoke/home-route-smoke.ts && node /tmp/loopx-home-route-smoke/home-route-smoke.js",
"smoke:delegation-preflight": "rm -rf node_modules/.cache/loopx-delegation-preflight-smoke && tsc --ignoreConfig --target ES2022 --module NodeNext --moduleResolution NodeNext --jsx react-jsx --skipLibCheck --strict --rootDir . --outDir node_modules/.cache/loopx-delegation-preflight-smoke smoke/delegation-preflight-smoke.tsx src/data/delegation-preflight.ts src/features/personal-workspace/delegation-preflight-status.tsx && node node_modules/.cache/loopx-delegation-preflight-smoke/smoke/delegation-preflight-smoke.js",
"smoke:delegation-preflight-browser": "node --experimental-strip-types smoke/delegation-preflight-browser-smoke.mjs",
"smoke:personal-workspace": "npm run smoke:conversation-history-order && npm run smoke:goal-order && npm run smoke:goal-activity && npm run smoke:proposal-recency && node src/features/personal-workspace/conversation-order.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/steering-recovery.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/since-you-looked.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/goal-suggestions.test.mjs && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/font-token.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser-smoke.mjs",
"smoke:personal-workspace": "npm run smoke:conversation-history-order && npm run smoke:goal-order && npm run smoke:goal-activity && npm run smoke:proposal-recency && node src/features/personal-workspace/conversation-order.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/steering-recovery.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/attention-notifications.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/since-you-looked.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/goal-suggestions.test.mjs && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/font-token.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser-smoke.mjs",
"smoke:workspace-locale": "LOOPX_PERSONAL_WORKSPACE_SCENARIO=workspace-locale node ../../../examples/personal-workspace-browser-smoke.mjs",
"smoke:personal-workspace-packaged": "npm run smoke:conversation-history-order && npm run smoke:goal-order && npm run smoke:goal-activity && npm run smoke:proposal-recency && node src/features/personal-workspace/conversation-order.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/steering-recovery.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/since-you-looked.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/goal-suggestions.test.mjs && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/font-token.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser/server-startup.test.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node ../../../examples/personal-workspace-browser-smoke.mjs && LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/team-evidence-return-smoke.mjs && LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/team-feedback-recovery-smoke.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/goal-workspace-craft-browser-smoke.mjs",
"smoke:personal-workspace-packaged:catalog": "npm run smoke:conversation-history-order && npm run smoke:goal-order && npm run smoke:goal-activity && npm run smoke:proposal-recency && node src/features/personal-workspace/conversation-order.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/steering-recovery.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/since-you-looked.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/goal-suggestions.test.mjs && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/font-token.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser/server-startup.test.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node ../../../examples/personal-workspace-browser-smoke.mjs",
"smoke:personal-workspace-packaged": "npm run smoke:conversation-history-order && npm run smoke:goal-order && npm run smoke:goal-activity && npm run smoke:proposal-recency && node src/features/personal-workspace/conversation-order.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/steering-recovery.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/attention-notifications.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/since-you-looked.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/goal-suggestions.test.mjs && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/font-token.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser/server-startup.test.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node ../../../examples/personal-workspace-browser-smoke.mjs && LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/team-evidence-return-smoke.mjs && LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/team-feedback-recovery-smoke.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/goal-workspace-craft-browser-smoke.mjs",
"smoke:personal-workspace-packaged:catalog": "npm run smoke:conversation-history-order && npm run smoke:goal-order && npm run smoke:goal-activity && npm run smoke:proposal-recency && node src/features/personal-workspace/conversation-order.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/steering-recovery.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/attention-notifications.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/since-you-looked.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/goal-suggestions.test.mjs && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/font-token.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser/server-startup.test.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node ../../../examples/personal-workspace-browser-smoke.mjs",
"smoke:personal-workspace-packaged:team": "LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/team-evidence-return-smoke.mjs && LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/team-feedback-recovery-smoke.mjs",
"smoke:personal-workspace-packaged:craft": "LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/goal-workspace-craft-browser-smoke.mjs",
"smoke:todo-resume-condition": "rm -rf /tmp/loopx-todo-resume-condition-smoke && tsc --ignoreConfig --target ES2022 --module commonjs --moduleResolution node --ignoreDeprecations 6.0 --skipLibCheck --strict --outDir /tmp/loopx-todo-resume-condition-smoke smoke/todo-resume-condition-smoke.ts src/features/personal-workspace/todo-resume-condition.ts && node /tmp/loopx-todo-resume-condition-smoke/smoke/todo-resume-condition-smoke.js",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import assert from "node:assert/strict";
import test from "node:test";

import {
attentionNotificationContents,
notifiableAttention,
attentionNotificationKey,
attentionNotificationSupport,
attentionPollIntervalMs,
newAttentionItems,
shouldNotifyAttention,
} from "./attention-notifications.ts";

const item = (goalId, todoId, text = "Approve the cut-over window", goalTitle = "Billing migration") => ({ goalId, todoId, text, goalTitle, blocking: true });

test("a needs-you item is identified by Goal and Todo, never by text", () => {
assert.equal(attentionNotificationKey(item("g1", "t1", "a")), attentionNotificationKey(item("g1", "t1", "b")));
assert.notEqual(attentionNotificationKey(item("g1", "t1")), attentionNotificationKey(item("g2", "t1")));
assert.notEqual(attentionNotificationKey(item("g1", "t1")), attentionNotificationKey(item("g1", "t2")));
});

test("only items absent from the seen set are new, in projection order", () => {
const seen = new Set([attentionNotificationKey(item("g1", "t1"))]);
const fresh = newAttentionItems(seen, [item("g2", "t9"), item("g1", "t1"), item("g1", "t2")]);
assert.deepEqual(fresh.map((row) => row.todoId), ["t9", "t2"]);
assert.deepEqual(newAttentionItems(seen, []), [], "A shrinking queue never notifies");
});

test("notify only when enabled, granted and the owner is not looking", () => {
const base = { enabled: true, support: "granted", visible: false, focused: false };
assert.equal(shouldNotifyAttention(base), true);
assert.equal(shouldNotifyAttention({ ...base, visible: true, focused: false }), true, "an unfocused visible window still needs a nudge");
assert.equal(shouldNotifyAttention({ ...base, visible: true, focused: true }), false, "a focused page already shows the lane");
assert.equal(shouldNotifyAttention({ ...base, enabled: false }), false);
for (const support of ["default", "denied", "unsupported"]) {
assert.equal(shouldNotifyAttention({ ...base, support }), false, `${support} never notifies`);
}
});

test("support reads the browser answer and tolerates a missing API", () => {
assert.equal(attentionNotificationSupport({}), "unsupported");
assert.equal(attentionNotificationSupport({ Notification: { permission: "granted" } }), "granted");
assert.equal(attentionNotificationSupport({ Notification: { permission: "denied" } }), "denied");
assert.equal(attentionNotificationSupport({ Notification: { permission: "default" } }), "default");
});

test("hidden pages re-read less often than visible ones", () => {
assert.ok(attentionPollIntervalMs(true) > attentionPollIntervalMs(false));
assert.ok(attentionPollIntervalMs(false) >= 15_000, "never hammer the local service");
});

test("content is owner language, bounded, and summarizes a burst", () => {
const copy = { fallbackTitle: "LoopX", more: (count) => `${count} more decisions are waiting` };
const items = [item("g1", "t1"), item("g1", "t2", "x".repeat(200)), item("g2", "t3", "Pick a source", ""), item("g2", "t4"), item("g2", "t5")];
const contents = attentionNotificationContents(items, copy);
assert.equal(contents.length, 4, "three items plus one summary");
assert.equal(contents[0].title, "Billing migration");
assert.equal(contents[1].body.length, 160, "long text is truncated with an ellipsis");
assert.equal(contents[2].title, "LoopX", "a missing Goal title falls back to the product name");
assert.deepEqual(contents[3], { body: "2 more decisions are waiting", tag: "loopx-attention-more", title: "LoopX" });
assert.equal(new Set(contents.map((row) => row.tag)).size, 4, "tags dedupe per item");
assert.equal(attentionNotificationContents([], copy).length, 0);
});

test("only items that wait on the owner may notify; optional actions never do", () => {
const gate = { ...item("g1", "gate"), blocking: true };
const optional = { ...item("g1", "optional", "Consider renaming the export folder"), blocking: false };
assert.deepEqual(notifiableAttention([optional, gate, optional]).map((row) => row.todoId), ["gate"]);
assert.deepEqual(notifiableAttention([optional]), []);
assert.deepEqual(newAttentionItems(new Set(), notifiableAttention([optional])), [], "an optional item is not a new decision");
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import type { WorkspaceAttention } from "./personal-workspace-model";

/**
* Browser notifications for items that need the owner.
*
* The preference is a per-browser convenience, like the theme: it lives in
* local storage, defaults to off and never writes to the service. While it is
* on, the page re-reads the workspace in the background so a decision that
* appears while the owner is elsewhere reaches them; nothing is sent for
* progress, runs or counts, only for a new item that waits on the owner.
*/

export const attentionNotificationStorageKey = "loopx-pw-attention-notifications";

export type AttentionNotificationSupport = "unsupported" | "default" | "granted" | "denied";

export function readAttentionNotificationPreference(): boolean {
try {
return window.localStorage.getItem(attentionNotificationStorageKey) === "on";
} catch {
return false;
}
}

export function writeAttentionNotificationPreference(enabled: boolean): void {
try {
if (enabled) window.localStorage.setItem(attentionNotificationStorageKey, "on");
else window.localStorage.removeItem(attentionNotificationStorageKey);
} catch {
// Storage may be unavailable; the in-memory state still applies to this page.
}
}

/** The browser's current answer; "unsupported" when no Notification API exists. */
export function attentionNotificationSupport(scope: { Notification?: { permission: string } } = globalThis as never): AttentionNotificationSupport {
const api = scope.Notification;
if (!api || typeof api.permission !== "string") return "unsupported";
return api.permission === "granted" ? "granted" : api.permission === "denied" ? "denied" : "default";
}

/**
* What may notify: the items the attention owner classifies as waiting on
* the owner. An optional user action is listed in the workspace but is not a
* decision, so it never interrupts; seed, diff and content all read this set.
*/
export function notifiableAttention<T extends Pick<WorkspaceAttention, "blocking">>(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<WorkspaceAttention, "goalId" | "todoId">): string {
return `${item.goalId}\u0000${item.todoId}`;
}

/** Items not present in the previously seen set, in projection order. */
export function newAttentionItems<T extends Pick<WorkspaceAttention, "goalId" | "todoId">>(seen: ReadonlySet<string>, 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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -1307,6 +1307,14 @@ const en = {
"cadence.retry": "Refresh policy",
"settings.workspaceDisplay": "Workspace display",
"settings.workspaceTheme": "Workspace theme",
"settings.attentionNotifications": "Desktop notifications",
"settings.attentionNotificationsToggle": "Notify me when a decision needs me",
"settings.attentionNotificationsHint": "Only while this page stays open in this browser. Progress and runs never notify; only a new item that waits on you does. While on, the page re-reads the workspace every 30 seconds.",
"settings.attentionNotificationsGranted": "Allowed by this browser.",
"settings.attentionNotificationsDenied": "Blocked by this browser. Allow notifications for this site in the browser's site settings, then turn this on again.",
"settings.attentionNotificationsDefault": "The browser asks for permission when you turn this on.",
"settings.attentionNotificationsUnsupported": "This environment has no notification support.",
"notifications.attentionMore": "{count} more decisions are waiting for you",
"session.closeRecord": "Exit run record",
"session.details": "Session details",
"session.record": "Execution Session · Run record",
Expand Down Expand Up @@ -2830,6 +2838,14 @@ const zhCN: Record<WorkspaceMessageKey, string> = {
"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 · 运行记录",
Expand Down
Loading
Loading