diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-activity-view.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-activity-view.tsx index 12eff91977..72f118514e 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-activity-view.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-activity-view.tsx @@ -1,5 +1,6 @@ import type { CSSProperties } from "react"; -import { goalHostDeliveryWindows, goalHostSurfaces, goalIdentity, hostSurfaceLabel, presentGoalActivity, type GoalActivity, type WorkspaceGoalExecution } from "./goal-activity"; +import { goalHostSurfaces, goalIdentity, goalObservationGaps, hostSurfaceLabel, presentGoalActivity, type GoalActivity, type WorkspaceGoalExecution } from "./goal-activity"; +import { ContextHelp } from "./context-help"; import { useWorkspaceI18n, type WorkspaceMessageKey } from "./i18n"; import type { WorkspaceGoal } from "./personal-workspace-model"; @@ -44,35 +45,58 @@ export function useExecutionDetail(execution: WorkspaceGoalExecution | undefined export function useGoalActivity(goal: GoalActivitySubject): GoalActivity & { text: string; - windowBadge: { state: "stale" | "missing"; labelKey: "activity.hostWindowStaleShort" | "activity.hostWindowMissingShort" } | null; + windowBadge: { state: "stale" | "missing"; text: string } | null; } { const { t } = useWorkspaceI18n(); const activity = presentGoalActivity(goal); const detail = useExecutionDetail(goal.execution); - const hostWindows = goalHostDeliveryWindows(goal); + const gaps = goalObservationGaps(goal); const parts = [t(activity.labelKey as WorkspaceMessageKey)]; - if (activity.alsoKey) parts.push(t(activity.alsoKey as WorkspaceMessageKey, { host: hostNames(goalHostSurfaces(goal)) })); + if (activity.alsoKey && !(gaps.unavailable && activity.alsoKey === "activity.executionUnknown")) parts.push(t(activity.alsoKey as WorkspaceMessageKey, { host: hostNames(goalHostSurfaces(goal)) })); if (activity.labelKey === "activity.running" || activity.labelKey === "activity.hostClaimed") parts.push(...detail); - for (const hostWindow of hostWindows) { - const host = hostSurfaceLabel(hostWindow.hostSurface); - const agent = hostWindow.agentId || "Agent"; - if (hostWindow.state === "stale") { - parts.push(t("activity.hostWindowStale", { host, agent, hours: Math.round((hostWindow.ageHours ?? 0) * 10) / 10 })); - } else if (hostWindow.state === "missing") { - parts.push(t("activity.hostWindowMissing", { host, agent })); - } else { - parts.push(t("activity.hostWindowFresh", { host, agent })); - } - } - const missedWindow = hostWindows.find((window) => window.state === "stale" || window.state === "missing"); - const windowBadge = missedWindow?.state === "stale" - ? { state: "stale" as const, labelKey: "activity.hostWindowStaleShort" as const } - : missedWindow?.state === "missing" - ? { state: "missing" as const, labelKey: "activity.hostWindowMissingShort" as const } - : null; + const windowBadge = goal.activationState === "stopped" || (activity.labelKey === "activity.observationUnavailable" && gaps.unavailable) + || (!gaps.missing && !gaps.stale && !gaps.incomplete) ? null : { + state: gaps.stale ? "stale" as const : "missing" as const, + text: gaps.unavailable ? t("activity.observationUnavailable") + : gaps.missing || gaps.stale ? t("activity.observationGaps", { count: gaps.missing + gaps.stale }) + : t("activity.observationIncomplete"), + }; return { ...activity, text: parts.join(" · "), windowBadge }; } +const observationReasons: Readonly> = { + unsupported_host: "activity.reason.unsupportedHost", + store_unavailable: "activity.reason.storeUnavailable", + thread_not_found: "activity.reason.threadNotFound", + record_unrecognized: "activity.reason.recordUnrecognized", + no_turn_marker: "activity.reason.noTurnMarker", + unparseable_observation: "activity.reason.unparseableObservation", +}; + +/** A sibling of the Goal navigation button so help never selects a Goal. */ +export function GoalActivityHelp({ goal }: { goal: GoalActivitySubject }) { + const { locale, t } = useWorkspaceI18n(); + const threads = goal.hostThreadActivity?.threads ?? []; + if (!goal.hostThreadActivity || goal.activationState === "stopped" || goal.loadState) return null; + return + {goal.hostThreadActivity?.completeness === "incomplete" ?

{t("activity.observationIncomplete")}

: null} + +
; +} + export function GoalIdentityMark({ goal, size = "md" }: { goal: GoalActivitySubject; size?: "md" | "lg" }) { const activity = presentGoalActivity(goal); const identity = goalIdentity(goal.goalId, goal.title); @@ -88,12 +112,11 @@ export function GoalIdentityMark({ goal, size = "md" }: { goal: GoalActivitySubj } export function GoalActivityChip({ goal }: { goal: GoalActivitySubject }) { - const { t } = useWorkspaceI18n(); const activity = useGoalActivity(goal); return ( ); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-activity.test.mjs b/apps/presentation/dashboard/src/features/personal-workspace/goal-activity.test.mjs index 23e3751ed2..b82799533a 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-activity.test.mjs +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-activity.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { goalExecution, goalHostDeliveryWindows, goalWorkKind, presentGoalActivity, taskSessionStatus } from "../../../node_modules/.cache/loopx-goal-activity/goal-activity.js"; +import { goalExecution, goalObservationGaps, goalWorkKind, presentGoalActivity, taskSessionStatus } from "../../../node_modules/.cache/loopx-goal-activity/goal-activity.js"; // One Goal's session facts, mixed by mode. `updated_at` stands for the time the // owner last recorded anything for that session. @@ -19,19 +19,9 @@ const recent = minutesAgo(2); const silent = minutesAgo(30); const old = minutesAgo(45); -assert.deepEqual(goalHostDeliveryWindows({ hostThreadActivity: { threads: [ - { agentId: "codex", hostSurface: "codex-app", state: "idle", lastEventAt: recent, deliveryWindow: { state: "stale", ageHours: 3.5 } }, - { agentId: "cursor-agent", hostSurface: "cursor", state: "idle", lastEventAt: recent, deliveryWindow: { state: "fresh", ageHours: 0.2 } }, -] } }), [ - { agentId: "codex", hostSurface: "codex-app", state: "stale", ageHours: 3.5 }, - { agentId: "cursor-agent", hostSurface: "cursor", state: "fresh", ageHours: 0.2 }, -]); -assert.deepEqual(goalHostDeliveryWindows({ hostThreadActivity: { threads: [ - { agentId: "codex", hostSurface: "codex-app", state: "unknown", lastEventAt: null, deliveryWindow: { state: "missing", reason: "no_observation" } }, -] } }), [{ agentId: "codex", hostSurface: "codex-app", state: "missing", ageHours: null }]); -assert.deepEqual(goalHostDeliveryWindows({ hostThreadActivity: { threads: [ +assert.deepEqual(goalObservationGaps({ hostThreadActivity: {completeness: "complete", threads: [ { agentId: "codex", hostSurface: "codex-app", state: "idle", lastEventAt: recent, deliveryWindow: { state: "unknown", reason: "no_expectation" } }, -] } }), [], "an unavailable cadence expectation is not a missed window"); +] } }), {missing: 0, stale: 0, unavailable: false, incomplete: false}, "an unavailable cadence expectation is not a missed window"); function executionOf(sessions, observation) { const execution = goalExecution(sessions, "goal-a", observation, now); @@ -103,6 +93,37 @@ assert.equal(freshHost.quiet, false); assert.equal(presentGoalActivity({ activationState: "active", execution: freshHost, state: "推进" }).live, true); assert.equal(goalExecution(undefined, "goal-a", hostObservation(recent), now).kind, "running"); assert.equal(goalExecution(null, "goal-a", hostObservation(minutesAgo(8 * 60)), now).kind, "unknown"); +assert.equal(goalExecution([], "goal-a", hostObservation(minutesAgo(8 * 60)), now).kind, "unknown", "An old open marker proves neither current execution nor an ended turn"); +const unreadable = { hostSurface: "codex-app", state: "unknown", reason: "no_turn_marker", lastEventAt: null, + deliveryWindow: { state: "missing", reason: "no_observation" } }; +const mixedHosts = { completeness: "complete", threads: [...hostObservation(recent).threads, unreadable] }; +assert.equal(executionOf([], mixedHosts).lastActivityAt, recent, "An unreadable lane cannot replace another lane's clock"); +assert.equal(goalObservationGaps({hostThreadActivity: mixedHosts}).unavailable, false); +assert.equal(goalObservationGaps({hostThreadActivity: mixedHosts}).missing, 1); +for (const reason of ["unsupported_host", "thread_not_found", "store_unavailable", "record_unrecognized", "no_turn_marker"]) { + const observation = {completeness: "complete", threads: [{...unreadable, reason}]}; + for (const sessions of [undefined, null, []]) { + assert.equal(goalExecution(sessions, "goal-a", observation, now).kind, "unknown", `${reason}: absent host evidence cannot establish idleness`); + } + assert.equal(goalObservationGaps({hostThreadActivity: observation}).unavailable, true); + assert.equal(executionOf([session("managed", "managed_runtime", recent)], observation).lastActivityAt, recent, + "Managed execution also survives a missing host observation"); +} +const endedAndMissing = {completeness: "complete", threads: [ + {hostSurface: "codex-app", state: "idle", lastEventAt: recent, deliveryWindow: {state: "fresh"}}, unreadable, +]}; +assert.equal(goalExecution([], "goal-a", endedAndMissing, now).kind, "unknown", "Recent completed activity is not execution or proof of all-host idleness"); +assert.deepEqual(goalObservationGaps({hostThreadActivity: endedAndMissing}), {missing: 1, stale: 0, unavailable: false, incomplete: false}); +assert.equal(goalExecution([], "goal-a", {completeness: "incomplete", threads: []}, now).kind, "unknown"); +assert.equal(goalExecution([], "goal-a", {completeness: "complete", threads: [{hostSurface: "codex-app", state: "archived", lastEventAt: null}]}, now).kind, "idle"); +assert.equal(goalObservationGaps({hostThreadActivity: {completeness: "complete", threads: [ + {hostSurface: "codex-app", state: "idle", lastEventAt: old, deliveryWindow: {state: "stale"}}, unreadable, +]}}).stale, 1, "An actual cadence miss stays visible beside missing coverage"); +assert.deepEqual(goalObservationGaps({hostThreadActivity: {completeness: "complete", threads: [ + {hostSurface: "codex-app", state: "turn_open", lastEventAt: "unreadable", deliveryWindow: {state: "stale"}}, +]}}), {missing: 1, stale: 0, incomplete: false, unavailable: true}, "One unreadable lane is counted once even when its cadence payload is inconsistent"); +assert.equal(presentGoalActivity({activationState: "active", state: "等待条件", execution: {kind: "unknown"}, hostThreadActivity: {completeness: "complete", threads: [unreadable]}}).labelKey, + "state.waiting", "A known wait condition survives missing execution coverage"); for (const completeness of ["incomplete", undefined]) { const partialIdle = presentGoalActivity({ activationState: "active", state: "已安排", boundHostSurfaces: ["codex-app"], diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-activity.ts b/apps/presentation/dashboard/src/features/personal-workspace/goal-activity.ts index 8206882968..0150d3c5a3 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-activity.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-activity.ts @@ -45,6 +45,8 @@ export type GoalHostThread = { agentId?: string | null; hostSurface: string; state: "turn_open" | "idle" | "archived" | "unknown"; + /** Host-owned read failure, distinct from the cadence window's reason. */ + reason?: string | null; lastEventAt: string | null; deliveryWindow?: { state: "fresh" | "stale" | "missing" | "unknown"; @@ -100,6 +102,10 @@ export function goalExecution( const open = (sessions ?? []).filter((session) => session.goal_id === goalId && session.status !== "closed"); const active = open.filter((session) => Boolean(session.active_turn_id)); if (active.length === 0 && hostTurns.length === 0) { + // An idle managed-session list cannot establish that unreadable or omitted + // host lanes are idle. Keep their coverage gap independent of other lanes. + if (hostThreadActivity && (hostThreadActivity.completeness !== "complete" + || hostThreadActivity.threads.some((thread) => thread.state !== "idle" && thread.state !== "archived"))) return { kind: "unknown" }; if (sessions === undefined) return undefined; return sessions === null ? { kind: "unknown" } : { kind: "idle", hostSurfaces: hostSurfacesOf(open) }; } @@ -149,18 +155,23 @@ export function goalHostSurfaces(goal: Pick) { - const windows = (goal.hostThreadActivity?.threads ?? []) - .filter((thread) => thread.deliveryWindow?.state === "stale" || thread.deliveryWindow?.state === "missing" || thread.deliveryWindow?.state === "fresh") - .map((thread) => ({ - agentId: thread.agentId ?? null, - hostSurface: thread.hostSurface, - state: thread.deliveryWindow!.state as "fresh" | "stale" | "missing", - ageHours: thread.deliveryWindow?.ageHours ?? null, - })); - const rank = { stale: 0, missing: 1, fresh: 2 } as const; - return windows.sort((a, b) => rank[a.state] - rank[b.state]); +/** Local presentation counts, not a Goal execution verdict. A missing lane + * cannot erase another lane's observed execution or recent completed turn. */ +export function goalObservationGaps(goal: Pick) { + const observation = goal.hostThreadActivity; + const threads = observation?.threads ?? []; + let missing = 0, stale = 0; + for (const thread of threads) { + if (thread.state === "unknown" || thread.deliveryWindow?.state === "missing" + || (thread.state === "turn_open" && (!thread.lastEventAt || Number.isNaN(Date.parse(thread.lastEventAt)))) + || thread.deliveryWindow?.reason === "unparseable_observation") missing++; + else if (thread.deliveryWindow?.state === "stale") stale++; + } + return { + missing, stale, + incomplete: observation?.completeness === "incomplete", + unavailable: threads.length > 0 && missing === threads.length, + }; } export type GoalActivityTone = "running" | "attention" | "danger" | "waiting" | "queued" | "quiet" | "stopped"; @@ -214,6 +225,9 @@ export function presentGoalActivity(goal: GoalActivityInput): GoalActivity { if (work === "unrecognized") return { labelKey: "activity.modeUnrecognized", tone: "attention", live: false, alsoKey: null }; if (work === "executing") return { labelKey: "activity.running", tone: live ? "running" : "attention", live, alsoKey: modeAlsoKey }; if (goal.state === "等待条件") return { labelKey: "state.waiting", tone: "waiting", live: false, alsoKey: null }; + if (goal.execution?.kind === "unknown" && goal.hostThreadActivity && goal.state !== "已完成") { + return { labelKey: "activity.observationUnavailable", tone: "queued", live: false, alsoKey: null }; + } if (goal.state === "已安排") { const hostThreads = goal.hostThreadActivity?.threads ?? []; const hostsIdle = goal.hostThreadActivity?.completeness === "complete" diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-sidebar.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-sidebar.tsx index 1d3a7634fe..025f2e651c 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-sidebar.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-sidebar.tsx @@ -4,7 +4,7 @@ import { DesktopUpdate } from "./desktop-update"; import { useGoalOrder } from "./use-goal-order"; import { goalHierarchy, visibleGoalTree } from "./goal-hierarchy"; -import { GoalIdentityMark, useGoalActivity } from "./goal-activity-view"; +import { GoalActivityHelp, GoalIdentityMark, useGoalActivity } from "./goal-activity-view"; import { useWorkspaceI18n } from "./i18n"; import type { WorkspaceGoal, WorkspaceGoalArchiveLoadState } from "./personal-workspace-model"; import { StatusSourceSwitcher, type StatusSourceControl } from "./status-source-switcher"; @@ -16,7 +16,7 @@ function GoalRowActivity({ goal, showLoad }: { goal: WorkspaceGoal; showLoad: bo return ( {activity.text} - {activity.windowBadge ? {t(activity.windowBadge.labelKey)} : null} + {activity.windowBadge ? {activity.windowBadge.text} : null} ); } @@ -103,8 +103,10 @@ export function GoalSidebar({ {tree.parents.has(goal.goalId) ? {t("sidebar.childOf", {goal: tree.byId.get(tree.parents.get(goal.goalId)!)!.title})} : null} - {tree.invalidParents.has(goal.goalId) && goal.goalId !== rootGoalId ? {t("sidebar.invalidParent")} : } + {tree.invalidParents.has(goal.goalId) && goal.goalId !== rootGoalId ? {t("sidebar.invalidParent")} + : !sorting && goal.hostThreadActivity && !stopped && !goal.loadState ? null : } + {!sorting ? : null} {!stopped && sorting ?
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index da5dd35fce..98bd128450 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -1432,11 +1432,20 @@ const en = { "activity.inHost": "handled by a {host} thread; the host owns live state", "activity.hostClaimed": "Claimed by host", "activity.hostIdle": "{host} thread idle", - "activity.hostWindowFresh": "{agent} on {host}: activity within its expected window", - "activity.hostWindowStale": "{agent} on {host}: activity outside its expected window ({hours} h)", - "activity.hostWindowMissing": "{agent} on {host}: activity not observed", - "activity.hostWindowStaleShort": "window stale", - "activity.hostWindowMissingShort": "no host activity", + "activity.hostWindowStale": "Activity outside its expected window ({hours} h)", + "activity.observationUnavailable": "state unavailable", + "activity.observationGaps": "gaps · {count}", + "activity.observationIncomplete": "partial coverage", + "activity.observationDetails": "Observed activity · {goal}", + "activity.observedOpen": "Turn has no recorded end", + "activity.observedEnded": "Last turn ended", + "activity.observedArchived": "Thread archived", + "activity.reason.unsupportedHost": "This host does not provide local activity observations.", + "activity.reason.storeUnavailable": "The host's local records could not be read.", + "activity.reason.threadNotFound": "The bound thread was not found in the available host records.", + "activity.reason.recordUnrecognized": "The host record format could not be recognized.", + "activity.reason.noTurnMarker": "The bounded host history contains no turn start or end. Execution remains unknown.", + "activity.reason.unparseableObservation": "The recorded activity time could not be read.", "activity.claimedAt": "claimed {time}", "activity.viaHost": "{host} host", "activity.quiet": "no new activity for {minutes} min", @@ -3027,11 +3036,20 @@ const zhCN: Record = { "activity.inHost": "由 {host} 线程负责,执行情况以宿主为准", "activity.hostClaimed": "宿主已领取", "activity.hostIdle": "{host} 线程空闲", - "activity.hostWindowFresh": "{agent}({host}):活动处于预期时间窗内", - "activity.hostWindowStale": "{agent}({host}):活动超出预期时间窗({hours} 小时)", - "activity.hostWindowMissing": "{agent}({host}):未观察到活动", - "activity.hostWindowStaleShort": "活动超时", - "activity.hostWindowMissingShort": "未观察到活动", + "activity.hostWindowStale": "活动超出预期时间窗({hours} 小时)", + "activity.observationUnavailable": "状态不可读", + "activity.observationGaps": "缺口 {count}", + "activity.observationIncomplete": "覆盖不完整", + "activity.observationDetails": "活动观测 · {goal}", + "activity.observedOpen": "回合尚无结束记录", + "activity.observedEnded": "最近回合已结束", + "activity.observedArchived": "线程已归档", + "activity.reason.unsupportedHost": "此宿主尚不提供本机活动观测。", + "activity.reason.storeUnavailable": "暂时无法读取宿主的本机记录。", + "activity.reason.threadNotFound": "可读取的宿主记录中未找到绑定的线程。", + "activity.reason.recordUnrecognized": "暂时无法识别宿主记录格式。", + "activity.reason.noTurnMarker": "有限的宿主历史中没有回合开始或结束标记,执行状态仍未知。", + "activity.reason.unparseableObservation": "暂时无法读取记录中的活动时间。", "activity.claimedAt": "领取于{time}", "activity.viaHost": "{host} 宿主", "activity.quiet": "已 {minutes} 分钟无新动静", 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 b190bc3c55..83d93b8bbe 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css @@ -130,6 +130,8 @@ .personal-goal-row:has(.personal-goal-move-actions) { grid-template-columns: minmax(0, 1fr) 52px 28px; } .personal-goal-row.has-goal-tree { grid-template-columns: 22px minmax(0, 1fr) auto auto; } .personal-goal-row.has-goal-tree:has(.personal-goal-move-actions) { grid-template-columns: 22px minmax(0, 1fr) 52px 28px; } +.personal-goal-row:has(> .context-help) { grid-template-columns: minmax(0, 1fr) 32px 28px; } +.personal-goal-row.has-goal-tree:has(> .context-help) { grid-template-columns: 22px minmax(0, 1fr) 32px 28px; } .personal-goal-row[data-reorder-goal] > .personal-goal-link { cursor: grab; user-select: none; } .personal-goal-row[data-reorder-goal] > .personal-goal-link:active { cursor: grabbing; } .personal-goal-row.is-drop-before { box-shadow: 0 -2px var(--color-link, #0070f3); } @@ -179,7 +181,14 @@ .personal-goal-activity-chip { --pw-tone: #a1a1a1; display: inline-flex; align-items: center; gap: 6px; min-width: 0; max-width: 100%; color: var(--pw-muted); font-size: var(--text-caption); line-height: var(--leading-caption); } .personal-goal-activity-chip > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .personal-goal-window-badge { flex: none; padding: 1px 5px; border-radius: 99px; background: var(--pw-amber-bg); color: var(--pw-amber); font-size: var(--text-caption); line-height: var(--leading-caption); } -.personal-goal-window-badge.is-missing { background: var(--pw-red-bg); color: var(--pw-red); } +.personal-goal-window-badge.is-missing { background: var(--pw-hover); color: var(--pw-muted); } +.personal-goal-observation-list { margin: 12px 0 0; padding: 0; list-style: none; } +.personal-goal-observation-list li + li { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--pw-line); } +.personal-goal-observation-list p { margin-top: 4px; } +@media (max-width: 640px) { + .personal-goal-row:has(> .context-help) { grid-template-columns: minmax(0, 1fr) 44px 28px; } + .personal-goal-row.has-goal-tree:has(> .context-help) { grid-template-columns: 22px minmax(0, 1fr) 44px 28px; } +} .personal-goal-activity-dot { position: relative; flex: none; width: 7px; height: 7px; box-sizing: border-box; border-radius: 50%; background: var(--pw-tone); } .personal-goal-activity-chip.is-queued .personal-goal-activity-dot, .personal-goal-activity-chip.is-waiting .personal-goal-activity-dot { border: 1.5px solid var(--pw-tone); background: transparent; } .personal-goal-activity-chip.is-live { color: var(--pw-text); } diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx index 8e636a3d68..42d24dc3c5 100644 --- a/apps/presentation/dashboard/src/views/dashboard-page.tsx +++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx @@ -1090,6 +1090,7 @@ function buildPersonalHomeModel( hostSurface: thread.host_surface, lastEventAt: thread.last_event_at ?? null, state: thread.state, + reason: thread.reason ?? null, deliveryWindow: thread.delivery_window ? { state: thread.delivery_window.state, ageHours: thread.delivery_window.age_hours ?? null, diff --git a/docs/development/assets/runtime-source-analysis/goal-activity-before.png b/docs/development/assets/runtime-source-analysis/goal-activity-before.png new file mode 100644 index 0000000000..78cf9fab8e Binary files /dev/null and b/docs/development/assets/runtime-source-analysis/goal-activity-before.png differ diff --git a/docs/development/assets/runtime-source-analysis/goal-activity-desktop.png b/docs/development/assets/runtime-source-analysis/goal-activity-desktop.png new file mode 100644 index 0000000000..1f930c020f Binary files /dev/null and b/docs/development/assets/runtime-source-analysis/goal-activity-desktop.png differ diff --git a/docs/development/assets/runtime-source-analysis/goal-activity-mobile.png b/docs/development/assets/runtime-source-analysis/goal-activity-mobile.png new file mode 100644 index 0000000000..c62aa40098 Binary files /dev/null and b/docs/development/assets/runtime-source-analysis/goal-activity-mobile.png differ diff --git a/docs/development/design.md b/docs/development/design.md index 4dce05ee9c..f9352ee314 100644 --- a/docs/development/design.md +++ b/docs/development/design.md @@ -81,6 +81,11 @@ Owner-facing state follows four rules: "executing", "running") requires an observed active worker, session or turn. A stored status, a registration or a count is not activity; unknown stays visibly unknown. + Aggregate mixed Agent lanes without letting one unreadable lane erase + another's observed activity. Distinguish a recent ended turn from execution. + Show missing coverage neutrally beside the Goal state, with each affected + Agent, host and read failure available through the shared contextual help. + A cadence miss is an older observation, not proof of a stopped worker. - **Group by responsibility.** Collaboration surfaces place work under the member, Agent or owner responsible for it, so one glance shows who is doing what and who is blocked. Work with no resolvable owner goes into an explicit diff --git a/examples/personal-workspace-browser/goal-activity.mjs b/examples/personal-workspace-browser/goal-activity.mjs index 3ae65c6e3d..da94602c30 100644 --- a/examples/personal-workspace-browser/goal-activity.mjs +++ b/examples/personal-workspace-browser/goal-activity.mjs @@ -72,7 +72,7 @@ async function leadVisible(row, prefix) { const range = document.createRange(); range.setStart(node, start); range.setEnd(node, start + lead.length); - const clip = element.getBoundingClientRect(); + const clip = (node.parentElement.closest(".personal-goal-row-activity-copy") || element).getBoundingClientRect(); return range.getBoundingClientRect().right <= clip.right + 0.5; }, prefix); } @@ -204,6 +204,98 @@ export const goalActivityScenario = { throw error; } + // A lane's missing record is coverage, not a verdict that the Goal stopped. + // Completed activity, open execution and cadence misses retain their own + // clocks and reasons. This uses the packaged status projection unchanged. + const gaps = await openWorkspacePage(browser, url, { + collectCoverage, + beforeGoto: api => { + api.hostThreadActivity = { + "product-release": {completeness: "complete", threads: [ + {...thread("turn_open", minutesAgo(1)), agent_id: "worker-active", delivery_window: {state: "fresh"}}, + {...thread("unknown", null), agent_id: "worker-unknown", reason: "no_turn_marker", delivery_window: {state: "missing", reason: "no_observation"}}, + {...thread("idle", minutesAgo(90)), agent_id: "worker-older", delivery_window: {state: "stale", age_hours: 1.5}}, + ]}, + "research-monitor": {completeness: "complete", threads: ["unsupported_host", "thread_not_found", "store_unavailable", "record_unrecognized"].map((reason, i) => ({ + ...thread("unknown", null), agent_id: `unreadable-${i}`, reason, + delivery_window: {state: "missing", reason: "no_observation"}, + }))}, + "multi-agent-projection": {completeness: "complete", threads: [{...thread("idle", minutesAgo(1)), delivery_window: {state: "fresh"}}]}, + }; + }, + }); + try { + const {api, page} = gaps; + const running = await goalRow(page, "Product Release"); + await running.locator("small", {hasText: "执行中"}).waitFor(); + await page.screenshot({path: resolve(outputDir, "goal-activity-observation-first.png"), animations: "disabled"}); + assert.equal(await running.locator(".personal-goal-mark.is-live").count(), 1); + assert.match(await running.locator("small").innerText(), /缺口 2/); + assert.doesNotMatch(await running.locator("small").innerText(), /未观察到活动|worker-|预期时间窗/); + assert.ok(await leadVisible(running, "执行中"), "The leading execution fact survives the gap badge"); + const help = running.getByRole("button", {name: "活动观测 · Product Release", exact: true}); + const selectedBeforeHelp = new URL(page.url()).searchParams.get("goalId"); + await help.hover(); + const bubble = page.getByRole("tooltip").filter({hasText: "worker-active"}); + await bubble.waitFor({state: "visible"}); + await bubble.hover(); + assert.match(await bubble.innerText(), /worker-unknown[\s\S]*没有回合开始或结束标记/); + assert.match(await bubble.innerText(), /worker-older[\s\S]*最近回合已结束[\s\S]*1.5 小时/); + await page.keyboard.press("Escape"); + await bubble.waitFor({state: "hidden"}); + await help.focus(); + await bubble.waitFor({state: "visible"}); + await page.keyboard.press("Escape"); + assert.equal(await help.evaluate(element => element === document.activeElement), true); + assert.equal(new URL(page.url()).searchParams.get("goalId"), selectedBeforeHelp, "Help never navigates or starts work"); + const unavailable = await goalRow(page, "Research Monitor"); + const unknownState = unavailable.locator("small"); + assert.equal(await unknownState.innerText(), "状态不可读"); + assert.equal(await unavailable.locator(".personal-goal-window-badge").count(), 0, "An all-unknown status is stated once"); + assert.equal(await unknownState.evaluate(element => { + const probe = document.createElement("span"); probe.style.color = "var(--pw-muted)"; element.append(probe); + const neutral = getComputedStyle(element).color === getComputedStyle(probe).color; probe.remove(); return neutral; + }), true, "Missing observation has a neutral color"); + assert.equal(await unavailable.locator(".personal-goal-mark.is-live").count(), 0); + await unavailable.getByRole("button", {name: "活动观测 · Research Monitor", exact: true}).click(); + const unknownHelp = page.getByRole("tooltip").filter({hasText: "unreadable-0"}); + await unknownHelp.waitFor({state: "visible"}); + assert.match(await unknownHelp.innerText(), /尚不提供本机活动观测/); + assert.match(await unknownHelp.innerText(), /未找到绑定的线程/); + assert.match(await unknownHelp.innerText(), /无法读取宿主的本机记录/); + assert.match(await unknownHelp.innerText(), /无法识别宿主记录格式/); + await page.keyboard.press("Escape"); + assert.doesNotMatch(await (await goalRow(page, "Multi Agent Projection")).locator("small").innerText(), /执行中|观测缺口|状态不可读/, + "Another Goal's completed activity stays isolated"); + await page.screenshot({path: resolve(outputDir, "goal-activity-observation-desktop.png"), animations: "disabled"}); + await page.setViewportSize({width: 390, height: 844}); + await page.locator(".personal-mobile-menu").click(); + assert.ok(await leadVisible(running, "执行中")); + await help.click(); + await bubble.waitFor({state: "visible"}); + const bounds = await bubble.boundingBox(); + assert.ok(bounds.x >= 0 && bounds.x + bounds.width <= 390, "The same disclosure fits the narrow sidebar"); + await page.screenshot({path: resolve(outputDir, "goal-activity-observation-mobile.png"), animations: "disabled"}); + await page.keyboard.press("Escape"); + await page.evaluate(() => localStorage.setItem("loopx-pw-locale", "en")); + await page.setViewportSize({width: 1512, height: 982}); + await page.reload({waitUntil: "networkidle"}); + const englishRunning = await goalRow(page, "Product Release"); + assert.match(await englishRunning.locator("small").innerText(), /^Running[\s\S]*gaps · 2/); + await englishRunning.getByRole("button", {name: "Observed activity · Product Release", exact: true}).click(); + assert.match(await page.getByRole("tooltip").filter({hasText: "worker-unknown"}).innerText(), /Execution remains unknown/); + await page.keyboard.press("Escape"); + api.hostThreadActivity["product-release"] = {completeness: "complete", threads: [thread("idle", minutesAgo(1))]}; + await page.reload({waitUntil: "networkidle"}); + const recovered = await goalRow(page, "Product Release"); + await recovered.locator(".personal-goal-window-badge").waitFor({state: "hidden"}); + assert.doesNotMatch(await recovered.locator("small").innerText(), /Running|state unavailable|gaps/); + assert.equal(await recovered.locator(".personal-goal-window-badge, .personal-goal-mark.is-live").count(), 0, + "A refreshed complete ended-turn observation clears the old gap without inventing execution"); + } finally { + coverageEntries.push(...await gaps.close()); + } + for (const completeness of ["incomplete", undefined, "unrecognized"]) { const partial = await openWorkspacePage(browser, url, { collectCoverage, @@ -218,7 +310,7 @@ export const goalActivityScenario = { try { const { api, page } = partial; const queued = await goalRow(page, "Multi Agent Projection"); - await queued.locator("small", { hasText: "执行情况以宿主为准" }).waitFor({ timeout: 15_000 }); + await queued.locator("small", { hasText: "状态不可读" }).waitFor({ timeout: 15_000 }); assert.doesNotMatch(await queued.locator("small").innerText(), /线程空闲|执行中/, "A partial or legacy sample cannot prove every bound thread idle"); assert.match(await (await goalRow(page, "Product Release")).locator("small").innerText(), /^执行中/, "A positive open-turn observation remains usable with partial coverage"); assert.doesNotMatch(await (await goalRow(page, "Research Monitor")).locator("small").innerText(), /线程空闲|执行中/, "An unrecognized record never becomes idle or running");