diff --git a/apps/presentation/dashboard/package.json b/apps/presentation/dashboard/package.json index 8d18b1086e..f5b0da5692 100644 --- a/apps/presentation/dashboard/package.json +++ b/apps/presentation/dashboard/package.json @@ -38,10 +38,10 @@ "smoke:home-route": "rm -rf /tmp/loopx-home-route-smoke && tsc --ignoreConfig --target ES2022 --module CommonJS --moduleResolution Node --ignoreDeprecations 6.0 --skipLibCheck --strict --outDir /tmp/loopx-home-route-smoke smoke/home-route-smoke.ts && node /tmp/loopx-home-route-smoke/home-route-smoke.js", "smoke:delegation-preflight": "rm -rf node_modules/.cache/loopx-delegation-preflight-smoke && tsc --ignoreConfig --target ES2022 --module NodeNext --moduleResolution NodeNext --jsx react-jsx --skipLibCheck --strict --rootDir . --outDir node_modules/.cache/loopx-delegation-preflight-smoke smoke/delegation-preflight-smoke.tsx src/data/delegation-preflight.ts src/features/personal-workspace/delegation-preflight-status.tsx && node node_modules/.cache/loopx-delegation-preflight-smoke/smoke/delegation-preflight-smoke.js", "smoke:delegation-preflight-browser": "node --experimental-strip-types smoke/delegation-preflight-browser-smoke.mjs", - "smoke:personal-workspace": "npm run smoke:conversation-history-order && npm run smoke:goal-order && npm run smoke:goal-activity && npm run smoke:proposal-recency && node src/features/personal-workspace/conversation-order.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/steering-recovery.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/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/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/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/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/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: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/goal-overview.css b/apps/presentation/dashboard/src/features/personal-workspace/goal-overview.css index 152b13c87d..3db336632a 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-overview.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-overview.css @@ -25,6 +25,11 @@ .goal-overview .goal-overview-heading button:hover { border-color: transparent; background: var(--pw-hover); color: var(--pw-text); } .goal-overview-objective { max-width: 44em; color: var(--pw-text); font-size: var(--text-heading); font-weight: 600; letter-spacing: -0.015em; line-height: var(--leading-heading); text-wrap: pretty; } +/* One quiet line above the hero: what changed since the owner's last look. Idle content stays still. */ +.goal-overview-since { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 14px; margin: 0 0 16px; padding-bottom: 12px; border-bottom: 1px solid var(--pw-line); color: var(--pw-muted); font-size: var(--text-caption); line-height: var(--leading-caption); font-variant-numeric: tabular-nums; } +.goal-overview-since button { min-height: 28px; padding: 0 10px; border: 1px solid var(--pw-line-strong); border-radius: 6px; background: transparent; color: var(--pw-text); font-size: var(--text-caption); cursor: pointer; } +.goal-overview-since button:hover { border-color: var(--pw-blue); color: var(--pw-blue-ink); } +.goal-overview-since button:focus-visible { outline: 2px solid var(--pw-blue); outline-offset: 2px; } .goal-overview-note { max-width: 56em; color: var(--pw-muted); font-size: var(--text-body); line-height: var(--leading-body); } .goal-overview-note small { color: var(--pw-faint); } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-overview.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-overview.tsx index c12e4825f9..97455f58a2 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-overview.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-overview.tsx @@ -9,13 +9,28 @@ import type {GoalWorkspaceDigest} from "../../../../../../loopx/control_plane/pr import { GoalAcceptanceObservationCard } from "./goal-acceptance-observation-card"; import type { WorkspaceActionPreview, WorkspaceActionPreviewRequest, WorkspaceDrawerSelection, WorkspaceGoal, WorkspaceGoalTab, WorkspaceModel, WorkspaceTimelineItem } from "./personal-workspace-model"; import { useWorkspaceI18n } from "./i18n"; +import type { SinceYouLookedDelta } from "./since-you-looked"; import {goalAcceptanceDisplayStatus, projectGoalAcceptanceCriteria} from "../../data/goal-acceptance-observation"; import "./goal-overview.css"; export const goalOverviewAttentionId = "goal-overview-attention"; -export function GoalOverview({ active, goal, items, userTodos, readOnly, onOpenDetails, onSelect, onView, selectedTodoId, board, results, proposals, onPreview, historyRefreshRevision, review, digest, railOpen, onCloseRail, onOpenAgentConversation }: { +/** "3 hours ago" for the owner's last look; falls back to the date for older marks. */ +function relativeLookLabel(iso: string, locale: string): string { + const then = new Date(iso).getTime(); + if (Number.isNaN(then)) return iso; + const minutes = Math.max(1, Math.round((Date.now() - then) / 60_000)); + const format = new Intl.RelativeTimeFormat(locale === "zh-CN" ? "zh-CN" : "en", { numeric: "always" }); + if (minutes < 60) return format.format(-minutes, "minute"); + if (minutes < 60 * 24) return format.format(-Math.round(minutes / 60), "hour"); + if (minutes < 60 * 24 * 14) return format.format(-Math.round(minutes / (60 * 24)), "day"); + return new Intl.DateTimeFormat(locale === "zh-CN" ? "zh-CN" : "en", { month: "short", day: "numeric" }).format(then); +} + +export function GoalOverview({ active, goal, items, userTodos, readOnly, onOpenDetails, onSelect, onView, selectedTodoId, board, results, proposals, onPreview, historyRefreshRevision, review, digest, railOpen, onCloseRail, onOpenAgentConversation, sinceYouLooked, onMarkSeen }: { onOpenAgentConversation?: (agentId: string) => void; + /** Material changes since the owner's last acknowledged look; null when none. */ + sinceYouLooked?: SinceYouLookedDelta | null; onMarkSeen?: () => void; railOpen: boolean; onCloseRail: () => void; review: DeliveryReviewRead; digest: GoalWorkspaceDigest; active: boolean; goal: WorkspaceGoal; items: WorkspaceTimelineItem[]; userTodos: WorkspaceModel["userTodos"]; @@ -58,7 +73,19 @@ export function GoalOverview({ active, goal, items, userTodos, readOnly, onOpenD const criteria = contract ? projectGoalAcceptanceCriteria(contract) : []; const passed = criteria.filter(criterion => criterion.state === "accepted").length; const acceptanceCopy = goalAcceptanceContractCopy[locale === "zh-CN" ? "zh-CN" : "en"]; + const sinceParts = sinceYouLooked ? [ + sinceYouLooked.newlyDone ? t("since.newlyDone", { count: sinceYouLooked.newlyDone }) : null, + sinceYouLooked.newDecisions ? t("since.newDecisions", { count: sinceYouLooked.newDecisions }) : null, + sinceYouLooked.newlyBlocked ? t("since.newlyBlocked", { count: sinceYouLooked.newlyBlocked }) : null, + sinceYouLooked.resultsReturned ? t("since.resultsReturned", { count: sinceYouLooked.resultsReturned }) : null, + ].filter((part): part is string => part !== null) : []; return
+ {sinceYouLooked && sinceParts.length ? ( +

+ {t("since.lead", { when: relativeLookLabel(sinceYouLooked.since, locale) })} {sinceParts.join(locale === "zh-CN" ? "、" : ", ")} + {onMarkSeen ? : null} +

+ ) : null}
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index 1d5184bf84..c47489e0f9 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -589,6 +589,12 @@ const en = { "header.chatRuntime": "Chat", "header.workAgentCount": "{count} work Agents", "header.overview": "Overview", + "since.lead": "Since you looked ({when}):", + "since.newlyDone": "{count} newly done", + "since.newDecisions": "{count} new decision(s) for you", + "since.newlyBlocked": "{count} newly blocked", + "since.resultsReturned": "{count} result(s) returned", + "since.markSeen": "Mark seen", "header.chat": "Chat", "header.files": "Files", "header.goalCapabilities": "Capability settings", @@ -2106,6 +2112,12 @@ const zhCN: Record = { "header.chatRuntime": "Chat", "header.workAgentCount": "{count} 个工作 Agent", "header.overview": "概览", + "since.lead": "自你上次查看({when}):", + "since.newlyDone": "新完成 {count} 项", + "since.newDecisions": "新增 {count} 项等你决定", + "since.newlyBlocked": "新受阻 {count} 项", + "since.resultsReturned": "新成果 {count} 项", + "since.markSeen": "标记已读", "header.chat": "对话", "header.files": "成果", "header.goalCapabilities": "能力配置", 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 b6c2f3ba8a..05c8e70057 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs @@ -600,6 +600,34 @@ 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`); } +// Since you looked (RFC goal-workspace-surface §5.7): a browser-local marker +// per source and Goal, typed counts and ids only, no cost, no service write; +// the line appears only for a material delta. Mark seen acknowledges what is +// on screen; leaving acknowledges the look that was compared on that visit. +// Decisions reuse the typed attention classification, the comparison waits +// for an owner refresh still in flight, and denied storage only loses memory. +const sinceYouLooked = source("./since-you-looked.ts"); +const seenMarkerType = sinceYouLooked.slice(sinceYouLooked.indexOf("export type SeenMarker = {"), sinceYouLooked.indexOf("};", sinceYouLooked.indexOf("export type SeenMarker = {"))); +assert.doesNotMatch(seenMarkerType, /cost|title|text|usd/i, "The marker carries no titles, text or cost"); +assert.match(sinceYouLooked, /export const seenMarkerIdLimit = 200;/, "Done and result ids are bounded"); +assert.match(page, /seenMarkerStorageKey\(seenStatusSource, selectedGoal\.goalId\)/, "Markers are scoped per status source and Goal"); +assert.match(page, /if \(delta\.material\) setSinceYouLooked\(/, "Only a material delta shows the line"); +assert.match(page, /advanceSeenMarker\(previous\.goalId, previous\.source, "compared"\);[\s\S]*?setSinceYouLooked\(\(shown\) => shown && shown\.goalId === previous\.goalId/, "Leaving the Goal records the compared look and acknowledges a shown line"); +assert.match(page, /advanceSeenMarker\(sinceYouLooked\.goalId, sinceYouLooked\.source, "latest"\)/, "Mark seen acknowledges the current look"); +assert.match(page, /if \(stored && stored\.observedAt > input\.observedAt\) return;/, "No write moves the marker back behind a newer acknowledgment"); +assert.match(page, /if \(look === "latest"\) comparedSeenInputsRef\.current\.set\(scope, input\);/, "Mark seen becomes the look the visit stands on, so leaving cannot undo it"); +assert.match(sinceYouLooked, /function coversWholeSet\(/, "Capped or partial id sets report count growth, never unretained ids as new"); +assert.match(sinceYouLooked, /newlyDone = coversWholeSet\(previous\.doneTodoIds, null\) \? doneById : 0;/, "With no done total a capped done history proves no completion"); +assert.match(page, /decisionIds: model\.userTodos\.filter\(\(item\) => item\.goalId === selectedGoal\.goalId && item\.blocking\)/, "Only items that wait on the owner are decisions; optional user actions keep the typed classification"); +assert.match(page, /if \(refreshState === "loading"\) return;[\s\S]*?seenComparedRef\.current = scope;/, "The comparison waits for an owner refresh still in flight instead of latching on the snapshot it replaces"); +const seenBlock = page.slice(page.indexOf("const seenStatusSource"), page.indexOf("function markSeen")); +assert.doesNotMatch(seenBlock, /window\.localStorage/, "Storage is acquired through the guarded helper, never read as a bare property"); +assert.match(sinceYouLooked, /export function seenMarkerStorage\([\s\S]*?try \{[\s\S]*?scope\.localStorage/, "Acquiring storage sits inside the failure boundary"); +assert.doesNotMatch(sinceYouLooked + page.slice(page.indexOf("const seenStatusSource"), page.indexOf("function markSeen")), /fetch\(|requestJson|\/api\//, "No service write or read belongs to the marker"); +for (const key of ["since.lead", "since.newlyDone", "since.newDecisions", "since.newlyBlocked", "since.resultsReturned", "since.markSeen"]) { + assert.equal((i18n.match(new RegExp(`"${key.replace(/\./g, "\\.")}":`, "g")) ?? []).length, 2, `${key} exists in both locales`); +} + // State-aware suggestions cite digest, Todo, execution and acceptance facts; // they never replace the shipped steward prompt row, and a stopped Goal or an // unknown execution state produces none. 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 33b872e11a..37c24c4991 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 @@ -90,6 +90,7 @@ const WorkspaceSettingsPage = lazy(() => import("./workspace-settings-page").the import { UsageStatisticsNotice } from "./usage-statistics-notice"; import { readWorkspaceTheme, writeWorkspaceTheme, type WorkspaceTheme } from "./workspace-theme"; import { compareProposalRecency } from "./proposal-recency"; +import { buildSeenMarker, compareSeenMarker, readSeenMarker, seenMarkerStorage, seenMarkerStorageKey, writeSeenMarker, type SeenMarkerInput, type SinceYouLookedDelta } from "./since-you-looked"; import { deriveGoalSuggestions, type GoalSuggestion } from "./goal-suggestions"; import { projectGoalAcceptanceCriteria } from "../../data/goal-acceptance-observation"; import { WorkspaceShell } from "./workspace-shell"; @@ -1250,6 +1251,89 @@ export function PersonalWorkspacePage({ && (selectedGoalTab === "overview" || selectedGoalTab === "tasks") && !selectedGoal?.loadState, selectionScope); const workspaceDigest = useGoalWorkspaceDigest(selectedGoal, model.userTodos, items, workspaceReview.usable ? workspaceReview.snapshot?.goal_map : null); + // "Since you looked" (RFC goal-workspace-surface §5.7): compare the current + // digest with this browser's last acknowledged look at this Goal on this + // source. Nothing here writes to the service or reads cost. Comparing on + // open can only under-count while a Goal is still loading (every count is + // "present now and not in the marker"); the marker itself advances from + // the latest complete look when the owner marks it seen or leaves. + const seenStatusSource = statusSourceControl?.activeSource.statusUrl ?? "/status.json"; + const seenGoalLoaded = Boolean(selectedGoal && !selectedGoal.loadState); + const seenMarkerInput = useMemo(() => { + if (!selectedGoal || !seenGoalLoaded) return null; + return { + observedAt: workspaceDigest.observedAt, + counts: { + open: workspaceDigest.work.open, blocked: workspaceDigest.work.classes.blocked, done: workspaceDigest.work.done, + decisions: workspaceDigest.needsYou.availability === "ok" ? workspaceDigest.needsYou.blocking : null, + }, + // Only an item that waits on the owner is a decision; an optional user action keeps the typed owner's classification. + decisionIds: model.userTodos.filter((item) => item.goalId === selectedGoal.goalId && item.blocking).map((item) => item.todoId), + doneTodoIds: workspaceDigest.work.rail.done, + blockedTodoIds: workspaceDigest.work.rail.blocked, + resultIds: items.flatMap((item) => item.kind === "output" && item.output.goalId === selectedGoal.goalId ? [item.output.outputId] : []), + }; + }, [selectedGoal, seenGoalLoaded, workspaceDigest, model.userTodos, items]); + const latestSeenInputsRef = useRef(new Map()); + const seenScopeKey = (source: string, goalId: string) => JSON.stringify([source, goalId]); + useEffect(() => { + if (selectedGoal && seenMarkerInput) latestSeenInputsRef.current.set(seenScopeKey(seenStatusSource, selectedGoal.goalId), seenMarkerInput); + }, [selectedGoal, seenMarkerInput, seenStatusSource]); + const [sinceYouLooked, setSinceYouLooked] = useState<{ goalId: string; source: string; delta: SinceYouLookedDelta } | null>(null); + const seenComparedRef = useRef(null); + const comparedSeenInputsRef = useRef(new Map()); + const seenVisitRef = useRef<{ source: string; goalId: string } | null>(null); + // "Mark seen" acknowledges what is on screen now and becomes the look this + // visit stands on; leaving acknowledges only the compared look (shown when + // material), so a change that arrived during the visit without being + // presented is still reported next time. No write moves the marker back + // behind a newer acknowledgment. + function advanceSeenMarker(goalId: string, source: string, look: "latest" | "compared") { + const scope = seenScopeKey(source, goalId); + const input = look === "latest" ? latestSeenInputsRef.current.get(scope) : comparedSeenInputsRef.current.get(scope); + if (!input) return; + const storage = seenMarkerStorage(); + const key = seenMarkerStorageKey(source, goalId); + const stored = readSeenMarker(storage, key); + if (stored && stored.observedAt > input.observedAt) return; + writeSeenMarker(storage, key, buildSeenMarker(input)); + if (look === "latest") comparedSeenInputsRef.current.set(scope, input); + } + useEffect(() => { + if (!selectedGoal || !seenGoalLoaded || !seenMarkerInput) return; + // An owner refresh still in flight when the Goal opens belongs to this + // look: against a real service it lands after the open, so compare once + // it has, not against the snapshot it is about to replace. + if (refreshState === "loading") return; + const scope = seenScopeKey(seenStatusSource, selectedGoal.goalId); + if (seenComparedRef.current === scope) return; + seenComparedRef.current = scope; + comparedSeenInputsRef.current.set(scope, seenMarkerInput); + const storage = seenMarkerStorage(); + const key = seenMarkerStorageKey(seenStatusSource, selectedGoal.goalId); + const previous = readSeenMarker(storage, key); + if (!previous) { writeSeenMarker(storage, key, buildSeenMarker(seenMarkerInput)); return; } + const delta = compareSeenMarker(previous, seenMarkerInput); + if (delta.material) setSinceYouLooked({ goalId: selectedGoal.goalId, source: seenStatusSource, delta }); + }, [selectedGoal, seenGoalLoaded, seenMarkerInput, seenStatusSource, refreshState]); + // Leaving a Goal records the look that was compared on this visit; if the + // line was shown, that look acknowledges it. + useEffect(() => { + const current = selectedGoal && seenGoalLoaded ? { source: seenStatusSource, goalId: selectedGoal.goalId } : null; + const previous = seenVisitRef.current; + if (previous && (!current || current.goalId !== previous.goalId || current.source !== previous.source)) { + advanceSeenMarker(previous.goalId, previous.source, "compared"); + seenComparedRef.current = null; + setSinceYouLooked((shown) => shown && shown.goalId === previous.goalId && shown.source === previous.source ? null : shown); + } + if (current) seenVisitRef.current = current; + else if (!selectedGoal) seenVisitRef.current = null; + }, [selectedGoal, seenGoalLoaded, seenStatusSource]); + function markSeen() { + if (!sinceYouLooked) return; + advanceSeenMarker(sinceYouLooked.goalId, sinceYouLooked.source, "latest"); + setSinceYouLooked(null); + } const goalSuggestions = useMemo(() => { if (!selectedGoal) return []; const contract = selectedGoal.acceptanceObservation?.goal_acceptance_contract; @@ -2353,7 +2437,7 @@ export function PersonalWorkspacePage({ ) : selectedGoal ? ( setRailScope(null)} digest={workspaceDigest} review={workspaceReview} active={!settingsOpen && selectedGoalTab === "overview"} goal={selectedGoal} items={items} userTodos={model.userTodos} readOnly={readOnly} + overview: setRailScope(null)} digest={workspaceDigest} review={workspaceReview} active={!settingsOpen && selectedGoalTab === "overview"} goal={selectedGoal} items={items} userTodos={model.userTodos} readOnly={readOnly} onOpenDetails={() => setSelection({ kind: "goal", item: selectedGoal })} onSelect={setSelection} onView={setSelectedGoalTab} selectedTodoId={drawerSelection?.kind === "todo" ? drawerSelection.item.todoId : null} board={taskBoard(!settingsOpen && selectedGoalTab === "overview")} results={goalResults(!settingsOpen && selectedGoalTab === "overview")} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/since-you-looked.test.mjs b/apps/presentation/dashboard/src/features/personal-workspace/since-you-looked.test.mjs new file mode 100644 index 0000000000..0554783829 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/since-you-looked.test.mjs @@ -0,0 +1,114 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildSeenMarker, compareSeenMarker, readSeenMarker, seenMarkerIdLimit, seenMarkerStorage, seenMarkerStorageKey, writeSeenMarker } from "./since-you-looked.ts"; + +const input = (overrides = {}) => ({ observedAt: "2026-10-15T08:00:00Z", counts: { open: 5, blocked: 1, done: 20, decisions: 1 }, decisionIds: ["d1"], doneTodoIds: ["a", "b"], blockedTodoIds: ["x"], resultIds: ["r1"], ...overrides }); + +test("a marker keeps typed counts and ids only, bounded", () => { + const marker = buildSeenMarker(input({ doneTodoIds: Array.from({ length: seenMarkerIdLimit + 50 }, (_, i) => `t${i}`) })); + assert.equal(marker.schema, "goal_seen_marker_v1"); + assert.equal(marker.doneTodoIds.length, seenMarkerIdLimit, "done ids are bounded to the newest"); + assert.equal(marker.doneTodoIds[0], "t50"); + assert.deepEqual(Object.keys(marker).sort(), ["counts", "decisionIds", "doneTodoIds", "observedAt", "resultIds", "schema"], "no titles, text or cost"); +}); + +test("a no-change pair stays silent", () => { + const marker = buildSeenMarker(input()); + const delta = compareSeenMarker(marker, input({ observedAt: "2026-10-15T09:00:00Z" })); + assert.equal(delta.material, false); + assert.deepEqual([delta.newlyDone, delta.newDecisions, delta.newlyBlocked, delta.resultsReturned], [0, 0, 0, 0]); + assert.equal(delta.since, "2026-10-15T08:00:00Z"); +}); + +test("known work, decision, blocked and result changes are counted since the marker", () => { + const marker = buildSeenMarker(input()); + const delta = compareSeenMarker(marker, input({ counts: { open: 4, blocked: 2, done: 22, decisions: 2 }, decisionIds: ["d1", "d2"], doneTodoIds: ["a", "b", "c", "d"], resultIds: ["r1", "r2"] })); + assert.deepEqual([delta.newlyDone, delta.newDecisions, delta.newlyBlocked, delta.resultsReturned], [2, 1, 1, 1]); + assert.equal(delta.material, true); +}); + +test("a shrinking queue or a resolved decision is not reported as new", () => { + const marker = buildSeenMarker(input({ decisionIds: ["d1", "d2"], counts: { open: 5, blocked: 3, done: 20, decisions: 2 } })); + const delta = compareSeenMarker(marker, input({ decisionIds: [], counts: { open: 5, blocked: 1, done: 20, decisions: 0 } })); + assert.equal(delta.material, false); +}); + +test("a trimmed marker cannot inflate newly done beyond the done-count growth", () => { + const many = Array.from({ length: seenMarkerIdLimit + 10 }, (_, i) => `t${i}`); + const marker = buildSeenMarker(input({ doneTodoIds: many, counts: { open: 5, blocked: 0, done: many.length, decisions: 1 } })); + const delta = compareSeenMarker(marker, input({ doneTodoIds: [...many, "new"], counts: { open: 4, blocked: 0, done: many.length + 1, decisions: 1 } })); + assert.equal(delta.newlyDone, 1, "the ten ids trimmed from the marker are old work, not new"); +}); + +test("without a projected done count the ids alone decide", () => { + const marker = buildSeenMarker(input({ counts: { open: 5, blocked: 0, done: null, decisions: 1 } })); + const delta = compareSeenMarker(marker, input({ counts: { open: 5, blocked: 0, done: null, decisions: 1 }, doneTodoIds: ["a", "b", "c"] })); + assert.equal(delta.newlyDone, 1); +}); + +test("storage round-trips per source and Goal and tolerates garbage", () => { + const store = new Map(); + const storage = { getItem: (k) => store.get(k) ?? null, setItem: (k, v) => { store.set(k, v); } }; + const key = seenMarkerStorageKey("/status.json", "g1"); + assert.notEqual(key, seenMarkerStorageKey("http://remote/status.json", "g1"), "sources keep separate markers"); + const marker = buildSeenMarker(input()); + assert.equal(writeSeenMarker(storage, key, marker), true); + assert.deepEqual(readSeenMarker(storage, key), marker); + store.set(key, "{not json"); + assert.equal(readSeenMarker(storage, key), null); + store.set(key, JSON.stringify({ schema: "other" })); + assert.equal(readSeenMarker(storage, key), null); + const throwing = { getItem: () => { throw new Error("blocked"); }, setItem: () => { throw new Error("blocked"); } }; + assert.equal(readSeenMarker(throwing, key), null); + assert.equal(writeSeenMarker(throwing, key, marker), false); +}); + +test("a browser that denies storage leaves the marker silent, whether the property or its methods throw", () => { + const key = seenMarkerStorageKey("/status.json", "g1"); + const denied = {}; + Object.defineProperty(denied, "localStorage", { get() { throw new Error("SecurityError: storage denied"); } }); + assert.equal(seenMarkerStorage(denied), null, "a throwing localStorage getter is caught at acquisition"); + assert.equal(seenMarkerStorage({}), null, "an absent storage is null, not a crash"); + assert.equal(seenMarkerStorage({ localStorage: undefined }), null); + assert.equal(readSeenMarker(null, key), null); + assert.equal(writeSeenMarker(null, key, buildSeenMarker(input())), false); + const store = new Map(); + const storage = { getItem: (k) => store.get(k) ?? null, setItem: (k, v) => { store.set(k, v); } }; + assert.equal(seenMarkerStorage({ localStorage: storage }), storage, "a usable storage passes through unchanged"); +}); + +test("past the id cap an unchanged decision set is silent and only count growth is new", () => { + const ids = Array.from({ length: seenMarkerIdLimit + 1 }, (_, i) => `g${i}`); + const marker = buildSeenMarker(input({ decisionIds: ids, counts: { open: 5, blocked: 0, done: 0, decisions: ids.length } })); + assert.equal(marker.decisionIds.length, seenMarkerIdLimit); + assert.equal(compareSeenMarker(marker, input({ decisionIds: ids, counts: { open: 5, blocked: 0, done: 0, decisions: ids.length } })).newDecisions, 0, "201 unchanged decisions are not one new decision"); + assert.equal(compareSeenMarker(marker, input({ decisionIds: [...ids.slice(1), "g0"], counts: { open: 5, blocked: 0, done: 0, decisions: ids.length } })).newDecisions, 0, "a reordered window is not a change"); + assert.equal(compareSeenMarker(marker, input({ decisionIds: [...ids, "g-new"], counts: { open: 5, blocked: 0, done: 0, decisions: ids.length + 1 } })).newDecisions, 1, "a real new decision shows as the count growth"); + const partial = buildSeenMarker(input({ decisionIds: ["d1"], counts: { open: 5, blocked: 0, done: 0, decisions: 3 } })); + assert.equal(compareSeenMarker(partial, input({ decisionIds: ["d1", "d2"], counts: { open: 5, blocked: 0, done: 0, decisions: 3 } })).newDecisions, 0, "a partially listed set that merely lists more is not new"); +}); + +test("within the cap a resolved-and-raised decision is still one new decision", () => { + const marker = buildSeenMarker(input({ decisionIds: ["d1", "d2"], counts: { open: 5, blocked: 0, done: 0, decisions: 2 } })); + assert.equal(compareSeenMarker(marker, input({ decisionIds: ["d2", "d3"], counts: { open: 5, blocked: 0, done: 0, decisions: 2 } })).newDecisions, 1); +}); + +test("results past the cap follow the same rule", () => { + const ids = Array.from({ length: seenMarkerIdLimit + 1 }, (_, i) => `r${i}`); + const marker = buildSeenMarker(input({ resultIds: ids })); + assert.equal(marker.counts.results, ids.length); + assert.equal(compareSeenMarker(marker, input({ resultIds: ids })).resultsReturned, 0); + assert.equal(compareSeenMarker(marker, input({ resultIds: [...ids, "r-new"] })).resultsReturned, 1); +}); + +test("with no done total a capped done history proves nothing; within the cap the ids decide", () => { + const ids = Array.from({ length: seenMarkerIdLimit + 1 }, (_, i) => `done${i}`); + const marker = buildSeenMarker(input({ doneTodoIds: ids, counts: { open: 5, blocked: 0, done: null, decisions: 1 } })); + assert.equal(marker.doneTodoIds.length, seenMarkerIdLimit); + assert.equal(compareSeenMarker(marker, input({ doneTodoIds: ids, counts: { open: 5, blocked: 0, done: null, decisions: 1 } })).newlyDone, 0, "201 unchanged completions with no total are not one new completion"); + assert.equal(compareSeenMarker(marker, input({ doneTodoIds: [...ids, "done-new"], counts: { open: 5, blocked: 0, done: null, decisions: 1 } })).newlyDone, 0, "past the cap with no total there is no evidence either way"); + const within = buildSeenMarker(input({ doneTodoIds: ["a", "b"], counts: { open: 5, blocked: 0, done: null, decisions: 1 } })); + assert.equal(compareSeenMarker(within, input({ doneTodoIds: ["a", "b", "c"], counts: { open: 5, blocked: 0, done: null, decisions: 1 } })).newlyDone, 1, "a complete history still counts a real completion"); + assert.equal(compareSeenMarker(marker, input({ doneTodoIds: [...ids, "done-new"], counts: { open: 5, blocked: 0, done: 202, decisions: 1 } })).newlyDone, 0, "a total that appears later cannot be compared with a marker that had none"); +}); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/since-you-looked.ts b/apps/presentation/dashboard/src/features/personal-workspace/since-you-looked.ts new file mode 100644 index 0000000000..d523c4a5e7 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/since-you-looked.ts @@ -0,0 +1,163 @@ +/** + * "Since you looked": what changed in a Goal between two of the owner's + * visits, per RFC goal-workspace-surface-v0 §5.7. + * + * The browser keeps one marker per (status source, Goal) in local storage: + * typed counts, decision ids, a bounded set of done Todo and result ids, and + * the observation time. No titles, content or cost ever enter it, and nothing + * is written to the service. A delta is reported only when it is material; + * cost is omitted because the usage projection cannot qualify the interval. + * The marker advances when the owner marks it seen or leaves the Goal after + * the line was shown. This is not a notification channel. + */ + +export const seenMarkerSchema = "goal_seen_marker_v1"; +export const seenMarkerIdLimit = 200; + +export type SeenMarker = { + schema: typeof seenMarkerSchema; + observedAt: string; + /** `decisions` is the attention owner's blocking count (null when unknown); `results` the returned-result count. */ + counts: { open: number; blocked: number; done: number | null; decisions: number | null; results: number }; + decisionIds: string[]; + doneTodoIds: string[]; + resultIds: string[]; +}; + +export type SeenMarkerInput = { + observedAt: string; + counts: { open: number; blocked: number; done: number | null; decisions: number | null }; + decisionIds: readonly string[]; + doneTodoIds: readonly string[]; + blockedTodoIds: readonly string[]; + resultIds: readonly string[]; +}; + +export type SinceYouLookedDelta = { + since: string; + newlyDone: number; + newDecisions: number; + newlyBlocked: number; + resultsReturned: number; + material: boolean; +}; + +function bounded(ids: readonly string[]): string[] { + return [...new Set(ids)].slice(-seenMarkerIdLimit); +} + +export function buildSeenMarker(input: SeenMarkerInput): SeenMarker { + return { + schema: seenMarkerSchema, + observedAt: input.observedAt, + counts: { open: input.counts.open, blocked: input.counts.blocked, done: input.counts.done, decisions: input.counts.decisions, results: input.resultIds.length }, + decisionIds: bounded(input.decisionIds), + doneTodoIds: bounded(input.doneTodoIds), + resultIds: bounded(input.resultIds), + }; +} + +/** + * Stored ids are evidence of what was seen only while they cover the whole + * set the owner projected. A capped set (more ids than the marker keeps) or a + * partially listed one cannot tell an unretained old item from a new one. + */ +function coversWholeSet(ids: readonly string[], count: number | null): boolean { + return count === null ? ids.length < seenMarkerIdLimit : ids.length >= count; +} + +/** + * Compare the current observation with the stored marker. Newly done work is + * the done ids not in the marker, bounded by the growth of the done count + * when the owner projects one, so a trimmed marker never inflates it; with + * no done total, ids count only while the marker kept them all. New + * decisions and results are ids not seen before while the marker covered the + * whole set; past the cap only growth of the owner's count is evidence, so an + * unchanged set never reports a change. Newly blocked work is the growth of + * the blocked count. A shrinking queue is not a change to report. + */ +export function compareSeenMarker(previous: SeenMarker, current: SeenMarkerInput): SinceYouLookedDelta { + const seenDone = new Set(previous.doneTodoIds); + const seenDecisions = new Set(previous.decisionIds); + const seenResults = new Set(previous.resultIds); + const doneById = current.doneTodoIds.filter((id) => !seenDone.has(id)).length; + let newlyDone: number; + if (previous.counts.done !== null && current.counts.done !== null) { + // The owner's done total is the evidence; ids can only refine it downward. + const doneByCount = Math.max(0, current.counts.done - previous.counts.done); + newlyDone = Math.min(doneById, doneByCount) || (doneByCount > 0 && doneById === 0 ? doneByCount : 0); + } else { + // Without a done total the ids are the only evidence, and only while the + // marker kept every one it saw; a capped history cannot prove a completion. + newlyDone = coversWholeSet(previous.doneTodoIds, null) ? doneById : 0; + } + const newDecisions = coversWholeSet(previous.decisionIds, previous.counts.decisions) + ? current.decisionIds.filter((id) => !seenDecisions.has(id)).length + : previous.counts.decisions !== null && current.counts.decisions !== null + ? Math.max(0, current.counts.decisions - previous.counts.decisions) : 0; + const newlyBlocked = Math.max(0, current.counts.blocked - previous.counts.blocked); + const resultsReturned = coversWholeSet(previous.resultIds, previous.counts.results) + ? current.resultIds.filter((id) => !seenResults.has(id)).length + : Math.max(0, current.resultIds.length - previous.counts.results); + return { + since: previous.observedAt, + newlyDone, newDecisions, newlyBlocked, resultsReturned, + material: newlyDone > 0 || newDecisions > 0 || newlyBlocked > 0 || resultsReturned > 0, + }; +} + +export function seenMarkerStorageKey(statusSource: string, goalId: string): string { + return `loopx-pw-seen:${statusSource}:${goalId}`; +} + +export type SeenMarkerStorage = Pick; + +/** + * Acquire the browser's local storage for the marker, or null when the + * browser denies it. Reading the `localStorage` property itself can throw + * (a SecurityError under a storage policy), so the acquisition sits inside + * the same boundary as the reads and writes: without storage the owner only + * loses the local look memory, never the Goal. + */ +export function seenMarkerStorage(scope: { localStorage?: SeenMarkerStorage } = typeof window === "undefined" ? {} : window): SeenMarkerStorage | null { + try { + const storage = scope.localStorage; + return storage && typeof storage.getItem === "function" && typeof storage.setItem === "function" ? storage : null; + } catch { + return null; + } +} + +export function readSeenMarker(storage: Pick | null, key: string): SeenMarker | null { + if (!storage) return null; + try { + const raw = storage.getItem(key); + if (!raw) return null; + const value = JSON.parse(raw) as Partial | null; + if (!value || value.schema !== seenMarkerSchema || typeof value.observedAt !== "string" || !value.counts) return null; + return { + schema: seenMarkerSchema, observedAt: value.observedAt, + counts: { + open: Number(value.counts.open ?? 0), blocked: Number(value.counts.blocked ?? 0), + done: value.counts.done === null || value.counts.done === undefined ? null : Number(value.counts.done), + decisions: value.counts.decisions === null || value.counts.decisions === undefined ? null : Number(value.counts.decisions), + results: Number(value.counts.results ?? 0), + }, + decisionIds: Array.isArray(value.decisionIds) ? value.decisionIds.filter((id): id is string => typeof id === "string") : [], + doneTodoIds: Array.isArray(value.doneTodoIds) ? value.doneTodoIds.filter((id): id is string => typeof id === "string") : [], + resultIds: Array.isArray(value.resultIds) ? value.resultIds.filter((id): id is string => typeof id === "string") : [], + }; + } catch { + return null; + } +} + +export function writeSeenMarker(storage: Pick | null, key: string, marker: SeenMarker): boolean { + if (!storage) return false; + try { + storage.setItem(key, JSON.stringify(marker)); + return true; + } catch { + return false; + } +} diff --git a/docs/architecture/rfcs/goal-workspace-surface-v0.md b/docs/architecture/rfcs/goal-workspace-surface-v0.md index 6c1fb4b42b..a246cda8f3 100644 --- a/docs/architecture/rfcs/goal-workspace-surface-v0.md +++ b/docs/architecture/rfcs/goal-workspace-surface-v0.md @@ -2,7 +2,7 @@ - **RFC status:** Accepted - **Supersedes / closes:** none -- **Delivery maturity:** M1 merged in [#6191](https://github.com/loopx-project/loopx/pull/6191). M2 Now, rail, shared selection, task links, inline creation and Results are proposed in [#6255](https://github.com/loopx-project/loopx/pull/6255), pending maintainer review and merge. M3–M4 remain unimplemented; installed and live-team qualification is separate. +- **Delivery maturity:** M1 merged in [#6191](https://github.com/loopx-project/loopx/pull/6191). M2 merged in [#6255](https://github.com/loopx-project/loopx/pull/6255). M3's since-you-looked delta is proposed (see the 2026-10-15 checkpoint); target declaration and M4 remain unimplemented; installed and live-team qualification is separate. - **Owners:** existing workspace presentation owner. Data and action owners named in Section 5 keep their authority. - **Created / normative revision:** 2026-10-11. - **Implementation baseline:** `5c62c5c03`. @@ -456,6 +456,33 @@ second upgrade. | M3 Direction and return | Since-you-looked delta; target declaration through the acceptance owner | M2; D3 decided by its owner | V5, V8 | Hide declaration; delete marker | | M4 Live and expressive | W2 live state on rows and map from the execution observation owner; event motion; keyboard map and palette; dark inverse | M2; W2 observation available | V6 (motion, dark); live-team W2 acceptance | Disable motion/theme | +**Checkpoint 2026-10-15 (M3 since-you-looked, proposed).** +Section 5.7 is implemented in the packaged page: a browser-local marker per (status source, Goal) +holding typed counts, decision ids, bounded done Todo and result ids and the observation time; +no titles, content or cost, no server write. On open the page compares the current digest with the +marker and shows one line only for a material delta (newly done, new decisions, newly blocked, +results returned), dismissable with "Mark seen". A decision is an item the typed attention lane +classifies as waiting on the owner; an optional user action is listed but never counted. The +comparison waits for an owner refresh still in flight when the Goal opens, so against a real +service it compares the look that lands, not the snapshot it replaces. One refinement of 5.7 is +recorded: "Mark seen" acknowledges what is on screen, while leaving a Goal records only the look +that was compared on that visit, so a visit with nothing new still advances the observation time +and a change that arrived during the visit without being presented is reported next time. A +browser that denies local storage loses only this memory; the Goal stays readable. "Mark seen" +becomes the look the visit stands on and no write moves the marker back behind a newer +acknowledgment. Stored ids are evidence only while they cover the owner's whole set: past the +200-id cap, or for a partially listed set, only growth of the owner's count reports a new decision, +result or completion, and with no done total a capped done history reports nothing, so an unchanged +set never reports a change. +V5 is covered by a node test (no-change pair, trimmed marker, unavailable done count, denied +storage, 201 unchanged decisions past the cap, 201 unchanged completions with no done total, +resolved-and-raised within it), a fixture browser +scenario (first visit silent, no-change return silent, work and decision changes shown and +dismissed, leave-acknowledge, refresh then Mark seen then leave keeps the acknowledgment, 201 +unchanged decisions silent, reload, no service write, denied storage getter) and a real-service scenario against a disposable Todo owner (optional user action silent, +CLI completion and gate counted as the lane counts them, Mark seen). Cost stays omitted per D7. +Target declaration (the other half of M3) remains undelivered. + **Checkpoint 2026-10-12 (M2 candidate, #6255).** The existing TypeScript digest classifies responsibility, unassigned work and recorded decision dependencies; masthead, Now, rail and Board share it. Map and delivery evidence share the existing snapshot read. diff --git a/docs/assets/personal-workspace/since-you-looked-line.png b/docs/assets/personal-workspace/since-you-looked-line.png new file mode 100644 index 0000000000..17e3a0a213 Binary files /dev/null and b/docs/assets/personal-workspace/since-you-looked-line.png differ diff --git a/docs/guides/personal-workspace-user-guide.md b/docs/guides/personal-workspace-user-guide.md index ec38357d75..d40dd1f3be 100644 --- a/docs/guides/personal-workspace-user-guide.md +++ b/docs/guides/personal-workspace-user-guide.md @@ -285,6 +285,32 @@ when the work list is complete and the owner's open count is known to be zero; a never read as finished. A stopped Goal gets no suggestion and unknown execution is never read as idle. The collapsed summary line shows the first suggestion. +### 4.1.2 自你上次查看 / Since you looked + +![回到 Goal 概览时的「自你上次查看」一行](../assets/personal-workspace/since-you-looked-line.png) + +回到一个 Goal 的概览时,如果自你上次查看以来有实质变化,概览顶部会出现一行安静的摘要: +「自你上次查看(3 小时前):新完成 2 项、新增 1 项等你决定、新受阻 1 项、新成果 1 项」,以及 +「标记已读」。它来自这个浏览器本地保存的上次查看标记(只有计数和编号,不含标题、内容或费用), +不写回服务,也不是通知。「等你决定」只计需要你介入的事项,可选提醒会列出但不计入。没有实质变化时 +不显示;点「标记已读」以当前画面为基线,离开这个 Goal 则以这次进入时看到的那一眼为基线,期间到达但 +没有呈现的变化下次回来仍会算入;已点过的「标记已读」不会被离开撤回。标记只保留每类最近 200 个编号, +超出后只按数量增长判断新增,没有总数时就不再报告,不会把没保留的旧项当成新项。浏览器不允许本地存储时只是失去这份记忆, +Goal 照常可读。费用变化暂不纳入:用量投影还不能可靠地界定两次查看之间的区间。 + +When you return to a Goal's overview after a material change, one quiet line at the top says what +changed since your last look ("Since you looked (3 hours ago): 2 newly done, 1 new decision for +you, 1 newly blocked, 1 result returned") with **Mark seen**. It comes from a marker this browser +keeps locally (counts and ids only, never titles, content or cost), writes nothing to the service +and is not a notification. "Decisions for you" counts only items that wait on you; an optional +reminder is listed but not counted. Nothing shows when nothing material changed. **Mark seen** makes +what is on screen the new baseline; leaving the Goal keeps the look you arrived to, so a change that +landed during the visit without being shown is still reported next time, and leaving never undoes a +**Mark seen**. The marker keeps the latest 200 ids of each kind; beyond that only growth of the +count reports something new (nothing at all when there is no total), never an older item the marker +did not retain. A browser that denies +local storage only loses this memory; the Goal stays readable. Spend is left out until the usage +projection can qualify the exact interval between two looks. ### 4.2 Goal 概览与交付依据 / Goal overview diff --git a/examples/personal-workspace-browser-smoke.mjs b/examples/personal-workspace-browser-smoke.mjs index 52981e51db..80d6a4f4f9 100644 --- a/examples/personal-workspace-browser-smoke.mjs +++ b/examples/personal-workspace-browser-smoke.mjs @@ -5,6 +5,8 @@ import {goalWorkspaceCreateScenario} from "./personal-workspace-browser/goal-wor import {agentModelSelectionScenario} from "./personal-workspace-browser/agent-model-selection.mjs"; import {goalWorkspaceM2Scenario} from "./personal-workspace-browser/goal-workspace-m2.mjs"; import {goalWorkspaceSelectionScenario} from "./personal-workspace-browser/goal-workspace-selection.mjs"; +import {sinceYouLookedScenario} from "./personal-workspace-browser/since-you-looked.mjs"; +import {sinceYouLookedLiveScenario} from "./personal-workspace-browser/since-you-looked-live.mjs"; import {firstGoalHomeScenario} from "./personal-workspace-browser/first-goal-home.mjs"; import {goalSuggestionsScenario} from "./personal-workspace-browser/goal-suggestions.mjs"; import {goalSuggestionsLiveScenario} from "./personal-workspace-browser/goal-suggestions-live.mjs"; @@ -112,6 +114,8 @@ scenarioCatalog.push(zcodeGoalScenario); scenarioCatalog.push(goalWorkspaceM2Scenario); scenarioCatalog.push(goalWorkspaceCreateScenario); scenarioCatalog.push(goalWorkspaceSelectionScenario); +scenarioCatalog.push(sinceYouLookedScenario); +scenarioCatalog.push(sinceYouLookedLiveScenario); scenarioCatalog.push(firstGoalHomeScenario); scenarioCatalog.push(goalSuggestionsScenario); scenarioCatalog.push(goalSuggestionsLiveScenario); diff --git a/examples/personal-workspace-browser/since-you-looked-live.mjs b/examples/personal-workspace-browser/since-you-looked-live.mjs new file mode 100644 index 0000000000..d9401c0dbf --- /dev/null +++ b/examples/personal-workspace-browser/since-you-looked-live.mjs @@ -0,0 +1,102 @@ +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.json while the owner looks at a Goal, leaves, and the CLI +// changes the Goal's Todos. An optional user action raised while away is not +// a decision; a completed agent task and a gate are, counted exactly as the +// typed attention lane counts them; Mark seen advances the marker. Only the +// status path is proxied; everything else keeps the fixture. + +const goalId = "progress-projection"; +const optionalText = "Consider renaming the export folder"; +const gateText = "Approve the restated FY2023 operating cash flow"; +const doneText = "Reconcile the March export"; + +export const sinceYouLookedLiveScenario = { + id: "since-you-looked-live", + async run({ browser, collectCoverage, url }) { + const root = await mkdtemp(join(tmpdir(), "loopx-seen-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 = "", ready, session; + child.stderr.on("data", (chunk) => { diagnostics = (diagnostics + chunk).slice(-4000); }); + const cli = (...args) => promisify(execFile)(python, ["-c", "from loopx.entrypoint import main; main()", "--format", "json", "--registry", ready.registry, "todo", ...args], { cwd: repoRoot, env: { ...process.env, PYTHONPATH: repoRoot } }).then(({ stdout }) => JSON.parse(stdout)); + try { + 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) => { + // The document URL also ends in "status.json" (its statusUrl query), so match the path, not a glob. + 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; + const line = page.getByTestId("goal-since-you-looked"); + const overview = page.locator(".goal-overview"); + const openGoal = async () => { + await page.locator(".personal-goal-row").first().locator(".personal-goal-link").click(); + await page.getByTestId("goal-vitals").waitFor({ state: "visible", timeout: 20_000 }); + await page.getByRole("navigation", { name: "Goal 视图" }).getByRole("button", { name: "概览", exact: true }).click(); + }; + const home = () => page.getByRole("button", { name: /^LoopX 管家/ }).first().click(); + const refresh = () => page.getByRole("button", { name: "刷新状态", exact: true }).click(); + await openGoal(); + await page.waitForTimeout(1_000); + assert.equal(await line.count(), 0, "A first look has nothing to compare"); + await home(); + + // Away: an optional reminder for the owner arrives. It is listed, but it is not a decision. + assert.equal((await cli("add", "--goal-id", goalId, "--role", "user", "--task-class", "user_action", "--text", optionalText)).ok, true); + await refresh(); + await openGoal(); + await overview.getByText(optionalText).first().waitFor({ state: "visible", timeout: 20_000 }); + await page.waitForTimeout(1_500); + assert.equal(await line.count(), 0, "An optional user action is not reported as a new decision"); + await home(); + + // Away: an agent task is added and completed, and a gate is raised. + const task = await cli("add", "--goal-id", goalId, "--role", "agent", "--task-class", "advancement_task", "--text", doneText); + assert.equal(task.ok, true); + const completed = await cli("complete", "--goal-id", goalId, "--role", "agent", "--todo-id", task.todo_id); + assert.equal(completed.ok, true, JSON.stringify(completed).slice(0, 200)); + assert.equal((await cli("add", "--goal-id", goalId, "--role", "user", "--task-class", "user_gate", "--text", gateText)).ok, true); + await refresh(); + await openGoal(); + await line.waitFor({ state: "visible", timeout: 20_000 }); + const text = await line.innerText(); + assert.match(text, /新完成 1 项/, `the real completion is counted once: ${text}`); + // The decision count is the typed attention lane's own count of items waiting on the owner. + const waiting = (await page.getByTestId("goal-vitals").innerText()).replace(/\s+/g, " ").match(/(\d+) 等你决定/); + assert.ok(waiting && Number(waiting[1]) >= 1, "the lane shows at least the gate waiting on the owner"); + assert.match(text, new RegExp(`新增 ${waiting[1]} 项等你决定`), `decisions match the lane (${waiting[1]}): ${text}`); + await page.screenshot({ path: resolve(outputDir, "since-you-looked-live.png"), animations: "disabled" }); + await line.getByRole("button", { name: "标记已读" }).click(); + await line.waitFor({ state: "hidden", timeout: 5_000 }); + await home(); + await openGoal(); + await page.waitForTimeout(1_000); + assert.equal(await line.count(), 0, "Nothing new after Mark seen"); + return { coverageEntries: await session.close(), note: "real Todo owner and status; CLI optional action, completion and gate reach the return line" }; + } catch (error) { + if (diagnostics) process.stderr.write(diagnostics); + await session?.close().catch(() => {}); + throw error; + } finally { + lines.close(); + if (child.exitCode === null && child.signalCode === null) { child.kill("SIGTERM"); const kill = setTimeout(() => child.kill("SIGKILL"), 5000); try { await closed; } finally { clearTimeout(kill); } } + } + }, +}; diff --git a/examples/personal-workspace-browser/since-you-looked.mjs b/examples/personal-workspace-browser/since-you-looked.mjs new file mode 100644 index 0000000000..6a3563ee4e --- /dev/null +++ b/examples/personal-workspace-browser/since-you-looked.mjs @@ -0,0 +1,174 @@ +import assert from "node:assert/strict"; +import { resolve } from "node:path"; +import { outputDir } from "./fixture.mjs"; +import { openWorkspacePage } from "./scenario-context.mjs"; + +// RFC goal-workspace-surface-v0 §5.7 / V5: a returning owner sees what changed +// since their last acknowledged look at a Goal, from a browser-local marker +// (typed counts and ids, no cost, no service write). A first visit seeds the +// marker silently; a no-change return shows nothing; known work and decision +// changes appear and can be dismissed; leaving after the line was shown +// advances the marker so the next return starts clean. + +function firstGoal(fixture) { return fixture.attention_queue.items[0]; } + +function addGate(fixture, todoId, text) { + const first = firstGoal(fixture); + 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: "2026-10-15T08:00:00Z" }); + first.user_todos.open_count = first.user_todos.items.filter((todo) => !todo.done).length; + first.user_todos.total_count = first.user_todos.items.length; +} + +function addDone(fixture, todoId, text) { + const first = firstGoal(fixture); + const todos = first.project_asset?.agent_todos ?? first.agent_todos; + todos.items.push({ done: true, status: "done", role: "agent", task_class: "advancement_task", goal_id: first.goal_id, todo_id: todoId, text, title: text, completed_at: "2026-10-15T09:00:00Z" }); + if (typeof todos.advancement_done_count === "number") todos.advancement_done_count += 1; + if (typeof todos.done === "number") todos.done += 1; +} + +export const sinceYouLookedScenario = { + id: "since-you-looked", + async run({ browser, collectCoverage, url }) { + const session = await openWorkspacePage(browser, url, { collectCoverage, viewport: { width: 1440, height: 900 } }); + const { api, page } = session; + const writes = []; + page.on("request", (request) => { if (request.method() !== "GET" && /\/api\//.test(request.url())) writes.push(`${request.method()} ${new URL(request.url()).pathname}`); }); + const openGoal = async () => { + await page.locator(".personal-goal-row").first().locator(".personal-goal-link").click(); + await page.getByTestId("goal-vitals").waitFor({ state: "visible", timeout: 15_000 }); + await page.getByRole("navigation", { name: "Goal 视图" }).getByRole("button", { name: "概览", exact: true }).click(); + }; + const home = () => page.getByRole("button", { name: /^LoopX 管家/ }).first().click(); + const refresh = () => page.getByRole("button", { name: "刷新状态", exact: true }).click(); + const line = page.getByTestId("goal-since-you-looked"); + try { + await openGoal(); + const goalId = await page.evaluate(() => Object.keys(localStorage).filter((key) => key.startsWith("loopx-pw-seen:/status.json:")).map((key) => key.split(":").pop())[0] ?? null); + assert.ok(goalId, "The first visit seeds a marker for this source and Goal"); + assert.equal(await line.count(), 0, "A first visit has nothing to compare"); + const marker = await page.evaluate((id) => JSON.parse(localStorage.getItem(`loopx-pw-seen:/status.json:${id}`)), goalId); + assert.equal(marker.schema, "goal_seen_marker_v1"); + assert.deepEqual(Object.keys(marker).sort(), ["counts", "decisionIds", "doneTodoIds", "observedAt", "resultIds", "schema"], "typed counts and ids only"); + + // A return with nothing new stays quiet. + await home(); await refresh(); await openGoal(); + await page.waitForTimeout(500); + assert.equal(await line.count(), 0, "No change stays absent"); + + // Work completed and a decision raised while away appear, and can be dismissed. + await home(); + api.transformStatus = (fixture) => { addDone(fixture, "seen-done-1", "Reconcile the March export"); addGate(fixture, "seen-gate-1", "Approve the restated FY2023 operating cash flow"); }; + await refresh(); + await openGoal(); + await line.waitFor({ state: "visible", timeout: 15_000 }); + const text = await line.innerText(); + assert.match(text, /自你上次查看(.+):/); + assert.match(text, /新完成 1 项/, `newly done counted once: ${text}`); + assert.match(text, /新增 1 项等你决定/, `new decision counted once: ${text}`); + assert.doesNotMatch(text, /成本|\$|¥/, "No cost in the delta"); + await page.screenshot({ path: resolve(outputDir, "since-you-looked-line.png"), animations: "disabled" }); + await line.getByRole("button", { name: "标记已读" }).click(); + await line.waitFor({ state: "hidden", timeout: 5_000 }); + const after = await page.evaluate((id) => JSON.parse(localStorage.getItem(`loopx-pw-seen:/status.json:${id}`)), goalId); + assert.ok(after.doneTodoIds.includes("seen-done-1") && after.decisionIds.includes("seen-gate-1"), "Mark seen advances the marker to the current observation"); + + // Leaving after the line was shown also advances it; the next return starts clean. + await home(); + api.transformStatus = (fixture) => { addDone(fixture, "seen-done-1", "Reconcile the March export"); addGate(fixture, "seen-gate-1", "Approve the restated FY2023 operating cash flow"); addGate(fixture, "seen-gate-2", "Choose the segment disclosure source"); }; + await refresh(); + await openGoal(); + await line.waitFor({ state: "visible", timeout: 15_000 }); + assert.match(await line.innerText(), /新增 1 项等你决定/); + await home(); + await openGoal(); + await page.waitForTimeout(500); + assert.equal(await line.count(), 0, "Leaving the Goal after the line was shown acknowledged it"); + + // An explicit Mark seen during a visit is never undone by leaving: the + // owner returns to two new decisions, refreshes to a third while looking, + // marks seen, leaves, and comes back to silence with all three retained. + await home(); + const threeGates = (fixture) => { addDone(fixture, "seen-done-1", "Reconcile the March export"); addGate(fixture, "seen-gate-1", "Approve the restated FY2023 operating cash flow"); addGate(fixture, "seen-gate-2", "Approve the vendor list"); addGate(fixture, "seen-gate-3", "Approve the Q4 calendar"); }; + api.transformStatus = threeGates; + await refresh(); + await openGoal(); + await line.waitFor({ state: "visible", timeout: 15_000 }); + api.transformStatus = (fixture) => { threeGates(fixture); addGate(fixture, "seen-gate-4", "Approve the audit scope"); }; + await refresh(); + await page.waitForTimeout(500); + await line.getByRole("button", { name: "标记已读" }).click(); + await line.waitFor({ state: "hidden", timeout: 5_000 }); + const acknowledged = await page.evaluate((id) => JSON.parse(localStorage.getItem(`loopx-pw-seen:/status.json:${id}`)), goalId); + assert.ok(acknowledged.decisionIds.includes("seen-gate-4"), "Mark seen records the refreshed look"); + await home(); + const afterLeave = await page.evaluate((id) => JSON.parse(localStorage.getItem(`loopx-pw-seen:/status.json:${id}`)), goalId); + assert.ok(afterLeave.decisionIds.includes("seen-gate-4"), "Leaving does not move the marker back behind the acknowledgment"); + assert.ok(afterLeave.observedAt >= acknowledged.observedAt); + await openGoal(); + await page.waitForTimeout(500); + assert.equal(await line.count(), 0, "Nothing is new after the acknowledged look"); + + // Reload keeps the marker; nothing was written to the service. + await page.reload({ waitUntil: "networkidle" }); + await page.getByTestId("personal-goal-home").waitFor({ state: "visible", timeout: 15_000 }); + await openGoal(); + await page.waitForTimeout(500); + assert.equal(await line.count(), 0, "The marker survives a reload"); + assert.deepEqual(writes.filter((row) => !/\/api\/chat\/(sessions|capabilities|agent-model-options|projection-messages|lark|goal-results)/.test(row)), [], `no service writes for the marker: ${writes.join(", ")}`); + + // Past the marker's id cap an unchanged set stays silent: 201 decisions + // and 201 completed rows with no done total on the first look, the same + // on the return, no "new" decision and no "newly done". + const cappedHistory = (fixture) => { + for (let i = 0; i <= 200; i += 1) { addGate(fixture, `seen-cap-${i}`, `Approve item ${i}`); addDone(fixture, `seen-done-cap-${i}`, `Completed item ${i}`); } + const first = firstGoal(fixture); + for (const group of [first.agent_todos, first.project_asset?.agent_todos]) { + if (!group) continue; + for (const key of ["advancement_done_count", "done_count", "done"]) delete group[key]; + } + }; + const capped = await openWorkspacePage(browser, url, { collectCoverage, viewport: { width: 1440, height: 900 }, + beforeGoto: (cappedApi) => { cappedApi.transformStatus = cappedHistory; } }); + try { + const cappedPage = capped.page; + const cappedLine = cappedPage.getByTestId("goal-since-you-looked"); + const openCapped = async () => { + await cappedPage.locator(".personal-goal-row").first().locator(".personal-goal-link").click(); + await cappedPage.getByTestId("goal-vitals").waitFor({ state: "visible", timeout: 15_000 }); + await cappedPage.getByRole("navigation", { name: "Goal 视图" }).getByRole("button", { name: "概览", exact: true }).click(); + }; + await openCapped(); + await cappedPage.waitForTimeout(500); + const cappedMarker = await cappedPage.evaluate(() => Object.entries(localStorage).filter(([key]) => key.startsWith("loopx-pw-seen:")).map(([, value]) => JSON.parse(value))[0]); + assert.ok(cappedMarker && cappedMarker.decisionIds.length === 200 && cappedMarker.counts.decisions >= 201, `the marker keeps 200 ids and the owner's count: ${JSON.stringify(cappedMarker?.counts)}`); + assert.ok(cappedMarker.doneTodoIds.length === 200 && cappedMarker.counts.done === null, `the done history is capped with no total: ${JSON.stringify(cappedMarker.counts)}`); + await cappedPage.getByRole("button", { name: /^LoopX 管家/ }).first().click(); + await openCapped(); + await cappedPage.waitForTimeout(800); + assert.equal(await cappedLine.count(), 0, "201 unchanged decisions and 201 unchanged completions with no total report nothing"); + await capped.close(); + } catch (error) { + await capped.close().catch(() => {}); + throw error; + } + + // A browser that denies storage (the property getter itself throws) keeps + // the Goal readable; only the local look memory is lost. + await home(); + await page.evaluate(() => { Object.defineProperty(window, "localStorage", { configurable: true, get() { throw new DOMException("Storage is disabled", "SecurityError"); } }); }); + await openGoal(); + await page.waitForTimeout(500); + assert.equal(await line.count(), 0, "Without storage there is no marker to compare"); + assert.doesNotMatch(await page.locator("body").innerText(), /页面暂时无法显示|Page unavailable/, "Denied storage never takes the Goal down"); + await line.waitFor({ state: "hidden" }); + await page.getByTestId("goal-vitals").waitFor({ state: "visible", timeout: 5_000 }); + return { coverageEntries: await session.close() }; + } catch (error) { + await session.close().catch(() => {}); + throw error; + } + }, +};